普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月9日掘金 前端

完成前端时间处理的另一块版图

作者 mumuy
2026年2月8日 23:34

之前的文章《为了解决时间问题,我开源了两个库》提到了,我为了处理日常中遇到的时间处理问题,针对性的开源了两个库:时间格式化处理库datex和日历转化(万年历); 这2个库分别解决了我在开发中遇到的时间处理和日历转换的问题,然而这还不够,我在想还有哪些地方遇到不好解决的时间相关问题呢?

有了!时间选择器!之前的万年历仅仅解决日历的展示问题,真正在产品开发中,更多的场景是如何更方便的选取时间。虽然,市面上并不缺少类似的库,可以说比比皆是。可奈何我喜欢造轮子呢~我需要一个属于自己的前端工具库宇宙。当然了,解决开发中实际遇到的问题同样是目的之一。

我认真盘点了下自己的需求:

  1. 我需要这样一个时间选择器组件,它只有一个文件,可以同时兼容日期选择、时间选择、日期和时间同时选择,同时它还能选择范围。
  2. 时间选择器少不了自定义有效选择范围,就是可以在组件中限制你能选择的范围,这样可以减少用户额外的有效值筛选。这个限定可能不是具体的范围,而是重复性的范围,比如具体星期几,周末,工作日,几点到几点。
  3. 我需要它可以自定义格式,就是value的值可以自己定义,比如:2026-01-01,2026/01/01,2026年01月01日。我不想每次选择完以后,还要手工转化成自己想要的格式。

我从时间格式化库datex中移植来了内部需要用的时间处理方法;又从日历转化库(万年历) 中移植来了日历组件的部分构成;“缝合”成了新的项目 - 日期时间选择器

QQ_1770444571115.png

QQ_1770564698027.png

这个组件库的使用也特别简单,只需引入一个脚本库,加一个标签即可。

<!-- 日期和时间 -->
<widget-datetime placeholder="请选择日期和时间" value="2026-01-01" range="[['09:00','18:00']]" limit="['weekday']"></widget-datetime>

<!-- 日期 -->
<widget-date placeholder="请选择日期" value="2026-01-01"></widget-date>

<!-- 时间 -->
<widget-time placeholder="请选择时间" value="09:00" range="[['09:00','18:00']]"></widget-time>

<!-- 日期和时间范围 -->
<widget-datetime-range placeholder="请选择日期和时间" value="" range="[['09:00','18:00']]" limit="['weekday']"></widget-datetime-range>

<!-- 日期范围 -->
<widget-date-range placeholder="请选择日期" value=""></widget-date-range>

<!-- 时间范围 -->
<widget-time-range placeholder="请选择时间" value="" range="[['09:00','18:00']]"></widget-time-range>
属性 参考值 说明
placeholder '请选择日期' 为空时占位提醒
value '2026-01-01' 默认值
pattern 'YYYY年MM月DD日'

格式化值

YYYY - 年

MM - 月

DD - 日

HH - 小时

mm - 分钟

ss - 秒

range [['2026-01-01','2026-06-30']]

限制选择范围

[['2026-01-01','2026-06-30']] - 限制日期

[['09:00','21:00']] - 限制时间

[['2026-01-01',null]] - 单向限制

limit ['weekend']

限制类型

weekend - 周末

weekday - 工作日

sunday/monday/tuesday/wednesday/thursday/friday/saturday - 单向限制

jwt介绍

2026年2月8日 22:26

前言

很多人不了解 JWT(JSON Web Token)啥意思,甚至jwt、cookie、token(令牌)、session、单点登录是啥搞不清,这篇文章以 JWT 介绍为引,顺便简单区分一下他们之间的关系,总而言之,这篇文章不白看

介绍

上面讲的实际上都是单点登录过程中的参与角色之一,这也是统一身份认证环境系统中的一部分

我们从一个角度开始分析他们之间的关系

  1. 我们都知道 http 是无状态协议,那么无状态的情况,服务器作为一个用户管理中心,他怎么知道我们我们用户是谁操作的呢(怎么区分用户呢)
  2. 很明显根据用户账号,而我们发现用户并不是每次都登陆,很麻烦,于是我们进一步追溯到了用户注册用户时,会在数据库中生成我们互联网用户的唯一标识,这个标识就是我们的用户id(互联网的身份证号),早期就是直接使用用户id来区分我们的身份
  3. 当使用账号密码登录后,客户端就会获取到用户的id,存放在内存中,后续对于需要确认用户的的操作传递用户id即可
  4. 那么问题来了,直接使用用户id不太好,不安全,再加上一些其他常用数据原因,于是出现了session,也就是服务端保存用户常用信息,使用一个session 关联用户,session id发送给用户,用户拿着session 给服务端,服务端确认身份,拿着信息直接操作,可是这样管理 session,用户一多服务端压力还会变大,也衍生出了redis 的操作,现在仍然受用
  5. 当然也出现了更加主流的身份令牌,发放一个期限的 token令牌 来代替我们的用户id,也就是身份令牌,这样就算token 泄露了,也不会长期被持有,风险大幅度降低,有条件再加上防篡改操作,这样 jwt 结构就出现了,后续就让用户拿着这些信息访问即可
  6. 那还有一个问题,并且我们客户端一旦不小心中途退出,重新进入又要重新输入账号密码,麻烦,需要用户本地保存一下令牌,这也是从session 方案就开始的存储方式了,那么令牌怎么存放呢,浏览器有cookie,localStore 的方式,于是出现了cookie + session 的方式,同时还有移动端的纯token方式,双token等
  7. 上面签名令牌后续免认证过程就是单点登录的核心逻辑,是统一身份认证子集,统一身份认证还包含整个签名过程、权限等更复杂功能

ps:当然身份系统打通的话,一个token便可以分别使用到多个平台

那么单点登录解决了什么问题?

答:单点登录,在这个系统中,同一个用户只需要登录一次,后续就可以拿着登录后签发的令牌免认证操作,减少了很多麻烦

cookie 是啥?

答:cookie是浏览器保存服务器令牌的一种方式,可以在浏览器中持久存放,即使用户不小心关闭标签,下次仍然可以在请求中取出它亮出我们的身份,当然如果过期了,就不行了需要重新登录签名

session是啥?

session是服务端的一个缓存,一种key-value结构,key则是生成的sessionid,用于给用户,value是用户信息或者令牌,可以根据用户传递过来的sessionid来直接获取到用户的信息

jwt

JWT(JSON Web Token) 实际上就是一种token令牌的生成方式,其主要组成方式如下所示:

  • 头部结构,主要保存了加密算法
  • 荷载payload,保存了我们的内容
  • Signature,加密结果,用于后端对比,避免有人篡改荷载信息

看了上面的结构,相信也能知道什么是jwt了,且jwt的内容是接近明文的,只是有了加密串防篡改,相对也安全了不少,且里面也有时间戳,能设置过期时间,算是很方便了

后端可以直接获取jwt,直接验证内容是否串改,里面的token可以直接使用了,一般token和用户信息会有一个对照表,直接使用token对照用户信息操作即可

最后

他们之间的关系大致如此,更细的没有多介绍,到了这里更细的只要稍微查一下,就能查到,本篇文章就介绍到这里了

富文本编辑器在 AI 时代为什么这么受欢迎

作者 Moment
2026年2月8日 22:21

大家好👋,我是Moment,目前我正在使用 NextJs,NestJs,langchain 开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点,如果你对这个项目感兴趣,可以添加我微信 yunmz777 了解更多详细的信息,如果觉得不错欢迎 star ⭐️⭐️⭐️。

在 2026 年的今天,富文本编辑器已经不再是单纯的打字框,它演变成了人类与 AI 协作的核心战场。

如果说过去十年是 Markdown 和纯文本的极客复兴,那么 AI 时代的到来,则让富文本编辑器重新夺回了统治地位。

AI 就在光标处

在 AI 普及之前,我们写作是写一段,去 ChatGPT 问一段,再复制回来。这种上下文切换是效率的杀手。

现在的富文本编辑器,如 Notion AI、Lex、WPS AI 等,将 AI 直接植入光标。你只需输入斜杠或空格,AI 就能根据前文自动续写、润色或改变语气。富文本编辑器能够理解文档的层级结构,包括标题、段落、列表,这让 AI 能更精准地执行总结这一段或把这部分转成表格的操作。

结构化数据的转换站

AI 最擅长的事情之一,就是将非结构化信息转化为结构化内容。富文本编辑器的块状结构完美适配了这一点。

你丢给 AI 一堆乱七八糟的会议纪要,富文本编辑器能瞬间将其渲染成带有看板、待办列表和甘特图的精美文档。AI 生成的不再只是文字,还有图表、代码块,甚至动态组件。富文本编辑器是承载这些复杂对象的最佳容器。

终结空白页恐惧症

对于创作者来说,最痛苦的是面对一张白纸。AI 时代的富文本编辑器变成了半自动驾驶。

AI 不再是取代作者,而是成了最好的二号位。它帮你打草稿,你负责做决策和注入灵魂。输入一个主题,AI 自动生成大纲和初稿,用户的工作从无中生有变成了审阅与精修。当你逻辑断层时,AI 可以在侧边栏提醒你,甚至帮你查找事实数据,省去了反复跳出窗口搜资料的麻烦。

传统与 AI 原生的分野

传统富文本编辑器的定位是静态记录工具,核心交互靠键盘输入和顶部工具栏,内容处理停留在简单的字体加粗、颜色修改,逻辑理解仅能识别字符和 HTML 标签,扩展性依赖插件系统。

AI 原生富文本编辑器的定位是动态协作伙伴和内容引擎,核心交互靠自然语言指令和斜杠命令,内容处理涵盖自动排版、风格迁移、跨语言同步翻译,逻辑理解能把握段落意图、自动提取任务项,扩展性支持 AI Agent 接入,可调用外部 API 填充数据。

协作维度的升华

以前的协作是人与人在文档里留言。现在的协作是人、AI、人三者联动。

当你打开一份长文档,AI 会为你总结其他人修改了什么。团队中的成员写出的内容风格不一时,AI 可以一键将全篇统一为公司标准公文包风格或互联网黑话风格。

降低专业感的门槛

富文本编辑器通过 AI 让普通人也能做出有大片感的内容。

以前需要懂点设计才能排得好看,现在只需说帮我把这个方案做得像麦肯锡的报告,编辑器会自动调整字体间距、引用格式和配色方案。语音转文字、文字转视觉,AI 在富文本背后的各种转换逻辑,让创作变得从未如此简单。

为什么不是 Word?

Word 几乎是富文本的代名词,但当我们说 AI 时代富文本编辑器火了时,大家脑子里浮现的通常是 Notion、Lex、Linear 这种现代化的编辑器,而不是那个陪了我们几十年的 Microsoft Word。

原因很简单:Word 是为了打印设计的,而 AI 时代的编辑器是为了信息流动设计的。

纸张思维与块思维

Word 的逻辑本质上是在模拟一张 A4 纸。所有的排版,页边距、分页符、行间距,都是为了最终打印出来好看。

AI 时代的逻辑则是原子化的。现代编辑器如 Notion,每一段话、一张图、一个表格都是一个独立的块。AI 很难理解 Word 那种长达 50 页、格式复杂的 XML 结构。但在块状编辑器里,AI 可以精准地知道:我现在的任务是只针对这一个待办事项块进行扩充,或者把这个文本块转化成代码块。这种精细度的控制,Word 很难做到。

功能堆砌与对话驱动

Word 拥有成千上万个功能,埋藏在密密麻麻的菜单栏里。在 Word 里用 AI,你得去点插件、点侧边栏。

现代编辑器奉行极简主义。界面通常只有一张白纸,所有的功能都通过一个斜杠指令或 AI 对话框呼出。在 Word 里,你是找功能;在 AI 编辑器里,你是下指令。当 AI 已经能帮你调格式、改语气时,Word 顶端那几百个图标反而成了视觉噪音。

数据结构的开放性

Word 是孤岛。docx 文件是一个封装好的压缩包。虽然现在有云端版,但它的数据很难实时与其他工具,如任务管理、数据库、代码库,无缝打通。

现代富文本编辑器往往是 All-in-one。AI 在编辑器里写完一个方案,可以直接将其中的任务转化为看板上的卡片。Word 的数据相对静态,而现代编辑器的内容是活着的,AI 可以轻松地跨页面、跨库调用数据。

协作的实时性与轻量化

Word 诞生于离线时代。即便现在的 OneDrive 协作已经进步很大,但其底层的冲突合并机制依然不如原生 Web 编辑器流畅。AI 时代的创作往往是高频次、碎片化、多人多机协作的,现代富文本编辑器原生支持网页访问,AI 可以在你和同事讨论时实时介入,这种流畅度是老牌软件难以企及的。

从设计目标看,Microsoft Word 面向排版与打印,内容单位是页面;AI 原生编辑器如 Notion、Lex 面向思考与协作,内容单位是块或组件。Word 的 AI 角色是辅助插件,Copilot 是外挂;AI 编辑器的 AI 是核心驱动力,是原生系统的一部分。典型动作上,Word 用户设置页边距、调整字体大小;AI 编辑器用户用斜杠总结、空格键续写。视觉感受上,Word 像是一本沉重的精装书,AI 编辑器像是一张无限延伸的草稿纸。

心理层面的创作压力

这听起来很玄学,但真实存在。

打开 Word,你会觉得自己在写公文,潜意识里会去纠正格式;打开 Notion 或 Lex,你会觉得自己在记录想法。AI 最强大的地方在于辅助灵感爆发,而现代富文本编辑器那种无负担的界面,比 Word 更适合作为 AI 的载体。

结语

当然,Word 也在努力。微软推出的 Microsoft Loop 就在全盘致敬这种块状编辑器逻辑,试图把 Word 的强大功能塞进 AI 时代的瓶子里。

富文本编辑器在 AI 时代受欢迎,是因为它已经从一个容器进化成了一个理解器。它不再只等着你喂数据,而是开始主动帮你处理、组织和美化信息。

当人类与 AI 的边界越来越模糊,那个让我们与智能体并肩写作的编辑器,或许才是这个时代真正的创作伙伴。

【节点】[DiffusionProfile节点]原理解析与实际应用

作者 SmalBox
2026年2月8日 22:10

【Unity Shader Graph 使用与特效实现】专栏-直达

扩散配置文件节点是高清渲染管线(HDRP)中一个专门用于处理次表面散射效果的重要工具。在Shader Graph中使用此节点,开发者能够轻松地集成和采样扩散配置文件资源,为材质实现逼真的皮肤、蜡、大理石等半透明物体的渲染效果。次表面散射是光线穿透半透明材质表面并在内部散射后从不同位置射出的物理现象,这种效果对于创造真实感渲染至关重要。

在现代实时渲染中,次表面散射效果的实现需要平衡视觉质量和性能消耗。Unity的HDRP通过扩散配置文件提供了一种标准化的方法来处理这种复杂的光学现象。扩散配置文件节点作为Shader Graph与这些配置文件之间的桥梁,使得即使没有深厚图形编程背景的艺术家也能创建出高质量的次表面散射材质。

节点基础概念与工作原理

扩散配置文件节点的核心功能是输出一个唯一的浮点标识符,该标识符在着色器执行过程中用于查找对应的扩散配置文件资源。这种设计允许HDRP在渲染时高效地访问复杂的散射参数,而不需要在着色器中直接嵌入大量数据。

当在Shader Graph中创建扩散配置文件节点时,需要为其指定一个扩散配置文件资源。这个资源包含了描述材质如何散射光线的物理参数。节点输出的浮点值实际上是资源在内部数据库中的索引,HDRP使用这个索引在预计算的查找表中找到相应的散射数据。

节点的工作流程可以分为以下几个步骤:

  • 在编辑阶段,艺术家或开发者将扩散配置文件资源分配给节点
  • 节点生成对应的唯一标识符(浮点值)
  • 在运行时,着色器使用这个标识符查询散射参数
  • HDRP根据查询结果应用相应的次表面散射模型

这种间接引用机制的优势在于:

  • 允许多个材质共享同一扩散配置文件,减少内存占用
  • 简化着色器代码复杂度,提高可读性和维护性
  • 提供统一的参数管理界面,便于调整和优化

创建与配置扩散配置文件节点

在Shader Graph中添加扩散配置文件节点是一个直观的过程。首先需要在Shader Graph编辑器的创建节点菜单中定位到该节点。可以通过以下步骤完成:

  • 在Shader Graph编辑器的空白区域右键点击,打开节点创建菜单
  • 在搜索框中输入"Diffusion Profile"或浏览至HDRP类别下找到该节点
  • 点击节点名称将其添加到图中

添加节点后,最重要的步骤是将其与实际的扩散配置文件资源关联起来。在节点的检视面板中,可以看到一个资源引用字段,需要在此处指定一个已创建的扩散配置文件。如果项目中没有合适的扩散配置文件,需要先创建该资源。

创建扩散配置文件资源的过程:

  • 在Project视图中右键点击,选择Create > Rendering > HDRP Diffusion Profile
  • 为新资源命名并调整其参数以满足项目需求
  • 返回Shader Graph,将新创建的扩散配置文件资源拖拽到节点的对应字段中

配置扩散配置文件资源时,需要理解几个关键参数的意义:

  • 散射半径(Scattering Radius):定义光线在材质内部散射的距离,影响散射效果的柔和度和范围
  • 纹理分辨率(Texture Resolution):用于散射预积分纹理的尺寸,更高的分辨率提供更精确的结果但增加内存使用
  • 散射颜色(Scattering Color):影响散射光线的色调,通常设置为材质的主色调或血液颜色(对于皮肤)
  • 权重参数(Weight Parameters):控制不同类型散射的贡献程度,允许微调散射效果的外观

正确配置这些参数对于获得理想的视觉效果至关重要。例如,在创建人类皮肤材质时,通常需要相对较小的散射半径和偏红的散射颜色,以模拟皮肤下血管的效果。

节点端口详解与数据流

扩散配置文件节点仅有一个输出端口,标记为"Out"。这个端口的输出类型是浮点数,但其含义远超过普通的数值。理解这个输出值的本质对于正确使用节点至关重要。

输出端口的浮点值实际上是一个经过特殊编码的标识符,它不代表普通的数学值,而是指向内部扩散配置文件数据库的索引。当这个值传递给HDRP的着色器系统时,系统会使用它来查找对应的散射参数集。

由于这个特殊性质,对输出值的数学操作需要格外小心:

  • 将输出值乘以0会有效地禁用扩散配置文件,因为结果不再对应任何有效的配置文件索引
  • 将输出值乘以1会保持原样,继续使用关联的扩散配置文件
  • 其他数学操作可能导致未定义行为,因为结果值可能不对应任何已注册的配置文件

在Shader Graph中连接扩散配置文件节点时,通常应将其输出直接连接到主节点的Diffusion Profile输入槽。这种直接连接确保标识符不被意外修改,保证HDRP能够正确识别和使用扩散配置文件。

在某些高级用例中,开发者可能需要在不同条件下选择使用不同的扩散配置文件。这种情况下,可以使用条件逻辑来控制使用哪个配置文件的标识符。例如,可以使用分支节点根据距离或其他因素在两个不同的扩散配置文件节点输出之间进行选择。但需要注意,HDRP不支持在同一像素上混合多个扩散配置文件,因此这种切换应该是离散的而非连续的。

在真实项目中的实际应用

扩散配置文件节点最常见的应用是创建逼真的皮肤材质。人类皮肤具有复杂的多层结构,每层对光线的散射方式各不相同。使用扩散配置文件可以近似这种效果,而不需要模拟完整的体积散射。

创建真实皮肤材质的步骤:

  • 首先创建或获取一个基础皮肤纹理,包含漫反射颜色、法线信息和其他表面细节
  • 在HDRP中创建扩散配置文件资源,设置适合皮肤的参数:
    • 设置散射半径约为2-5毫米(取决于角色比例和艺术方向)
    • 调整散射颜色为略带红色或橙色的色调,模拟皮下血液的影响
    • 根据目标平台平衡纹理分辨率和质量需求
  • 在Shader Graph中集成扩散配置文件节点,将其输出连接到主节点
  • 可能需要额外调整材质的光泽度和反射属性,以配合散射效果

除了皮肤,扩散配置文件还可用于多种其他材质:

  • 蜡质材料:如蜡烛、奶酪等,通常需要中等散射半径和温和的散射颜色
  • 植物材料:树叶、花瓣等,光透射效果可以通过散射模拟
  • 大理石和玉石:这些矿物材料具有独特的半透明特性
  • 塑料和橡胶:某些类型的塑料具有轻微的次表面散射效果

在实际项目中,性能考虑是必不可少的。次表面散射是一种计算密集型效果,特别是在高分辨率下。对于移动平台或低端硬件,可能需要减少散射采样次数或使用简化的散射模型。HDRP提供了多种质量设置,允许根据目标平台调整散射计算的精度。

高级技巧与最佳实践

掌握扩散配置文件节点的基本用法后,可以探索一些高级技巧来提升材质质量或优化性能。

多层材质技术:

对于特别复杂的材质如真实人类皮肤,单一扩散配置文件可能不足以捕捉所有细节。在这种情况下,可以使用多个材质层,每层使用不同的扩散配置文件。通过精心设计的混合策略,可以创建更加丰富和真实的散射效果。需要注意的是,这种技术会增加渲染成本,应谨慎使用。

性能优化策略:

  • 使用适当的纹理分辨率:对于远处可见的物体,可以使用较低分辨率的散射纹理
  • 限制使用散射的物体数量:只为对视觉影响最大的物体启用高质量的次表面散射
  • 利用HDRP的质量设置:根据目标平台调整全局散射质量
  • 考虑使用简化的散射模型:对于某些材质,近似散射效果可能就足够了

与其它HDRP功能集成:

扩散配置文件节点可以与其他HDRP特性结合使用,创建更加复杂和真实的效果。例如:

  • 与光线追踪结合:HDRP的光线追踪次表面散射可以提供更准确的物理效果,但性能成本更高
  • 与后期处理效果配合:适当的颜色分级和色调映射可以增强散射效果的视觉冲击力
  • 与光照系统协同:正确设置场景光照对于展现散射效果至关重要,特别是背光和边缘光情况

调试和问题解决:

当散射效果不如预期时,可以使用以下方法进行调试:

  • 检查扩散配置文件资源是否正确分配给了节点
  • 验证节点输出是否正确地连接到了主节点
  • 使用HDRP的调试视图可视化散射效果,如散射 albedo 或散射半径
  • 确保材质使用了正确的着色器类型,某些着色器可能不支持次表面散射

常见问题与解决方案

在使用扩散配置文件节点时,开发者可能会遇到一些典型问题。了解这些问题及其解决方案可以帮助节省调试时间。

节点输出值为0或无效:

这通常表示节点没有正确配置扩散配置文件资源。检查节点检视面板中的资源引用字段,确保已分配有效的扩散配置文件。如果资源已被删除或移动,需要重新分配。

散射效果不明显或过强:

这通常是由于扩散配置文件参数设置不当造成的。调整散射半径和散射颜色可以显著改变效果的外观。记住,散射半径的单位是米,因此对于小物体(如游戏角色),值通常在0.001到0.01范围内。

性能问题:

如果启用次表面散射后帧率显著下降,考虑以下优化措施:

  • 减少散射采样次数(在HDRP资产设置中调整)
  • 降低扩散配置文件的纹理分辨率
  • 只为近距离可见的物体使用高质量散射
  • 使用HDRP的LOD系统,根据距离切换不同质量的散射效果

平台兼容性问题:

虽然扩散配置文件节点专为HDRP设计,但在不同平台上可能有不同的表现。特别是在移动设备上,某些高级散射功能可能不可用。使用HDRP的平台特定设置可以确保在所有目标设备上获得一致的行为。

与自定义着色器代码的集成:

对于需要超出Shader Graph功能的高级用例,可能需要将扩散配置文件与自定义HLSL着色器代码结合使用。在这种情况下,需要了解HDRP如何内部处理扩散配置文件标识符,并确保自定义代码与HDRP的散射系统正确交互。

扩散配置文件节点的未来发展趋势

随着实时渲染技术的不断进步,扩散配置文件节点和相关的次表面散射功能也在持续演化。了解这些趋势可以帮助开发者更好地规划长期项目。

实时全局光照与散射的集成:

未来的HDRP版本可能会更紧密地集成次表面散射与全局光照系统,允许散射光线影响周围环境,实现更加真实的材质交互。

机器学习加速的散射模型:

机器学习技术正在被越来越多地用于实时渲染的各个领域。未来可能会看到基于神经网络的散射模型,能够在保持高质量的同时大幅降低计算成本。

更高效的混合渲染技术:

随着混合渲染器(如HDRP的Hybrid Renderer)的成熟,次表面散射可能会受益于新的渲染架构,在保持视觉质量的同时提高性能。

艺术家友好的工具改进:

Unity一直在努力使复杂渲染技术更易于艺术家使用。未来可能会看到扩散配置文件节点的改进界面,更直观的参数控制和实时预览功能。

跨管线兼容性:

虽然目前扩散配置文件节点仅适用于HDRP,但未来可能会看到类似功能在URP中的实现,使更多项目能够利用高质量的次表面散射效果。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用

作者 Cobyte
2026年2月8日 21:45

1. 引言

在大语言模型(LLM)快速发展的今天,几乎所有产品都在借助大模型进行重塑与升级。在过去一段时间,各类旨在提升效率的 AI Agent 如雨后春笋般涌现,尤其是 Coding Agent 的兴起,在一定程度上对前端开发者的职业前景带来了冲击与挑战。一些走在行业前沿的公司甚至开始提倡“前后端再度融合”,这意味着未来开发者可能需要向具备 AI 能力的全栈工程师转型。因此,掌握 AI 全栈相关的知识与技能变得非常重要。

本文将带你通过实战,从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。另外我们将使用 DeepSeek 作为底层模型进行学习。

技术栈前瞻

  • 后端: Python 3, FastAPI (Web 框架), LangChain (LLM 编排), Uvicorn (ASGI 服务器)
  • 前端: Vue 3, TypeScript, Vite (构建工具)
  • 模型: DeepSeek API (兼容 OpenAI 格式)

我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

2. 为什么选择 Python ?

在 AI 领域,Python 无疑是首选的开发语言。因此,如果想通过学习 AI 全栈技术来获得一份理想工作,掌握 Python 几乎是必经之路。这就像在国内想从事后端开发,Java 绝对是不二之选。对于前端背景的同学而言,虽然也可以通过 Node.js 入门 AI 开发,但就整体就业前景和发展空间来看,跟 Node.js 相比 Python 的优势也是断层领先。同时,Python 作为一门入门门槛较低的语言,学习起来相对轻松,所以大家无需过于担心学习难度问题。

最后本人提倡在实战中学习 Python,并且完全可以借助 AI 进行辅导学习。

2. Python 环境配置

我们这里只提供 Windows 环境的讲解,其他的环境自行 AI,Python 的环境搭建还是十分简单的。

  1. 访问官网下载安装包

www.python.org/downloads/

选择对应的平台版本:

image.png

  1. 安装时勾选 "Add Python to PATH"

image.png

  1. 验证安装

打开终端命令工具输入以下命令行:

python --version
pip --version

出现如下信息则说明安装成功了。

image.png

最后编辑器我们可以选择 VS Code,只需在拓展中安装以下插件即可。

image.png

我们前面说到了我们是使用 DeepSeek 作为底层模型进行学习,所以我们需要去 DeepSeek 的 API 开放平台申请一个大模型的 API key。申请地址如下:platform.deepseek.com/api_keys 。当然我们需要充一点钱,就充几块也够我们学习用了。

3. Python 快速入门

3.1 Hello World

我们创建一个 simple-llm-app 的项目目录,然后在根目录创建一个 .env 文件,用于存放项目的环境变量配置,跟前端的项目一样。我们这里设置上面申请到的 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

然后我们可以通过 python-dotenv 库读从 .env 文件中读取它,我们创建一个 test.py 的文件,里面的代码如下:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 打印
print(os.getenv("DEEPSEEK_API_KEY"))

其中 dotenv 库需要安装 python-dotenv 依赖,安装方法也跟安装 npm 包类似,命令如下:

pip install python-dotenv

接着执行 test.py 文件,执行命令跟 Node.js 类似:

python test.py

我们就可以在命令终端看到 .env 文件中的 DeepSeek API key 了。这算是成功输出了 Python 的 Hello world。

3.2 Python 语法入门

接着我们继续了解 Python 的相关语法。在 Python 中,使用 from ... import ...,在 ES6 JavaScript 中,我们使用 import ... from ...。 所以上述代码的 import os -> 类似于 Node.js 中的 import * as os from 'os'os 是一个内置库。 from dotenv import load_dotenv 则类似于从 npm 包中导入一个类,比如: import load_dotenv from 'dotenv'

Python:没有显式的变量声明关键字,直接通过赋值创建变量。

# Python - 直接赋值,无需关键字
name = "张三"
AGE = 25 # 常量(约定)没有内置的常量类型,但通常用全大写变量名表示常量,实际上可以修改
is_student = True

JavaScript:使用 varlet 或 const 声明变量。

// JavaScript - 必须使用关键字
let name = "张三";
const age = 25;  // 常量 使用 `const` 声明常量,不可重新赋值。
var isStudent = true;  // 旧方式

注释对比

Python注释:

  • 单行注释:以 # 开头
# 这是一个Python单行注释
name = "张三"  # 这是行尾注释
  • 多行注释:可以使用三个单引号 ''' 或三个双引号 """ 包裹
'''
这是一个Python多行注释
可以跨越多行
实际上这是字符串,但常用作注释
'''

"""
双引号三引号也可以
这在Python中通常用作文档字符串(docstring)
"""

JavaScript 注释:

  • 单行注释:以 // 开头
// 这是一个JavaScript单行注释
let name = "张三";  // 这是行尾注释
  • 多行注释:以 /* 开头,以 */ 结尾
/*
 这是一个JavaScript多行注释
 可以跨越多行
 这是真正的注释语法
*/


/**
 * 用户类,表示系统中的一个用户
 * @class
 */
class User {
}

好了我们不贪杯,实战中遇到不同的 Python 语法,我们再针对学习或者借助 AI 通过与 JavaScript 语法进行横向对比,对于有一定编程基础的我们,肯定非常容易理解的。相信通过上述 Python 语法的学习,聪明的你再回头看上述示例的 Python 代码,肯定可以看懂了。

我们这里只是简单介绍上面代码中涉及到的 Python 语法,本人推荐在实战中进行学习。更多 JavaScript 视觉学习 Python:langshift.dev/zh-cn/docs/…

3.3 FastAPI 框架快速入门

3.3.1 FastAPI 是什么

FastAPI 是一个现代、高性能(与 NodeJS 和 Go 相当)的 Web 框架,用于构建 API,基于 Python 3.6+ 并使用了标准的 Python 类型提示。但它本身不提供完整的 Web 服务器功能,而是通过 ASGI(Asynchronous Server Gateway Interface)与服务器进行通信。

Uvicorn 是一个高性能的 ASGI 服务器,它支持异步编程,能够运行 FastAPI 这样的异步 Web 应用。所以 FastAPI 需要配合 Uvicorn 使用,这样才能够充分发挥 FastAPI 的异步特性,提供极高的性能。同时,Uvicorn 在开发和部署时都非常方便。

简单来说

  • FastAPI 负责:路由、验证、序列化、依赖注入等应用逻辑
  • Uvicorn 负责:HTTP 协议解析、并发处理、连接管理等服务器功能

两者结合形成了现代 Python Web 开发的黄金组合,既能享受 Python 的便捷,又能获得接近 Go、Node.js 的性能。

3.3.2 基本示例

我们创建一个 server.py 文件,输入以下示例代码:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。"}

# 程序的入口点
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.1.1.1", port=9527)

上述代码引用了两个依赖 fastapi 和 uvicorn,我们通过 pip 进行安装一下:

pip install fastapi uvicorn

然后我们在终端启动服务:python server.py,运行结果如下:

image.png

接着我们在浏览器打开 http://127.1.1.1:9527 显示如下:

image.png

3.3.3 路径参数和查询参数

示例:

@app.get("/items/{id}")
def read_item(
    id: int, 
    limit: int = 10,         # 默认值
    q: Optional[str] = None, # 可选参数
    short: bool = False,     # 默认值
    tags: List[str] = []     # 列表参数
):
    item = {"id": id, "limit": limit, "tags": tags}
    if q:
        item.update({"q": q})
    if not short:
        item.update({"desc": "长说明"})
    return item

重启服务,在浏览器输入:http://127.1.1.1:9527/items/1?q=cobyte ,结果如下:

image.png

总结

  • 路径参数:在路径中声明的参数,如 id
  • 查询参数:作为函数参数,但不是路径参数,将自动解释为查询参数。
3.3.4 FastAPI 中的模型定义

在 FastAPI 中,我们经常需要处理来自客户端的请求数据,例如 POST 请求的 JSON 体。为了确保数据的正确性,我们需要验证数据是否符合预期的格式和类型。使用 Pydantic 模型可以让我们以一种声明式的方式定义数据的结构,并自动进行验证。

Pydantic 是一个 Python 库,用于数据验证和设置管理,主要基于 Python 类型提示(type hints)。它可以在运行时提供类型检查,并且当数据无效时提供详细的错误信息。

Pydantic 的核心功能是定义数据的结构(模型),并自动验证传入的数据是否符合这个结构。它非常适用于以下场景:

  • 验证用户输入(例如 API 请求的数据)
  • 配置管理
  • 数据序列化和反序列化(例如将 JSON 数据转换为 Python 对象)

Pydantic 模型使用 Python 的类来定义,类的属性使用类型注解来指定类型,并且可以设置默认值。

请求体(Request Body)和响应模型(Response Model)的示例如:

from pydantic import BaseModel, validator, Field
from typing import Optional, List
import re

# 请求体(Request Body)
class UserRequest(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    password: str
    email: str
    @validator('username')
    def username_alphanumeric(cls, v):
        if not re.match('^[a-zA-Z0-9_]+$', v):
            raise ValueError('只能包含字母、数字和下划线')
        return v
    
    @validator('email')
    def email_valid(cls, v):
        if '@' not in v:
            raise ValueError('无效的邮箱地址')
        return v.lower()  # 转换为小写
    
    @validator('password')
    def password_strong(cls, v):
        if len(v) < 6:
            raise ValueError('密码至少6位')
        return v
# 响应模型(Response Model)
class UserResponse(BaseModel):
    username: str
    email: str

@app.post("/user/", response_model=UserResponse)
async def create_user(user: UserRequest):
    # 密码会被过滤,不会出现在响应中
    return user

FastAPI 会自动从 Pydantic 模型生成 API 文档,我们在 server.py 文件中添加了上述示例之后,重启服务,访问 http://127.1.1.1:9527/docs 可以看到:

image.png

并且我们还可以在文档地址中进行测试,这里就不展开讲了。

3.3.5 异步和中间件

示例:

from fastapi import Request

@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Process-Time"] = str(process_time)
    return response

我们可以看到 Python 的这个异步语法跟 JavaScript 的 async/await 是一样的语法。

3.3.6 CORS 配置

通过设置 CORS 配置允许前端跨域访问。

from fastapi.middleware.cors import CORSMiddleware
# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],  # 允许的方法
    allow_headers=["*"],  # 允许的头部
)

到此本文所用到的 FastAPI 知识就基本介绍完毕了,后续再在实战中进行学习,先上了 AI 全栈的车再说。

4. LLM 和 OpenAI 接口快速入门

4.1 入门示例代码

让我们从安装依赖开始,借助 DeepSeek 大模型一起探索 OpenAI 接口规范。

pip install openai

接着我们在 test.py 中添加如下代码:

import os
from dotenv import load_dotenv
# 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()
# 加载 OpenAI 库,从这里也可以看到 Python 的库加载顺序跟 JavaScript ES6 import 是不一样,反而有点像 requrie
from openai import OpenAI

# 初始化客户端
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"), # 身份验证凭证,确保你有权访问 API
    base_url="https://api.deepseek.com" # 将请求重定向到 DeepSeek 的服务器(而非 OpenAI)
)
# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ]
)
# 打印结果
print(response.choices[0].message.content.strip())

终端输出结果如下:

image.png

可以看到我们成功调用了 DeepSeek 大模型。

在 openai 中,返回的 response 对象是一个 Pydantic 模型,如果我们想详细查看 response 返回的结果,可以使用它自带的 .model_dump_json() 方法。

# 使用 model_dump_json 并指定缩进
print(response.model_dump_json(indent=2))

可以看到通过上述方式打印大模型响应的信息如下:

image.png

4.2 choices 字段详解

我们从上面打印的结果可以了知道,大模型返回的文本信息是存储在 choices 字段中的,所以我们来了解一下它。

在调用 chat.completions.create 时,如果设置了 n 参数(n>1),那么模型会生成多个输出,此时 choices 字段就会包含多个元素。每个 choice 代表一个可能的响应,我们可以通过遍历 choices 来获取所有响应。

另外,即使 n=1(默认值),choices 也是一个列表,只不过只包含一个元素。所以我们上述例子中才通过 response.choices[0] 来获取大模型的返回结果。

4.3 流式响应

因为大模型本质上是一个预测生成器,简单来说就是你输入一句话,大模型就预测下一个字。因此我们希望在模型生成文本的同时就显示给用户,提高交互的实时性。这就是流式响应。代码设置如下:

# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ],
+  stream=True, # 启用流式传输
)

+# response是一个生成器,在Python中,生成器是一种迭代器,每次迭代返回一个值。这里,每次迭代返回一个chunk(部分响应)。
+for chunk in response:                           # 1. 遍历响应流
+    if chunk.choices[0].delta.content:           # 2. 检查是否有内容
+        print(chunk.choices[0].delta.content,    # 3. 打印内容
+              end="",                            # 4. 不换行
+              flush=True)                        # 5. 立即刷新缓冲区

输出结果如下:

20260207-132313.gif

4.4 temperature 参数

我个人觉得那么多大模型参数中 temperature 参数还是比较重要的,值得我们了解一下。模型在生成每一个词时,都会计算一个所有可能的下一个词的概率分布(例如,“苹果”概率0.3,“香蕉”概率0.5,“水果”概率0.2)。temperature 的值会影响这个概率分布的形状,从而改变模型最终根据这个分布进行“抽样”选择的结果。

一个简单的比喻:选餐厅吃饭

  • Temperature = 0.0永远只去评分最高、去过无数次的那一家最保险的餐厅。结果最稳定,但永远没有新体验。
  • Temperature = 1.0大多数时候去那家最好的,但偶尔也会根据评价试试附近其他不错的餐厅。平衡了可靠性和新鲜感。
  • Temperature = 1.5经常尝试新餐厅,甚至包括一些评价奇特或小众的地方。体验非常丰富,但有时会“踩雷”。

总结与建议

  1. 追求确定性时调低 (接近0) :当你需要精确、可靠、可复现的结果时,如生成代码、数学推导、事实问答、指令严格遵循。
  2. 追求创造性和多样性时调高 (>1.0) :当你需要创意、多样化表达、故事生成、诗歌时。
  3. 通用场景用中间值 (0.8-1.2) :大多数对话、摘要、分析等任务,这个范围能提供既连贯又有一定灵活性的输出。

4.5 消息角色

在 OpenAI API 中,messages 数组中的每条消息都有一个 role 字段,它定义了消息的来源和用途。消息角色主要有三种:system、user、assistant。此外,在后续的更新中,还引入了 tool 和 function 等角色,但最基础的是前三种。

1. system (系统)

  • 作用: 设置助手的背景、行为、指令等。

  • 特点:

    • 通常作为第一条消息,用于设定对话的上下文和规则。
    • 不是必须的,但可以显著影响助手的行为。
  • 示例:

    {"role": "system", "content": "你是一个专业的翻译助手,只能将中文翻译成英文,其他问题一律不回答。"}
    

2. user (用户)

  • 作用: 用户输入的问题或指令

  • 特点:

    • 代表对话中的人类用户
    • 每个请求必须至少包含一条 user 消息
    • 通常是最后一条消息(除了流式响应)
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个有帮助的助手"},
        {"role": "user", "content": "什么是机器学习?"}
    ]
    

3. assistant (助手)

  • 作用: 代表助手之前的回复。

  • 特点:

    • 在多轮对话中保存历史回复
    • 帮助模型保持对话连贯性
    • 在单轮对话中不需要此角色
  • 示例:

    messages = [
        {"role": "system", "content": "你是一个数学老师"},
        {"role": "user", "content": "2+2等于多少?"},
        {"role": "assistant", "content": "2+2等于4"},
        {"role": "user", "content": "那3+3呢?"}  # 模型知道这是新问题
    ]
    

通过合理组合这些角色,你可以构建从简单问答到复杂多轮对话的各种应用场景。记住:清晰的角色定义和恰当的消息组织是获得高质量回复的基础。我们这里先介绍前三种核心角色。

5. LangChain 入门

5.1 怎么理解 LangChain 框架

从前端的视角来理解,LangChain 就好比是 Vue 或 React 这类框架。在前端开发中,如果没有 Vue 或 React,我们就需要直接编写大量操作浏览器 API 的底层代码;而有了这类框架,它们封装了常见的交互逻辑和状态管理,让开发变得更高效、更结构化。类似地,LangChain 实际上是一套封装了大型语言模型常见操作模式的方案,它帮助开发者更便捷地调用、组合与管理大模型的能力,而无需每次都从头编写复杂的模型交互代码。

5.2 LangChain 调用 LLM 示例

接着我们在项目根目录下创建一个 llm-app.py 文件,输入以下内容:

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv

# 1. 加载环境变量 (读取 .env 中的 DEEPSEEK_API_KEY)
load_dotenv()

# 2. 创建组件
# 相对于上面的使用 OpenAI 的接口,现在经过 LangChain 封装后确实简洁了很多
llm = ChatOpenAI(
    model="deepseek-chat", 
    temperature=0.7,
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1"
)

# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
prompt = ChatPromptTemplate.from_template("{question}")

# 创建解析器
parser = StrOutputParser()
# 将AI响应转换为字符串,通过前面的知识我们知道大模型返回的数据一般包含很多数据,
# 很多时候我们只需要其中的文本内容。`StrOutputParser` 就是用来提取这个文本内容的

# 3. 组合链 (LCEL 语法) Python LangChain 常见的链式调用
chain = prompt | llm | parser
# 等价于:输入 → 模板填充 → AI处理 → 结果解析

# 4. 执行
result = chain.invoke({"question": "你是谁?"})
# 内部执行:填充"你是谁?" → 调用API → 解析响应 → 返回字符串

# 5. 打印结果
print(result)

然后在终端安装对应的依赖(这个步骤跟前端也很像,所以学习 Python 是很简单的):

pip install langchain_openai langchain_core dotenv

接着在终端执行

# 跟前端的 node llm-app.js 等价
python llm-app.py

终端输出结果如下:

image.png

可以看到我们成功执行了一个 Python + LangChain 的应用程序。

5.2 消息模板系统

我们上面的注释讲解了 prompt = ChatPromptTemplate.from_template("{question}") 这句代码默认创建了一个人类角色的提示模板,也就是 {"role": "user", "content": "用户输入的内容"}

LangChain 作为一个强大的 LLM 应用开发框架, 为了让开发者能够精确控制对话的流程和结构,提供了灵活且强大的消息模板系统。LangChain 的消息模板系统基于角色(role)的概念,将对话分解为不同类型的信息单元。目前的类型如下:

角色 用途 对应 OpenAI 角色
SystemMessagePromptTemplate system 系统指令、设定 system
HumanMessagePromptTemplate human 用户输入 user
AssistantMessagePromptTemplate assistant AI 回复 assistant
AIMessagePromptTemplate ai AI 回复(别名) assistant
ToolMessagePromptTemplate tool 工具调用结果 tool
FunctionMessagePromptTemplate function 函数调用结果 function

ChatPromptTemplate 则是消息系统的核心容器,负责协调各种消息类型:

from langchain_core.prompts import (
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
    AssistantMessagePromptTemplate
)
system = SystemMessagePromptTemplate.from_template(...)
human = HumanMessagePromptTemplate.from_template(...)
assistant = AssistantMessagePromptTemplate.from_template(...)
prompt = ChatPromptTemplate.from_messages([system, human, assistant])

所以上述入门实例代码可以进行以下修改:

-from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.prompts import ChatPromptTemplate,HumanMessagePromptTemplate
# 省略...
-# 创建了一个人类角色的提示模板,`from_template` 方法允许我们通过一个字符串模板来定义提示,默认是人类角色。
-prompt = ChatPromptTemplate.from_template("{question}")
+human = HumanMessagePromptTemplate.from_template("{question}")
+prompt = ChatPromptTemplate.from_messages([human])
# 省略...

然后重新在终端执行 python llm-app.py 依然正常输出。

同时通过 LangChain 消息模型来理解大模型的调用过程也变得十分的清晰,所以整个流程是:

输入 → prompt → llm → parser → 输出
     ↓
{"question": "你是谁?"}
     ↓
prompt 处理:创建消息 "你是谁?"
     ↓
llm 处理:调用 LLM 处理,返回 AIMessage 对象
     ↓
parser 处理:提取文本内容
     ↓
最终结果字符串

在 LangChain 中还有一个最基础的模板类 PromptTemplate 用于构建字符串提示。下面我们也来了解一下它的基本用法。

from langchain_core.prompts import PromptTemplate

# 方式1:使用 from_template 类方法(最常用)
prompt = PromptTemplate.from_template("请解释什么是{concept}。")

# 方式2:直接实例化
prompt = PromptTemplate(
    input_variables=["concept"], 
    template="请解释什么是{concept}。"
)

综上所述我们通过理解和掌握 LangChain 这些核心概念,才能高效地构建可靠、可维护的 LLM 应用。此外,LangChain 的消息模板系统仍在不断发展当中,我们需要不断地持续关注。

5.3 LangChain 链式调用(管道操作符)

在 LangChain 中所谓的链式调用是通过管道操作符 | 来实现的,也就是通过 | 实现将一个函数的输出作为下一个函数的输入。

例如上述的示例代码中的:

# LangChain 中的管道操作
chain = prompt | llm | output_parser
  • 等价于手动执行链的每一步:
# 第一步:prompt 处理
messages = prompt.invoke({"question": "你是谁?"})
# messages = [HumanMessage(content="你是谁?")]

# 第二步:llm 处理
response = llm.invoke(messages)
# response = AIMessage(content="我是DeepSeek...")

# 第三步:parser 处理
result = parser.invoke(response)
# result = "我是DeepSeek..."

在标准 Python 语法中,| 是按位或操作符,用于:

  • 整数的按位运算:5 | 3 = 7
  • 集合的并集运算:{1, 2} | {2, 3} = {1, 2, 3}
  • 从 Python 3.10 开始,用于类型联合:int | str

但 LangChain 通过 重载(overload)  | 操作符,赋予了它新的含义:

  • | 在 LangChain 中是一种语法糖,让链式操作更直观
  • 它不是 Python 的新语法,而是通过操作符重载实现的框架特定功能
  • 这种设计让 LangChain 的代码更加简洁和易读

6. LLM 聊天应用后端

6.1 后端架构设计

我们遵循单一职责原则(SRP)进行分层架构设计,将系统划分为API层、业务层和数据层,旨在实现高内聚、低耦合,提升代码的可维护性、可测试性和可扩展性。

API层  专注于处理 HTTP 协议相关的逻辑,包括路由定义、请求验证、响应序列化和跨域处理等。它作为系统的入口点,负责与客户端进行通信,并将业务逻辑委托给下层。这种设计使得我们可以独立地调整 API 暴露方式(如支持 WebSocket)而不影响核心业务逻辑。

业务层  封装 LLM 的核心应用逻辑,例如与 AI 模型的交互、对话历史管理和流式生成等。这一层独立于 Web 框架,使得业务逻辑可以复用于其他场景(如命令行界面或批处理任务)。同时,业务层的单一职责确保了我们能够针对 LLM 交互进行优化和测试,而无需关心 HTTP 细节。

数据层  通过 Pydantic 定义系统的数据模型,包括请求、响应结构和内部数据传输对象。通过集中管理数据模型,我们确保了数据格式的一致性,并便于进行数据验证和类型提示。这种分离使得数据结构的变更更加可控,同时也为生成 API 文档提供了便利。

6.1 实现业务层和数据层

实现业务层其实就是封装 LLM 的核心应用逻辑。通过将复杂的 LLM 调用逻辑、提示工程和流式处理封装在独立的类中,这样 API 层只需关注请求与响应,而无需了解 LangChain 或特定 API 的细节。这使得底层技术栈的迭代或更换(例如从 LangChain 切换到其他操作大模型的框架或更改 LangChain 最新的 API)变得轻而易举,只需修改封装类内部实现,而对外接口保持不变,实现了有效隔离。

创建 ./backend/llm_app.py 文件,内容如下:

import os
from typing import Generator
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 加载环境变量
load_dotenv()

class LLMApp:
    def __init__(self, model_name="deepseek-chat", temperature=0.7):
        """
        初始化 LLM 应用程序
        """
        # 检查 DeepSeek API 密钥
        if not os.getenv("DEEPSEEK_API_KEY"):
            raise ValueError("请在 .env 文件中设置 DEEPSEEK_API_KEY 环境变量")
        
        # 初始化配置
        self.model_name = model_name
        self.temperature = temperature
        self.api_key = os.getenv("DEEPSEEK_API_KEY")
        self.base_url = "https://api.deepseek.com/v1"
        
        # 初始化非流式 LLM (用于普通任务)
        self.llm = self._create_llm(streaming=False)
        
        # 初始化流式 LLM (用于流式对话)
        self.streaming_llm = self._create_llm(streaming=True)
        
        # 输出解析器
        self.output_parser = StrOutputParser()
        
        # 初始化对话链
        self._setup_chains()
    
    def _create_llm(self, streaming: bool = False):
        """创建 LLM 实例"""
        return ChatOpenAI(
            model_name=self.model_name,
            temperature=self.temperature,
            api_key=self.api_key,
            base_url=self.base_url,
            streaming=streaming
        )
    
    def _setup_chains(self):
        """设置处理链"""
        # 带上下文的对话 Prompt
        conversation_prompt = PromptTemplate(
            input_variables=["chat_history", "user_input"],
            template="""你是一个有用的 AI 助手。请根据对话历史回答用户的问题。
            
            对话历史:
            {chat_history}
            
            用户:{user_input}
            助手:"""
        )
        # 注意:这里我们只定义 prompt,具体执行时再组合
        self.conversation_prompt = conversation_prompt

    def format_history(self, history_list) -> str:
        """格式化聊天历史"""
        if not history_list:
            return "无历史对话"
        
        formatted = []
        for msg in history_list:
            # 兼容 Pydantic model 或 dict
            if isinstance(msg, dict):
                role = msg.get('role', 'unknown')
                content = msg.get('content', '')
            else:
                role = getattr(msg, 'role', 'unknown')
                content = getattr(msg, 'content', '')
                
            formatted.append(f"{role}: {content}")
        
        return "\n".join(formatted[-10:])  # 只保留最近 10 条

    def stream_chat(self, user_input: str, chat_history: list) -> Generator[str, None, None]:
        """流式对话生成器"""
        try:
            history_text = self.format_history(chat_history)
            
            # 构建链:Prompt | StreamingLLM | OutputParser
            chain = self.conversation_prompt | self.streaming_llm | self.output_parser
            
            # 执行流式生成
            for chunk in chain.stream({
                "chat_history": history_text,
                "user_input": user_input
            }):
                yield chunk
                
        except Exception as e:
            yield f"Error: {str(e)}"

接下来我们对上述封装的 LLM 类的功能进行测试,测试前先在 ./backend/.env 文件中添加 DeepSeek 开放平台的 API key。

DEEPSEEK_API_KEY=sk-xxxxxx

接着创建 ./backend/test.py 文件写上以下测试代码。

from llm_app import LLMApp

# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"

# 收集流式响应
response_chunks = []
for chunk in llmApp.stream_chat(user_input, chat_history):
    response_chunks.append(chunk)
    # 模拟实时显示
    print(chunk, end="", flush=True)

# 合并响应
full_response = "".join(response_chunks)
print(f"\n完整响应: {full_response}")

测试结果如下:

20260208-172852.gif

接着我们通过 Pydantic 来定义数据的结构(模型)

创建 ./backend/models.py 文件,内容如下:

from pydantic import BaseModel
from typing import List, Optional

class ChatMessage(BaseModel):
    """单条聊天消息"""
    role: str  # "user" 或 "assistant"
    content: str

class ChatRequest(BaseModel):
    """聊天请求模型"""
    message: str
    chat_history: Optional[List[ChatMessage]] = []

修改 ./backend/test.py 文件,内容如下:

import json
import asyncio
from llm_app import LLMApp
from models import ChatRequest, ChatMessage


# 测试
llmApp = LLMApp()

# 模拟聊天历史
chat_history = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮助你的吗?"},
]
# 模拟用户输入
user_input = "请介绍一下人工智能"
# 模拟 SSE 的流式聊天响应
async def chat_stream(request: ChatRequest):
    # 1. 发送开始事件
    yield f"data: {json.dumps({'type': 'start'})}\n\n"
    await asyncio.sleep(0.01) # 让出控制权,以便运行其他任务。
    
    full_response = ""
    
    # 2. 生成并发送 token
    for token in llmApp.stream_chat(request.message, request.chat_history):
        full_response += token
        yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
        await asyncio.sleep(0.01)
    
    # 3. 发送结束事件
    yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"

# 异步测试函数
async def test_chat_stream():
    # 使用 Pydantic 模型实现数据序列化和反序列化(即将JSON数据转换为Python对象)
    request = ChatRequest(message=user_input, chat_history=chat_history)
    async for chunk in chat_stream(request):
        print(chunk)
# 在异步编程中,我们使用asyncio.run()来运行一个异步函数(coroutine)作为程序的入口点。
asyncio.run(test_chat_stream())

打印结果如下:

image.png

在上述的测试代码中的 chat_stream 函数实现一个基于 Server-Sent Events (SSE) 的流式聊天响应的异步生成器,它接收一个 ChatRequest 对象,然后逐步生成事件流。事件流格式遵循 SSE 规范,每个事件以 "data: " 开头,后跟 JSON 字符串,并以两个换行符结束。

  1. 首先,发送一个开始事件,通知客户端开始生成响应。
  2. 然后,通过调用 llmApp.stream_chat 方法,逐个获取 token,并将每个 token 作为一个事件发送。
  3. 在发送每个 token 事件后,使用 await asyncio.sleep(0.01) 来让出控制权,这样其他任务可以运行,避免阻塞。
  4. 同时,将每个 token 累加到 full_response 中,以便在最后发送整个响应。
  5. 最后,发送一个结束事件,并包含完整的响应内容。

这样设计的好处:

  • 流式传输:可以逐步将响应发送给客户端,客户端可以实时看到生成的 token,提升用户体验(如打字机效果)。
  • 异步:使用异步生成器,可以在等待模型生成下一个 token 时让出控制权,提高并发性能。
  • 事件驱动:通过定义不同类型的事件(开始、token、结束),客户端可以方便地根据事件类型进行处理。

6.2 实现 API 层

上面测试代码中实现的 chat_stream 函数,其实就是我们接下来要实现的 流式对话接口,即接收用户的消息和聊天历史,通过流式方式返回 LLM 的响应。同时我们再实现一个健康检查接口,提供服务器的健康状态,包括 LLM 应用是否初始化成功、模型名称等,便于监控。

根据上面所学的知识,我们实现一个基于 FastAPI 的 LLM 聊天 API 服务。

我们创建 ./backend/server.py 文件,内容如下:

import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from llm_app import LLMApp
from models import ChatRequest, HealthResponse

app = FastAPI(title="Cobyte LLM Chat API")

# CORS 配置:允许前端跨域访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 在生产环境中建议设置为具体的前端域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局 LLM 应用实例
llm_app = None

@app.on_event("startup")
async def startup_event():
    """应用启动时初始化 LLM"""
    global llm_app
    try:
        print("正在初始化 LLM 应用...")
        llm_app = LLMApp()
        print("✅ LLM 应用初始化成功")
    except Exception as e:
        print(f"❌ LLM 应用初始化失败: {e}")

@app.get("/api/health")
async def health_check():
    """健康检查接口"""
    return HealthResponse(
        status="healthy" if llm_app else "unhealthy",
        model="deepseek-chat",
        api_configured=llm_app is not None,
        timestamp=datetime.now().isoformat()
    )

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    """流式对话接口"""
    if not llm_app:
        raise HTTPException(status_code=500, detail="LLM 服务未就绪")
    
    async def generate():
        try:
            # 1. 发送开始事件
            yield f"data: {json.dumps({'type': 'start'})}\n\n"
            await asyncio.sleep(0.01) # 让出控制权
            
            full_response = ""
            
            # 2. 生成并发送 token
            # 注意:llm_app.stream_chat 是同步生成器,但在 FastAPI 中可以正常工作
            # 如果需要完全异步,需要使用 AsyncChatOpenAI,这里为了简单保持同步调用
            for token in llm_app.stream_chat(request.message, request.chat_history):
                full_response += token
                yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
                await asyncio.sleep(0.01)
            
            # 3. 发送结束事件
            yield f"data: {json.dumps({'type': 'end', 'full_response': full_response})}\n\n"
            
        except Exception as e:
            error_msg = str(e)
            print(f"生成错误: {error_msg}")
            yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

至此我们基于 FastAPI 实现了 API 层。核心功能就是提供了两个 API:

  1. 流式对话接口 /api/chat/stream

    • 支持 Server-Sent Events (SSE) 流式响应
    • 接收用户消息,实时返回 AI 生成的回复
    • 支持对话历史管理
  2. 健康检查接口 /api/health

    • 检查服务状态
    • 返回 API 配置信息

6.3 依赖管理

为了更好地管理我们的依赖,我们可以创建一个 ./backend/requirements.txt 文件,将使用到的依赖都设置到这个文件中:

fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0
langchain>=1.2.9
langchain-openai>=0.0.5
pydantic>=2.5.0

这样我们就可以进行以下方式进行安装依赖了。

# 安装依赖
pip install -r requirements.txt

7. 前端聊天界面

先创建一个 Vue3 + TS 的前端项目,我们在根目录下执行以下命令:

npm create vite@latest frontend --template vue-ts

接下来我们主要实现以下核心功能:

  1. 对话界面

    • 消息列表展示(用户消息 + AI 回复)
    • 输入框 + 发送按钮
    • 流式显示 AI 回复(逐字显示效果)
    • 加载状态提示
  2. 交互功能

    • 发送消息(Enter 键/点击按钮)
    • 清空对话历史
    • 滚动到最新消息

./frontend/src/types/chat.ts 文件如下:

export interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: number
  streaming?: boolean  // 是否正在流式生成
}

export interface ChatRequest {
  message: string
  chat_history: Array<{
    role: string
    content: string
  }>
}

export interface SSEEvent {
  type: 'start' | 'token' | 'end' | 'error'
  content?: string
  full_response?: string
  message?: string
}

./frontend/src/api/chat.ts 文件内容如下:

import type { ChatRequest, SSEEvent } from '../types/chat'

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'

export class ChatAPI {
  /**
   * 流式对话接口
   */
  static streamChat(
    payload: ChatRequest,
    onToken: (token: string) => void,
    onComplete: (fullResponse: string) => void,
    onError: (error: string) => void
  ): () => void {
    // 使用 fetch API 配合 ReadableStream 来处理 POST 请求的流式响应
    // 因为标准的 EventSource 不支持 POST 请求
    const controller = new AbortController()
    
    const fetchStream = async () => {
      try {
        const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(payload),
          signal: controller.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const reader = response.body?.getReader()
        const decoder = new TextDecoder()
        
        if (!reader) throw new Error('Response body is null')

        let buffer = ''

        while (true) {
          const { done, value } = await reader.read()
          if (done) break
          
          const chunk = decoder.decode(value, { stream: true })
          buffer += chunk
          
          // 处理 buffer 中的每一行
          const lines = buffer.split('\n\n')
          buffer = lines.pop() || '' // 保留最后一个可能不完整的块
          
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const jsonStr = line.slice(6)
              try {
                const data: SSEEvent = JSON.parse(jsonStr)
                
                switch (data.type) {
                  case 'start':
                    break
                  case 'token':
                    if (data.content) onToken(data.content)
                    break
                  case 'end':
                    if (data.full_response) onComplete(data.full_response)
                    return // 正常结束
                  case 'error':
                    onError(data.message || 'Unknown error')
                    return
                }
              } catch (e) {
                console.error('JSON parse error:', e)
              }
            }
          }
        }
      } catch (error: any) {
        if (error.name === 'AbortError') return
        onError(error.message)
      }
    }

    fetchStream()

    // 返回取消函数
    return () => controller.abort()
  }

  /**
   * 健康检查
   */
  static async healthCheck() {
    try {
      const response = await fetch(`${API_BASE_URL}/api/health`)
      return await response.json()
    } catch (error) {
      console.error('Health check failed', error)
      return { status: 'error' }
    }
  }
}

./frontend/src/composables/useChat.ts 文件内容如下:

import { ref, nextTick } from 'vue'
import type { Message } from '../types/chat'
import { ChatAPI } from '../api/chat'

export function useChat() {
  const messages = ref<Message[]>([])
  const isLoading = ref(false)
  const currentStreamingMessage = ref<Message | null>(null)
  
  // 用于取消当前的请求
  let cancelStream: (() => void) | null = null

  /**
   * 滚动到底部
   */
  const scrollToBottom = () => {
    nextTick(() => {
      const container = document.querySelector('.message-list')
      if (container) {
        container.scrollTo({
          top: container.scrollHeight,
          behavior: 'smooth'
        })
      }
    })
  }

  /**
   * 发送消息
   */
  const sendMessage = async (content: string) => {
    if (!content.trim() || isLoading.value) return

    // 1. 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: content.trim(),
      timestamp: Date.now()
    }
    messages.value.push(userMessage)
    
    // 准备发送给后端的历史记录(去掉刚加的这一条,因为后端只要之前的)
    // 或者你可以根据设计决定是否包含当前条,通常 API 设计是:新消息 + 历史
    // 我们的后端设计是:message + chat_history
    const historyPayload = messages.value.slice(0, -1).map(m => ({
      role: m.role,
      content: m.content
    }))

    // 2. 创建 AI 消息占位符
    const aiMessage: Message = {
      id: (Date.now() + 1).toString(),
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      streaming: true
    }
    messages.value.push(aiMessage)
    currentStreamingMessage.value = aiMessage
    isLoading.value = true
    
    scrollToBottom()

    // 3. 调用流式 API
    cancelStream = ChatAPI.streamChat(
      {
        message: content.trim(),
        chat_history: historyPayload
      },
      // onToken
      (token) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += token
          scrollToBottom()
        }
      },
      // onComplete
      (fullResponse) => {
        if (currentStreamingMessage.value) {
          // 确保内容完整
          if (currentStreamingMessage.value.content !== fullResponse && fullResponse) {
             currentStreamingMessage.value.content = fullResponse
          }
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      },
      // onError
      (error) => {
        if (currentStreamingMessage.value) {
          currentStreamingMessage.value.content += `\n[错误: ${error}]`
          currentStreamingMessage.value.streaming = false
        }
        currentStreamingMessage.value = null
        isLoading.value = false
        cancelStream = null
        scrollToBottom()
      }
    )
  }

  /**
   * 清空历史
   */
  const clearHistory = () => {
    if (cancelStream) {
      cancelStream()
      cancelStream = null
    }
    messages.value = []
    isLoading.value = false
    currentStreamingMessage.value = null
  }

  return {
    messages,
    isLoading,
    sendMessage,
    clearHistory
  }
}

./frontend/src/App.vue 文件内容如下:

<template>
  <div class="app-container">
    <header class="chat-header">
      <div class="header-content">
        <h1>🤖 DeepSeek 对话助手</h1>
        <div class="status-badge" :class="{ online: isServerOnline }">
          {{ isServerOnline ? '在线' : '离线' }}
        </div>
      </div>
      <button @click="clearHistory" class="clear-btn" title="清空对话">
        🗑️
      </button>
    </header>

    <main class="message-list">
      <div v-if="messages.length === 0" class="empty-state">
        <p>👋 你好!我是基于 DeepSeek 的 AI 助手。</p>
        <p>请在下方输入问题开始对话。</p>
      </div>

      <div 
        v-for="msg in messages" 
        :key="msg.id" 
        class="message-wrapper"
        :class="msg.role"
      >
        <div class="avatar">
          {{ msg.role === 'user' ? '👤' : '🤖' }}
        </div>
        <div class="message-content">
          <div class="bubble">
            {{ msg.content }}
            <span v-if="msg.streaming" class="cursor">|</span>
          </div>
        </div>
      </div>
    </main>

    <footer class="input-area">
      <div class="input-container">
        <textarea
          v-model="inputContent"
          placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
          @keydown.enter.exact.prevent="handleSend"
          :disabled="isLoading"
          rows="1"
          ref="textareaRef"
        ></textarea>
        <button 
          @click="handleSend" 
          :disabled="isLoading || !inputContent.trim()"
          class="send-btn"
        >
          {{ isLoading ? '...' : '发送' }}
        </button>
      </div>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useChat } from './composables/useChat'
import { ChatAPI } from './api/chat'

const { messages, isLoading, sendMessage, clearHistory } = useChat()
const inputContent = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isServerOnline = ref(false)

// 检查服务器状态
onMounted(async () => {
  const health = await ChatAPI.healthCheck()
  isServerOnline.value = health.status === 'healthy'
})

// 自动调整输入框高度
watch(inputContent, () => {
  if (textareaRef.value) {
    textareaRef.value.style.height = 'auto'
    textareaRef.value.style.height = textareaRef.value.scrollHeight + 'px'
  }
})

const handleSend = () => {
  if (inputContent.value.trim() && !isLoading.value) {
    sendMessage(inputContent.value)
    inputContent.value = ''
    // 重置高度
    if (textareaRef.value) {
      textareaRef.value.style.height = 'auto'
    }
  }
}
</script>

<style>
:root {
  --primary-color: #4a90e2;
  --bg-color: #f5f7fa;
  --chat-bg: #ffffff;
  --user-msg-bg: #e3f2fd;
  --bot-msg-bg: #f5f5f5;
  --border-color: #e0e0e0;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  background-color: var(--bg-color);
  height: 100vh;
  overflow: hidden;
}

.app-container {
  max-width: 800px;
  margin: 0 auto;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--chat-bg);
  box-shadow: 0 0 20px rgba(0,0,0,0.05);
}

/* Header */
.chat-header {
  padding: 1rem;
  border-bottom: 1px solid var(--border-color);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: white;
  z-index: 10;
}

.header-content h1 {
  font-size: 1.2rem;
  color: #333;
}

.status-badge {
  font-size: 0.8rem;
  padding: 2px 6px;
  border-radius: 4px;
  background: #ff5252;
  color: white;
  display: inline-block;
  margin-left: 8px;
}

.status-badge.online {
  background: #4caf50;
}

.clear-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 5px;
  border-radius: 50%;
  transition: background 0.2s;
}

.clear-btn:hover {
  background: #f0f0f0;
}

/* Message List */
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.empty-state {
  text-align: center;
  margin-top: 50px;
  color: #888;
}

.message-wrapper {
  display: flex;
  gap: 12px;
  max-width: 85%;
}

.message-wrapper.user {
  align-self: flex-end;
  flex-direction: row-reverse;
}

.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: #eee;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.2rem;
  flex-shrink: 0;
}

.bubble {
  padding: 12px 16px;
  border-radius: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-wrapper.user .bubble {
  background: var(--user-msg-bg);
  color: #0d47a1;
  border-radius: 12px 2px 12px 12px;
}

.message-wrapper.assistant .bubble {
  background: var(--bot-msg-bg);
  color: #333;
  border-radius: 2px 12px 12px 12px;
}

.cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: #333;
  animation: blink 1s infinite;
  vertical-align: middle;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Input Area */
.input-area {
  padding: 20px;
  border-top: 1px solid var(--border-color);
  background: white;
}

.input-container {
  display: flex;
  gap: 10px;
  align-items: flex-end;
  background: #f8f9fa;
  padding: 10px;
  border-radius: 12px;
  border: 1px solid var(--border-color);
}

textarea {
  flex: 1;
  border: none;
  background: transparent;
  resize: none;
  max-height: 150px;
  padding: 8px;
  font-size: 1rem;
  font-family: inherit;
  outline: none;
}

.send-btn {
  background: var(--primary-color);
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 600;
  transition: opacity 0.2s;
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

./frontend/src/style.css 文件内容如下:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

#app {
  width: 100%;
  height: 100vh;
}

.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #1a1a1a;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.header {
  text-align: center;
  margin-bottom: 2rem;
}

.header h1 {
  font-size: 2rem;
  color: #ffffff;
  margin: 0;
}

.header p {
  font-size: 1rem;
  color: #bbbbbb;
  margin: 0;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #ffffff;
  font-size: 0.9rem;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
}

.form-group textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #444;
  border-radius: 6px;
  background-color: #2a2a2a;
  color: #ffffff;
  font-size: 1rem;
  resize: vertical;
}

.form-group button {
  width: 100%;
  padding: 0.75rem;
  border: none;
  border-radius: 6px;
  background-color: #4caf50;
  color: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.form-group button:hover {
  background-color: #45a049;
}

.error-message {
  color: #ff4d4d;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

.success-message {
  color: #4caf50;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  display: none;
}

@media (max-width: 600px) {
  .container {
    padding: 1rem;
  }

  .form-group input,
  .form-group textarea {
    font-size: 0.9rem;
  }

  .form-group button {
    font-size: 0.9rem;
  }
}

前端比较简单,前端部分的实现就不进行详细讲解了。

8. 运行与验证

8.1 启动后端

打开一个终端窗口:

cd backend
# 1. 安装依赖
pip install -r requirements.txt

# 2. 设置 API Key (重要!)
# 编辑 .env 文件,填入你的 DeepSeek API Key
# DEEPSEEK_API_KEY=sk-... 

# 3. 启动服务器
python server.py
# 服务将运行在 http://0.0.0.0:8000

8.2 启动前端

打开一个新的终端窗口:

cd frontend
# 1. 安装依赖
npm install

# 2. 启动开发服务器
npm run dev

访问前端地址,你就可以看到一个简洁的聊天界面。

image.png

当你输入问题并点击发送时,请求会经过: 前端 -> FastAPI -> LangChain -> DeepSeek API -> 返回结果

9. 总结

通过本文,我们完成了一个最小可行性产品(MVP)。从零开始搭建一个基于 Python (FastAPI)LangChain 和 Vue 3 的全栈 LLM 聊天应用程序。

这个项目虽然简单,但它包含了一个 AI 应用的完整骨架。你可以在此基础上扩展更多功能,例如添加对话历史记忆 (Memory)  或 RAG (知识库检索)

接下来我将继续输出更多 AI 全栈的相关知识,欢迎大家关注本栏目。我是 Cobyte,欢迎添加 v:icobyte,学习 AI 全栈。

前端算法:从 O(n²) 到 O(n),列表转树的极致优化

作者 NEXT06
2026年2月8日 21:34

1. 引言与业务场景

在前端开发中,数据结构的转换是一项基础且高频的技能。后端数据库通常以扁平化(Flat List)的形式存储层级数据,每条记录仅保留 id 和 parentId 来标识父子关系。然而,前端组件(如 Ant Design 的 Tree、Cascader,或 Element UI 的 Table 树形模式)往往需要嵌套的树形结构(Tree Structure)来渲染视图。

常见的业务场景包括但不限于:

  • RBAC 权限系统:后台管理系统的侧边栏菜单。
  • 组织架构图:展示公司部门与员工的层级关系。
  • 行政区划联动:省、市、区/县的三级联动选择器。
  • 评论盖楼:社交平台的多级回复机制。

输入数据通常如下所示:

JavaScript

const flatList = [
  { id: 1, parentId: 0, name: '系统管理' },
  { id: 2, parentId: 1, name: '用户管理' },
  { id: 3, parentId: 1, name: '权限配置' },
  { id: 4, parentId: 2, name: '用户列表' },
  // ... 可能有成百上千条数据
];

目标是将其转换为如下的树形结构:

JavaScript

[
  {
    id: 1,
    name: '系统管理',
    children: [
      {
        id: 2,
        name: '用户管理',
        children: [
          { id: 4, name: '用户列表', children: [] }
        ]
      },
      { id: 3, name: '权限配置', children: [] }
    ]
  }
]

本文将从面试官的角度,分析两种主流的实现方案,探讨从递归到哈希映射的思维跃迁,以及如何通过利用 JavaScript 的对象引用(Object Reference)特性实现性能的极致优化。


2. 基础方案:递归实现 (Recursion)

递归是处理树形结构最直观的思维方式。其核心逻辑是:对于每一个节点,遍历整个列表,找出所有 parentId 等于当前节点 id 的项,作为其子节点。

代码实现

利用 ES6 的数组方法,我们可以写出非常简洁的代码:

JavaScript

/**
 * 递归查找,构建树形结构
 * @param {Array} list 原始列表
 * @param {Number} parentId 当前节点的父节点ID,默认为根节点ID 0
 * @return {Array} 树形结构
 */
function listToTreeRecursive(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: listToTreeRecursive(list, item.id)
    }));
}

深度解析与瓶颈

这段代码在面试中通常作为“及格”的答案。它逻辑清晰,代码量少,但在工程实践中存在明显的性能隐患。

时间复杂度分析:O(n²)

假设列表长度为 n。

  1. 函数 listToTreeRecursive 会被调用多次。
  2. 每一次调用,filter 都会遍历整个列表(长度为 n)来寻找子节点。
  3. 随着递归深度的增加,虽然总调用次数取决于节点数量,但从宏观算法角度来看,这是一个典型的嵌套遍历模型。其时间复杂度接近 O(n²)

性能风险

  • CPU 阻塞:当数据量达到几千条(例如全国省市区数据)时,计算量将呈指数级增长,可能导致主线程阻塞,页面卡顿。
  • 栈溢出:虽然在 DOM 树场景下层级通常不会太深,但如果数据层级极深,递归调用栈可能超出浏览器限制(Stack Overflow)。

3. 进阶方案:Map 映射优化 (Iterative Approach)

为了解决递归带来的性能问题,我们需要打破“每次查找子节点都要遍历整个列表”的限制。

优化思路:空间换时间

通过引入一个哈希表(Hash Map),我们可以将节点的查找时间复杂度从 O(n)  降低到 O(1) 。在 JavaScript 中,我们可以利用 Map 或原生 Object 来实现。

核心原理:利用对象引用

这是面试中的加分项,也是容易写错的地方。
核心在于:JavaScript 中的对象是引用传递(Pass by Reference) 。当我们修改 Map 中存储的对象的 children 属性时,所有指向该对象的引用都会同步感知到变化。

代码实现

JavaScript

/**
 * 利用 Map 映射,非递归构建树形结构
 * 时间复杂度 O(n)
 * @param {Array} list 原始列表
 * @return {Array} 树形结构
 */
function listToTreeMap(list) {
  const nodeMap = new Map();
  const tree = [];

  // 第一步:初始化 Map,将所有节点以 id 为键存入 Map
  // 关键点:不仅存入,还必须为每个节点初始化 children 数组
  list.forEach(item => {
    nodeMap.set(item.id, { ...item, children: [] });
  });

  // 第二步:再次遍历,建立父子关系
  list.forEach(item => {
    // 必须获取 Map 中的引用(reference),而不是原始 list 中的 item
    // 只有修改 Map 中的对象,才能通过引用机制同步到 tree 数组中
    const node = nodeMap.get(item.id);
    
    // 如果是根节点,直接放入结果数组
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      // 在 Map 中查找父节点
      const parentNode = nodeMap.get(item.parentId);
      // 如果父节点存在,将当前节点(的引用)推入父节点的 children
      if (parentNode) {
        parentNode.children.push(node);
      }
    }
  });

  return tree;
}

关键逻辑解析

  1. Map 初始化:我们首先遍历一次列表,将所有数据转换为 { id: node } 的映射结构。这一步使得后续查找任意节点的操作变为 O(1)。

  2. 引用传递的妙用

    • 当 tree.push(node) 执行时,tree 数组持有的是节点的内存地址引用
    • 当 parentNode.children.push(node) 执行时,parentNode 的 children 数组持有的也是同一个内存地址引用
    • 因此,无论节点层级多深,我们只需要两层平级的遍历即可完成所有连接。

时间复杂度分析:O(n)

  • 第一次遍历构建 Map:O(n)。
  • 第二次遍历构建关系:O(n)。
  • 总复杂度:O(2n),即 O(n)

4. 方案对比与选型建议

从面试官的角度来看,能够清晰分析出两种方案的优劣,并根据场景选择合适的方案,是高级工程师具备的素质。

维度 递归方案 (Recursion) Map 映射方案 (Iteration)
时间复杂度 O(n²)  (性能较差) O(n)  (性能极佳)
空间复杂度 O(n) (递归栈开销) O(n) (Map 存储开销)
代码可读性 高,逻辑符合直觉 中,需要理解引用关系
适用场景 数据量小 (<100条),快速开发 数据量大 (>1000条),追求性能
健壮性 深度过大可能导致栈溢出 无栈溢出风险

面试建议

  • 如果面试要求“写一个转换函数”,先询问数据量级。
  • 默认情况下,优先通过 Map 方案展示你对复杂度和引用的理解。
  • 在编写 Map 方案时,务必注意不要直接操作原始 list item,而是操作 Map 中存储的新对象引用,这是最常见的逻辑陷阱。

5. 结语

“扁平列表转树”不仅仅是一道算法题,它深刻体现了前端开发中对内存引用时间复杂度的理解。

  1. 基础层:理解树形结构,能写出递归。
  2. 进阶层:理解哈希表(Hash Map)在算法优化中的“空间换时间”思想。
  3. 专家层:熟练掌握 JavaScript 的对象引用机制,能够编写出无副作用、高性能的转换代码。

在实际业务开发中,面对复杂且庞大的组织架构或菜单数据,使用 O(n) 的 Map 映射方案应是你的首选。

昨天 — 2026年2月8日掘金 前端

chrome单页签内存分配上限问题,怎么解决

2026年2月8日 21:27

前言

我们都知道 chrome 的单个标签内存占用都是有限制的,平均也就2~4G,32位的只会更少,并且这很多情况下只是理想状态,当桌面应用多开的时候,chrome 单个页签的大小更是会大幅度缩水,那么我们怎样解决chrome的内存分配上限呢

为什么

疑问:为何要管这个内存分配上限问题?

答:内存不足时,无论是操作系统还是浏览器都会有一些内存交换或者释放内存的方式,无论是哪一个短时间内都是极度耗费性能的,尤其是单纯的内存占用高的问题,频繁的内存交换,这个只会造成更严重的页面严重卡顿,甚至无响应,也就是说到底内存问题还是会再转化为标注你的性能优化问题

一般场景

解决单页签内存分配上限时,我们首先要看问题在哪里,一般会有下面几种场景:

  • 页面递归死循环,堆栈溢出,这个属于重大bug,需要及时解决,不多解释
  • 页面出现了循环引用内存泄漏,导致内存没能及时释放,出现内存占用过高,此时我们要想尽办法解决内存泄漏问题,当然这个问题算是中等问题,因为还有垃圾回收,只要不是全局引用的一般都会随着页面资源的释放释放,或者内存不足时统一释放回收
  • 页面出现了海量数据需要展示的数据,但是又无可奈何的时候,只能想尽办法来避免占用过高,常见的:echarts图标大量数据,直播巨量弹幕等

一些解决方案

递归死循环

对于递归死循环的问题,只能说,靠实力或者队友解决此bug,不应该出现

内存泄漏

当页面出现内存泄漏的时候,前端一般出现在闭包场景,或者强引用场景,出现了类似的循环引用问题,导致内存无法释放或者无法及时释放,累计起来也是一个不小的数字

对于常规页面内的循环引用,一般会随着页面的释放,那片内存空间会成为无主之物,会成为垃圾回收的养料,一般不管

主要是涉及到模块或者全局场景,这类大对象的强引用问题需要注意,如果不及时释放持有,则可能会长期持有,导致内存居高不不下,成为压死骆驼的最后一根稻草

海量数据问题

对于海量的数据问题,常见的就是大屏echart 之类的表格折线图等数据了,这类数据可以通过一些算法,优化尽量减少数据、分段渲染,甚至更改展示区间逻辑,这样内存高占用以及非高占用的卡顿问题能够改善

对于海量数据,还有另外一种情况,就是需要展示海量内容很多,单独一个页面无论怎么处理都是非常麻烦的,此时可以将该页面展示一个概览数据,当需要看详情的时候,直接跳转到另外一个浏览器标签,这样新的把标签的内存占用情况会明显改善,对于新标签页仍然占用过高的情况,仍然使用之前的数据分段减少数据等手段解决

最后

从chrome的内存分配上限,到转化为实际的性能瓶颈优化,到bug修复,到内存泄漏问题,再到海量数据优化,再到页面分布改善标签跳转等

解决问题的思路总是随着我们的疑问,一步一步向外延伸,问题和解决的方案上面也只是其中一部分,分析问题实际上也是我们进步的一环

本篇文章也就介绍到这里了,下次见😄

生成随机数,Math.random的使用

2026年2月8日 21:26

有时候我们有生成随机数的一些想法,可能会直接想使用 Math.random,但这个Math.random 优缺也很明显,优点简单方便兼容性高,缺点就是算法固定,后续可能会预测,且全是数字,长度也不固定,对于一些场景不太友好

我们可以通过使用码表 + 随机数偏移的方式生成随机数,这样效率实际也不低,且不仅仅是纯数字了,长度也固定了,并且码表也是可以随机变动的,这样上面的一些隐患实际就没了

//码表可以扩展加乱序,可打乱减少被预测概率,这里就不写码表打乱的算法了,我们就解决数字 + 长度问题,看起来舒服
const mapString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

//生成随机数,根据随机数生成偏移,取出码表中的一个字符,根据长度取出指定个数
function getRandomString(length) {
  let result = "";
  const charactersLength = mapString.length;
  for (let i = 0; i < length; i++) {
    //当然想进一步提速,也可以转化为数组的访问,这里不多做操作了
    result += mapString[Math.floor(Math.random() * charactersLength)];
  }
  return result;
}

如果对于码表没有要求的话仅仅是当一个唯一id的话,我们实际上直接将随机数转化成36进制字符串,取出其中一项就行了,实际上转化成36进制,取出其中一项,就是 [0-9a-z]+ 了,但感觉没上面好

function getRandomString(length) {
  let result = '';
    for (let i = 0; i < length; i++) {
        result += Math.random().toString(36).charAt(2);
    }
    return result;
}

看了上面的写法,是不是感觉思路更多了,确实如此,我们实际使用的话,就使用第一种方案感觉就挺好的,不管是生成表单的id,还是生成其他地方的随机数,都足够用了😄

js数组之快速组、慢数组、密集数组、稀松数组

2026年2月8日 21:26

前言

我们大多数只知道数组是一个有序集合,但由于js的数组使用非常灵活,因而衍生出来非常多的疑问,本篇文章会对一些常对其一些场景情况出现的名词和逻辑进行一定程度探讨

实际上有学习过数据结构和算法的基本上都能想到大致可能的场景或者关键字,线性数组、索引、哈希表

另外数组中经常听到的快速组、慢数组、密集数组、稀松数组,也会从这里慢慢带入

js数组介绍

集合有很多,js中的数组主要目的是存放有序内容,当然实际也会使用和对象一样

密集数组与快数组

密集也就是我们常见的数组使用方式,按照数字索引逐步存放的,按顺序每个索引都存放内容了即使是undefined、null也是如此,这就叫做密集数组

密集数组通常是数组中的快数组,顾名思义,效率高,快

const list = []
list.push('第一个')
list.push('第二个')
list.push(null)
//实际上存放的是 ['第一个', '第二个']

稀疏数组与慢数组

了解了密集数组,也很容易想到稀疏数组,并不是索引逐步存放的,中间存在断档,也就是跳过了部分索引赋值

稀疏数组可能会转化为慢数组,顾名思义,效率低,慢

const list = []
list[10] = 10
list[100] = 100
//实际上存放了2个元素,长度缺是101,多出来的都是空元素

快数组与慢数组

当我们生成的数组是一个密集数组的时候,我们的数组会生成一个连续的内存结构,用于存放我们的索引,因此访问效率很高,时间复杂度位O(1),这就是快速组

连续内存,可以理解为,一个小区的楼栋分别按照顺序排列,我们知道了第一个只需要加上4个偏移就是第五栋了,在寻址中,知道地址就能快速访问到对应的地址内容了

当我们的数组是一个稀松数组,或者一个对象数组(标记数组,实际可能是对象或者混搭)时,会转化为慢数组,也就是我们常见的对象结构时,此时就是传说中的慢数组了,访问起来就是一个哈希结构了,这也是为了节省内存,毕竟学过数据结构或者了解过计算机内存分布的都知道,空间是申请好才能用的,且是连续的,不可能上来就乱申请一个巨大的连续空间,然后就为了保存那两个数据,非常不合理

尽管申请的内存块都是连续的,对于索引不连续的储存,一般都是采用哈希表的处理方式,将key转化为索引,处理冲突等,来解决内存映射问题,当然对于哈希结构用的比较多的就是线性表、树表了,而对于哈希表尽管优化,但操作、访问效率是不如顺序表的平均来看大约是O(1) ~ O(logn),并且由于线性表有冲突处理是要高于顺序表的,这里并不多介绍这两者,就介绍这么多x(想多了解的可以翻我以前写的数据结构与算法中的哈希表环节)

那么平时我们怎样做能用到快数组,什么样的场景慢数组呢

//快数组,索引有序逐步填充
const list = []
list.push('第一个')
list.push('第二个')

//慢数组,仍然使用数字的
const list = []
list[10] = 10
list[100] = 100

//慢数组,直接使用非数字的
const list = [] //使用new Array(101)也一样
list['name'] = 10
list[name2] = 100

实际上还有一种密集数组,仍然会转化为慢数组,那就是数据内容过多,比如超过1000,可能就会被v8转化为哈希结构的数组的,可能会问为什么,因为一块非常大连续的内存空间申请也是一个技术活,内存申请释放很容易出现内存碎片,简单说就是很多申请释放之间的连续内存区域不足够大,这么做也是为了更好使用碎片化内存,毕竟内存不够申请的话,也是一个大问题

最后

我们都知道数组实际上也是继承自对象,也有自己的原型,针对于上面的不同结构,我们用的数组操作是js提供给我们的一个统一接口,如果我们使用同一个接口用来转化操作不同结构的对象,我们会怎么处理呢,又会想到哪些设计模式呢?

本篇文章就介绍到这里了,希望大家有点收获😄

css外边距重叠问题

2026年2月8日 21:26

前端开发过程中,是否碰到这样一个场景,同时设置了上部元素下边距和下部元素上边距,发现实际的边距竟然不是两个外边距之和,这就是我们这里讲的css 外边距重叠了

css外边距重叠通常发生在 block 块级元素中(flex、inline不会出现),且只包括上下边距,左右边距不会重叠

有三种场景会出现边距重叠问题:

场景一:两个子节点同时设置上下边距

出现了两个子节点,分上部元素下边距(margin-bottom)和下部元素上边距(margin-top),此时,两者边距会出现以下合并场景

  • 两者均为正、均为负,取绝对值的最大值
  • 两者一正一负,取他们的和

场景二:父子节点的元素穿透

父节点没设置margin-top,子节点设置了margin-top,则子节点的margin-top则会穿透到父节点产生间距,当然这种场景一般我们直观感觉不到,确实存在这种场景

场景二:空元素上下边距重叠

一个空元素的存在,且设置了上下边距,一般我们认为就是一个占高的元素,通常我们会直接设置height,如果同时设置了margin-top、margin-bottom,则上下边距会重叠选择最大值,合并成他的占位高度,这个见到的非常少了

ps:我们正常编写为了避免出现此类问题,可以尽量使用 BFC 场景,例如:我们使用flex布局,子元素就不会出现重叠了

css实现一个宽高固定百分比的布局的一个方式

2026年2月8日 21:26

如果说要实现一个固定百分比布局,有不少方案,但大多数是针对于一个固定的值进行计算宽高的,搭配js非常的方便,如果要使用纯css的话有什么好的相对简单的办法呢

当然实际不推荐直接这么用,可以根据情况使用最合适的方法,这里只是提供一种思考思路,也就是css的一些特性,能让我们有更多方式实现同一种效果

这里 使用padding-top、padding-bottom 的百分比,相对于父节点宽度百分比的思路来实现

function App() {
  return (
    <div className="App">
    </div>
  );
}

cs效果如下所示

.App {
  padding-top: 50%;
  height: 0;
  width: 100%;
  background-color: red;
}

这样就实现了一个宽高固定百分比的方案,搭配一个简单的reactive、absolute 就可以轻松实现内容百分比展示、

当然有些场景使用 calc 直接计算也行,配合js、css变量更不多说了,方法多得很,看怎么操作

@tdesign/uniapp 图标瘦身

作者 Novlan1
2026年2月8日 20:35

1. 背景

随着 tdesign-icons 图标的不断丰富,@tdesign/uniapp 包的图标文件大小也不断变大。但是普通业务一般并不需要同时使用这么多图标,并且小程序等场景图标只能使用 iconfont 等,不支持动态加载,所以需要一种方式减包、瘦身。

2. 解决方案

不同于原生小程序,@tdesign/uniapp 在上传前有构建一步,可以利用插件来减包。

可以使用postcss-plugin-remove-selector 来在构建时进行无用图标的删减。

点此查看文档

3. 如何使用

安装

pnpm add @novlan/postcss-plugin-remove-selector -D

3.1. 简化模式(推荐)

通过 mode 指定预设,只需关注 used/unused,无需手动配置 fileselectorPattern

import { defineConfig } from 'vite';
import { postcssPluginRemoveSelector } from '@novlan/postcss-plugin-remove-selector';

export default defineConfig({
  css: {
    postcss: {
      plugins: [
        postcssPluginRemoveSelector({
          mode: 'tdesign',
          used: ['home', 'chat', 'user', 'add', 'search', 'close'],
        }),
      ],
    },
  },
});

使用 customUsed / customUnused 可在预设基础上增量追加,不会覆盖预设中已有的列表:

postcssPluginRemoveSelector({
  mode: 'tdesign',
  // 在 tdesign 预设默认的 used 列表上,额外追加 'star' 和 'heart'
  customUsed: ['star', 'heart'],
  // 从结果中额外移除 'loading'
  customUnused: ['loading'],
})

3.2. 标准模式

通过 list 数组传入完整配置,适用于需要匹配多个文件的复杂场景:

import { defineConfig } from 'vite';
import { postcssPluginRemoveSelector } from '@novlan/postcss-plugin-remove-selector';
import {
  TDESIGN_ICON_REMOVE_SELECTOR
} from '@novlan/postcss-plugin-remove-selector/lib/tdesign-uniapp-icon';


export default defineConfig({
  css: {
    postcss: {
      plugins: [postcssPluginRemoveSelector(TDESIGN_ICON_REMOVE_SELECTOR)],
    },
  },
});

4. 类型

插件支持两种配置方式,传入 SimpleOptions(简化模式)或 Options(标准模式)均可。

4.1. SimpleOptions(简化模式)

属性 类型 是否必填 说明
mode 'tdesign' 预设模式,设置后自动使用对应的 fileselectorPattern 默认值
file RegExp | string 文件匹配规则。使用 mode 时可省略
used string[] 正在使用的图标名称列表,这些图标会被保留
unused string[] 未使用的图标名称列表,这些图标会被移除
customUsed string[] 增量追加到 used 列表(不覆盖预设或已有的 used
customUnused string[] 增量追加到 unused 列表(不覆盖预设或已有的 unused
selectorPattern RegExp 选择器匹配模式。使用 mode 时可省略
debug boolean 是否开启调试模式

modefile 至少需要指定一个。当 modefile/selectorPattern 同时指定时,file/selectorPattern 优先。

4.2. Options(标准模式)

属性 类型 是否必填 说明
list FileConfig[] 配置列表
debug boolean 是否开启调试模式

4.3. FileConfig

属性 类型 是否必填 说明
file RegExp | string 文件匹配规则,可以是字符串或正则表达式
used string[] 正在使用的图标名称列表,这些图标会被保留
unused string[] 未使用的图标名称列表,这些图标会被移除
customUsed string[] 增量追加到 used 列表(不覆盖已有的 used
customUnused string[] 增量追加到 unused 列表(不覆盖已有的 unused
selectorPattern RegExp 选择器匹配模式,只处理匹配该模式的选择器

4.4. 内置预设

mode 说明 默认 file 默认 selectorPattern
tdesign TDesign UniApp 图标减包 /[@/]tdesign[/]uniapp[/]dist[/]icon[/]icon\.[css|vue]/ /^\.t-icon-[\w-]+:before$/

5. 文档

  1. 文档
  2. 更新日志
  3. 示例项目

6. 效果

tdesign-uniapp-starter 为例,使用此插件,可使小程序大小减少138KB,约95.8%

使用前:

转存失败,建议直接上传图片文件

使用后:

转存失败,建议直接上传图片文件

虚拟列表:支持“向上加载”的历史消息(Vue 3 & React 双版本)

2026年2月8日 19:00

前言

在AI聊天产品中,向上滚动加载历史消息是一个经典场景。如果直接渲染万级聊天记录,页面必卡无疑。而使用虚拟列表时,向上插入数据导致的位置偏移是最大的技术痛点。本文将分享如何实现一个支持“滚动位置锁定”和“动态高度补偿”的虚拟列表方案。

react虚拟列表向上加载.gif

一、 核心困难点:为什么向上加载这么难?

  1. 滚动位置丢失:当你向数组头部插入 5 条新消息时,总高度会增加。如果不处理,浏览器会停留在原来的 scrollTop,导致用户看到的内容被“顶走”。
  2. 动态高度计算:聊天内容(图片、长文本)高度不一,必须在 DOM 渲染后通过 ResizeObserver 实时修正。
  3. 索引偏移:插入数据后,原来的索引全部失效,必须依赖“累计高度数组”和二分查找重新定位。

二、 实现思路

1、第一步:搭个“戏台子”(基础结构)

我们要搭一个三层嵌套的戏台,每一层都有它的“使命”:

  1. 外层大管家:固定好高度,别让列表把页面撑坏了。
  2. “虚胖”占位层:这是个空盒子,高度设为 totalHeight。它的唯一作用是欺骗浏览器,让滚动条以为这里有成千上万条数据,从而产生真实的滚动感。
  3. 舞台中心(可视区) :绝对定位。它会像电梯一样,跟着你的滚动距离通过 translateY 灵活位移,永远保证自己出现在观众视线内。

2、第二步:准备核心数据

为了让“戏”不演砸,我们需要掌握这些情报:

  • 预判值MIN_ITEM_HEIGHT(哪怕不知道多高,也得有个保底值)和 BUFFER_SIZE(多渲染几行,别让用户一滑就看到白屏)。
  • 雷达站LOAD_THRESHOLD(距离顶部还有多远时,赶紧去后台搬救兵/加载数据)。
  • 记账本:用一个 Map 记录每个消息的真实高度,再整一个 cumulativeHeights(累计高度数组),记录每一条消息距离顶部的距离。

3、第三步:索引计算

  • 找起点:用二分查找在“记账本”里搜一下,看现在的滚动位置对应哪一行的地盘。
  • 定终点:起点加上你能看到的行数,再算上“缓冲区”的几位,就是这一幕的结束。
  • 定位置:算出起点项对应的累计高度,把舞台一推(offsetY),搞定!

4、第四步:时间回溯(向上加载的核心!核心!)

这是实现向上加载最难的地方:往开头塞了新胶片,怎么保证观众看到的画面不跳动?

  1. 做标记:触发加载前,先死死记住现在的 scrollHeight(总高)和 scrollTop(进度)。
  2. 塞数据:把新消息“砰”地一下插到 listData 的最前面。
  3. 神操作(高度补偿) :数据塞进去后,总高度肯定变了。这时候赶紧算一下:新高度 - 旧高度 = 增加的高度
  4. 瞬间平移:把滚动条位置强制修改为 旧进度 + 增加的高度。这套动作要在浏览器刷新前完成,用户只会觉得加载了新内容,但眼前的画面纹丝不动。

5、第五步:实时监控(高度纠正)

万一某条消息里突然蹦出一张大图,高度变了怎么办?

  • 派出侦察兵:子组件自带 ResizeObserver,一旦发现自己长高了,立马报告给父组件。
  • 精准打击:父组件收到报告,更新账本。如果这个变高的项在观众视线上方,还得手动把滚动条再推一推,防止内容在眼皮子底下“乱跳”。

6、终章:开幕仪式(初始化)

  1. 一滚到底:聊天室嘛,进场肯定得看最下面(最新消息)。
  2. 双重保险:调用 scrollToBottom 时,先用 requestAnimationFrame 请浏览器配合,再加个 setTimeout 兜底,确保无论网络多慢,都能准确降落在列表底部。

三、 Vue 3 + TailwindCSS 实现

1. 虚拟列表组件:

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div
      class="bg-white mt-10 rounded-xl border shadow-lg relative"
      ref="containerRef"
    >
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative overflow-anchor-none"
        @scroll="handleScroll"
      >
        <!-- 顶部加载提示 -->
        <div
          v-if="isLoading"
          class="sticky top-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500"
        >
          <div class="flex items-center space-x-2">
            <span>正在加载...</span>
          </div>
        </div>

        <div :style="{ height: `${totalHeight}px` }"></div>
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import VirtualListItem from './listItem.vue';

const MIN_ITEM_HEIGHT = 80; //预设虚拟列表项最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载项
const LOAD_THRESHOLD = 50; // 加载消息触发距离

const virtualListRef = ref<HTMLDivElement | null>(null); // 虚拟列表容器引用
const listData = ref<any[]>([]); // 列表数据
const itemHeights = ref<Map<number, number>>(new Map()); // 列表项高度数组:存储每个项的高度
const scrollTop = ref(0); // 滚动位置:当前滚动的垂直偏移量
const isLoading = ref(false); // 是否正在加载更多数据
const isInitialized = ref(false); // 是否已初始化:用于判断是否已加载初始数据
const hasMore = ref(true); // 是否有更多数据可加载
const containerRef = ref<HTMLDivElement | null>(null);

let minId = 10000; // 模拟生成消息ID

// 计算累计高度数组,对应了每个元素在列表中的垂直位置
const cumulativeHeights = computed(() => {
  const heights: number[] = [0];
  let currentSum = 0;
  for (const item of listData.value) {
    const h = itemHeights.value.get(item.id) || MIN_ITEM_HEIGHT;
    currentSum += h;
    heights.push(currentSum);
  }
  return heights;
});

// 列表总高度:列表所有项的累计高度
const totalHeight = computed(() => {
  const len = cumulativeHeights.value.length;
  return len > 0 ? cumulativeHeights.value[len - 1] : 0;
});

// 起始索引
const startIndex = computed(() => {
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内第一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < scrollTop.value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.max(0, low - 1 - BUFFER_SIZE);
});

// 结束索引
const endIndex = computed(() => {
  if (!virtualListRef.value) return 10;
  const t = scrollTop.value + virtualListRef.value.clientHeight; // 可视区底部在列表中的垂直位置`
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内最后一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < t) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.min(listData.value.length, low + BUFFER_SIZE);
});

// 可见列表项:根据起始索引和结束索引截取列表数据
const visibleList = computed(() => {
  return listData.value.slice(startIndex.value, endIndex.value);
});

// 偏移量:根据起始索引计算列表项的垂直偏移量
const offsetY = computed(() => {
  if (startIndex.value === 0) return 0;
  return cumulativeHeights.value[startIndex.value];
});

// mock真实数据
const generateData = (count: number) => {
  const arr = [];
  for (let i = 0; i < count; i++) {
    minId--;
    arr.push({
      id: minId,
      content: `历史消息 ${minId}`,
      timestamp: new Date().toLocaleTimeString(),
    });
  }
  return arr;
};

// 初始化数据
const initData = async () => {
  const initialData = await new Promise<any[]>(
    (resolve) => setTimeout(() => resolve(generateData(20)), 100) // 模拟异步数据加载,初始加载时加载20条数据防止数据量过少撑不起容器
  );
  listData.value = initialData.reverse();
  await nextTick(); // 等待listData渲染到DOM中
  await nextTick(); // 再次等待子组件完全渲染并计算好实际高度

  isInitialized.value = true;
  scrollToBottom(); // 滚动到底部显示最新消息
};

// 滚动到底
const scrollToBottom = () => {
  if (!virtualListRef.value) return;
  const scroll = () => {
    nextTick(() => {
      if (virtualListRef.value) {
        const scrollHeight = virtualListRef.value.scrollHeight;
        const clientHeight = virtualListRef.value.clientHeight;
        virtualListRef.value.scrollTop = scrollHeight - clientHeight;
        scrollTop.value = virtualListRef.value.scrollTop;
      }
    });
  };

  // 双重保障:先使用requestAnimationFrame等待浏览器完成一次重绘,此时 scrollHeight 和 clientHeight 已正确计算,
  // 再用setTimeout兜底确保即使 requestAnimationFrame 失效也能执行
  requestAnimationFrame(() => {
    scroll();
    // 兜底方案,确保滚动执行
    setTimeout(() => {
      scroll();
    }, 100);
  });
};

// 监听totalHeight变化,初始化时确保滚动到底部
watch(
  totalHeight,
  (newVal, oldVal) => {
    if (isInitialized.value && oldVal === 0 && newVal > 0) {
      scrollToBottom();
    }
  },
  { immediate: true }
);

// 加载新消息
const loadNewMessages = async () => {
  if (isLoading.value || !hasMore.value || !isInitialized.value) return;
  isLoading.value = true;
  try {
    await new Promise((resolve) => setTimeout(resolve, 1000));    // 模拟1秒延迟
    const newData = generateData(5); // 每次加载5条新消息
    const currentScrollHeight = virtualListRef.value?.scrollHeight || 0;    // 记录当前滚动状态,为未加载前整个列表的高度(含不可见)!!!
    const currentScrollTop = scrollTop.value;
    listData.value = [...newData, ...listData.value];    // 在顶部添加新数据
    await nextTick();    // 等待DOM更新
    // 保持滚动位置,让用户停留在原来的地方
    if (virtualListRef.value) {
      const newScrollHeight = virtualListRef.value.scrollHeight;
      const heightAdded = newScrollHeight - currentScrollHeight;
      virtualListRef.value.scrollTop = currentScrollTop + heightAdded;
      scrollTop.value = virtualListRef.value.scrollTop;
    }
    // 模拟没有更多数据的情况
    if (minId <= 9000) {
      hasMore.value = false;
    }
  } catch (error) {
    console.error('加载消息失败:', error);
  } finally {
    isLoading.value = false;
  }
};

// 处理项目高度更新
const handleItemHeightUpdate = (id: number, realHeight: number) => {
  const oldHeight = itemHeights.value.get(id) || MIN_ITEM_HEIGHT;
  const diff = realHeight - oldHeight;
  if (Math.abs(diff) < 1) return;

  itemHeights.value.set(id, realHeight);
  // 如果项目在可视区域上方,调整滚动位置
  const index = listData.value.findIndex((item) => item.id === id);
  if (index < 0) return;

  const itemTop = cumulativeHeights.value[index];
  const viewportTop = scrollTop.value;

  if (itemTop < viewportTop && virtualListRef.value) {
    virtualListRef.value.scrollTop += diff;
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 处理滚动事件
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement;
  scrollTop.value = target.scrollTop;

  // 当滚动到距离顶部LOAD_THRESHOLD像素时,加载更多消息
  if (
    scrollTop.value <= LOAD_THRESHOLD &&
    !isLoading.value &&
    hasMore.value &&
    isInitialized.value
  ) {
    loadNewMessages();
  }
};


// 初始化
onMounted(() => {
  // 计算容器高度:视口高度减去上下边距和标题区域
  if (containerRef.value) {
    const computedHeight = window.innerHeight - 200; // 等价于 calc(100vh - 200px)
    containerRef.value.style.height = `${Math.max(200, computedHeight)}px`; // 防止负数或太小
  }
  // 确保DOM完全挂载后再初始化数据
  nextTick(() => {
    initData();
  });
});
</script>

<style scoped>
.overflow-anchor-none {
  overflow-anchor: none;
}
</style>

2. 子组件:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.content }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    content: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

3. 效果图:

vue虚拟列表向上加载.gif


四、 React + TailwindCSS实现

在React中我们需要利用 useMemo 优化索引计算,并利用 useLayoutEffect 处理滚动位置,避免视觉闪烁。

1. 虚拟列表组件:

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
} from 'react';
import VirtualListItem from './VirtualListItem';

const MIN_ITEM_HEIGHT = 80; // 每个列表项的最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载
const LOAD_THRESHOLD = 30; // 触发加载的px值
const NEW_DATA_COUNT = 5; // 每次加载的新数据数量
const PRE_LOAD_OFFSET = 100; // 预加载偏移量,用于提前加载部分数据

// 列表项类型定义
interface ListItem {
  id: number; // 列表项的唯一标识符
  content: string; // 列表项的内容
  timestamp: string; // 列表项的时间戳
}

const VirtualList: React.FC = () => {
  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
  const containerRef = useRef<HTMLDivElement>(null); // 列表容器引用
  const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 加载定时器引用
  const initScrollAttemptsRef = useRef(0); // 初始化滚动尝试次数引用,最多10次
  const [listData, setListData] = useState<ListItem[]>([]); // 列表数据状态,初始为空数组
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 列表项高度映射Map
  const [scrollTop, setScrollTop] = useState<number>(0); // 滚动位置状态,初始为0
  const [isLoading, setIsLoading] = useState<boolean>(false); // 加载状态,初始为false
  const [isInitialized, setIsInitialized] = useState<boolean>(false); // 初始化状态,初始为false
  const [hasMore, setHasMore] = useState<boolean>(true); // 是否还有更多数据状态,初始为true

  const minIdRef = useRef(10000); // 最小ID引用,初始为10000
  const isLoadingRef = useRef(false); // 正在加载状态
  const hasMoreRef = useRef(true); // 是否还有更多数据
  const isFirstInitRef = useRef(true); // 是否第一次初始化
  const scrollStateRef = useRef<{
    isManualScroll: boolean;
    lastScrollTop: number;
  }>({
    isManualScroll: false,
    lastScrollTop: 0,
  }); // 滚动状态引用

  // 同步 ref 和 state
  useEffect(() => {
    isLoadingRef.current = isLoading;
    hasMoreRef.current = hasMore;
  }, [isLoading, hasMore]);

  // 计算累计高度
  const cumulativeHeights = useMemo(() => {
    const heights: number[] = [0];
    let currentSum = 0;
    for (const item of listData) {
      const h = itemHeights.get(item.id) || MIN_ITEM_HEIGHT;
      currentSum += h;
      heights.push(currentSum);
    }
    return heights;
  }, [listData, itemHeights]);

  // 列表总高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0) return 0;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < scrollTop) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    const baseIndex = Math.max(0, low - 1);
    return Math.max(0, baseIndex - BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 结束索引
  const endIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0)
      return BUFFER_SIZE * 2;
    const clientHeight = virtualListRef.current.clientHeight;
    const t = scrollTop + clientHeight + PRE_LOAD_OFFSET;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < t) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return Math.min(listData.length, low + BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 可见列表项
  const visibleList = useMemo(() => {
    return listData.slice(startIndex, endIndex);
  }, [listData, startIndex, endIndex]);

  // 偏移量
  const offsetY = useMemo(() => {
    return startIndex === 0 ? 0 : cumulativeHeights[startIndex];
  }, [cumulativeHeights, startIndex]);

  // 生成模拟数据
  const generateData = useCallback(
    (count: number, isInitialLoad: boolean = false) => {
      const arr: ListItem[] = [];
      for (let i = 0; i < count; i++) {
        minIdRef.current--;
        arr.push({
          id: minIdRef.current,
          content: `历史消息 ${minIdRef.current}`,
          timestamp: new Date().toLocaleTimeString(),
        });
      }
      console.log('生成数据:', arr);
      if (!isInitialLoad) {
        arr.reverse();
      }
      return arr;
    },
    []
  );

  // 滚动到底部
  const scrollToBottom = useCallback(() => {
    if (!virtualListRef.current) return;

    const scrollEl = virtualListRef.current;

    // 使用多次尝试,直到成功滚动到底部
    const attemptScroll = () => {
      requestAnimationFrame(() => {
        const scrollHeight = scrollEl.scrollHeight;
        const clientHeight = scrollEl.clientHeight;

        if (scrollHeight > clientHeight) {
          const targetScrollTop = scrollHeight - clientHeight;
          const currentScrollTop = scrollEl.scrollTop;

          // 如果还没到底部,继续滚动
          if (Math.abs(currentScrollTop - targetScrollTop) > 1) {
            scrollEl.scrollTop = targetScrollTop;
            setScrollTop(targetScrollTop);

            // 增加尝试次数
            initScrollAttemptsRef.current++;

            // 最多尝试10次,每次间隔50ms
            if (initScrollAttemptsRef.current < 10) {
              setTimeout(attemptScroll, 50);
            } else {
              console.log('初始化滚动到底部完成');
              isFirstInitRef.current = false;
            }
          } else {
            console.log('已经滚动到底部');
            isFirstInitRef.current = false;
          }
        } else {
          isFirstInitRef.current = false; // 内容高度小于容器高度,不需要滚动
        }
      });
    };

    // 重置尝试次数并开始滚动
    initScrollAttemptsRef.current = 0;
    attemptScroll();
  }, []);

  // 初始化数据
  const initData = useCallback(async () => {
    try {
      const initialData = await new Promise<ListItem[]>((resolve) =>
        setTimeout(() => resolve(generateData(20, true)), 100)
      );
      setListData(initialData);
      setIsInitialized(true);
    } catch (error) {
      console.error('初始化数据失败:', error);
    }
  }, [generateData]);

  // 核心:加载新消息
  const loadNewMessages = useCallback(async () => {
    if (isLoadingRef.current || !hasMoreRef.current || !isInitialized) return;

    isLoadingRef.current = true;
    setIsLoading(true);

    try {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      const newData = generateData(NEW_DATA_COUNT, false);

      const scrollEl = virtualListRef.current;
      if (!scrollEl) return;

      // 1. 记录加载前的滚动位置
      const beforeScrollTop = scrollEl.scrollTop;
      const beforeScrollHeight = scrollEl.scrollHeight;

      // 2. 更新数据
      setListData((prev) => [...newData, ...prev]);

      // 3. 等待DOM更新后调整滚动位置
      requestAnimationFrame(() => {
        if (scrollEl) {
          const afterScrollHeight = scrollEl.scrollHeight;
          const heightAdded = afterScrollHeight - beforeScrollHeight;

          // 关键修复:检查当前是否仍在顶部附近
          const isStillNearTop = scrollEl.scrollTop <= LOAD_THRESHOLD + 50;

          // 只有当用户没有手动滚动且仍在顶部时才调整
          if (!scrollStateRef.current.isManualScroll && isStillNearTop) {
            scrollEl.scrollTop = beforeScrollTop + heightAdded;
            setScrollTop(scrollEl.scrollTop);
          }
        }
      });

      // 模拟没有更多数据
      if (minIdRef.current <= 9000) {
        hasMoreRef.current = false;
        setHasMore(false);
      }
    } catch (error) {
      console.error('加载消息失败:', error);
    } finally {
      isLoadingRef.current = false;
      setIsLoading(false);
    }
  }, [generateData, isInitialized]);

  // 处理列表项高度更新
  const handleItemHeightUpdate = useCallback(
    (id: number, realHeight: number) => {
      setItemHeights((prev) => {
        const newHeights = new Map(prev);
        const oldHeight = newHeights.get(id) || MIN_ITEM_HEIGHT;
        const diff = realHeight - oldHeight;

        if (Math.abs(diff) < 1) return prev;

        newHeights.set(id, realHeight);

        // 自动调整滚动位置
        if (
          virtualListRef.current &&
          !isFirstInitRef.current &&
          !scrollStateRef.current.isManualScroll
        ) {
          const scrollEl = virtualListRef.current;
          const index = listData.findIndex((item) => item.id === id);

          if (index >= 0) {
            const itemTop = cumulativeHeights[index];
            const viewportTop = scrollEl.scrollTop;

            // 仅当元素在视口上方时调整
            if (itemTop < viewportTop) {
              scrollEl.scrollTop += diff;
              setScrollTop(scrollEl.scrollTop);
            }
          }
        }

        return newHeights;
      });
    },
    [listData, cumulativeHeights]
  );

  // 处理滚动事件
  const handleScroll = useCallback(
    (e: React.UIEvent<HTMLDivElement>) => {
      const target = e.target as HTMLDivElement;
      const currentScrollTop = target.scrollTop;
      setScrollTop(currentScrollTop);

      // 标记手动滚动
      scrollStateRef.current = {
        isManualScroll: true,
        lastScrollTop: currentScrollTop,
      };

      // 检查是否需要加载
      const shouldLoad = currentScrollTop <= LOAD_THRESHOLD;

      if (
        shouldLoad &&
        !isLoadingRef.current &&
        hasMoreRef.current &&
        isInitialized
      ) {
        // 清除之前的防抖计时器
        if (loadTimerRef.current) {
          clearTimeout(loadTimerRef.current);
        }

        // 防抖处理
        loadTimerRef.current = setTimeout(() => {
          if (target.scrollTop <= LOAD_THRESHOLD && !isLoadingRef.current) {
            loadNewMessages();
          }
        }, 100);
      }
    },
    [isInitialized, loadNewMessages]
  );

  // 初始化
  useEffect(() => {
    console.log('组件挂载,开始初始化');

    // 设置容器高度
    if (containerRef.current) {
      const computedHeight = window.innerHeight - 200;
      containerRef.current.style.height = `${Math.max(200, computedHeight)}px`;
    }

    initData();

    // 清理函数
    return () => {
      console.log('组件卸载,清理定时器');
      if (loadTimerRef.current) {
        clearTimeout(loadTimerRef.current);
      }
    };
  }, [initData]);

  // 监听总高度变化,在数据完全渲染后滚动到底部
  useEffect(() => {
    if (isInitialized && totalHeight > 0 && isFirstInitRef.current) {
      // 延迟一段时间确保DOM完全渲染
      const timer = setTimeout(() => {
        scrollToBottom();
      }, 300); // 增加延迟时间,确保所有列表项都已渲染并测量高度

      return () => clearTimeout(timer);
    }
  }, [isInitialized, totalHeight, scrollToBottom]);

  // 监听列表数据变化,确保在高度测量后滚动
  useEffect(() => {
    if (listData.length > 0 && isInitialized && isFirstInitRef.current) {
      console.log('列表数据更新,当前数据量:', listData.length);

      // 再给一些时间让所有列表项完成高度测量
      const timer = setTimeout(() => {
        if (isFirstInitRef.current) {
          console.log('高度测量后尝试滚动');
          scrollToBottom();
        }
      }, 500);

      return () => clearTimeout(timer);
    }
  }, [listData.length, isInitialized, scrollToBottom]);

  // 重置手动滚动标记
  useEffect(() => {
    const timer = setTimeout(() => {
      scrollStateRef.current.isManualScroll = false;
    }, 500);
    return () => clearTimeout(timer);
  }, [scrollTop]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div
        ref={containerRef}
        className="bg-white mt-10 rounded-xl border shadow-lg relative"
      >
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
          style={{
            overflowAnchor: 'none',
            overscrollBehavior: 'contain',
            scrollBehavior: 'auto',
          }}
        >
          {/* 加载提示(绝对定位,不影响布局) */}
          {isLoading && (
            <div className="absolute top-0 left-0 right-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500 ">
              <div className="flex items-center space-x-2">
                <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
                <span>正在加载历史消息...</span>
              </div>
            </div>
          )}

          {/* 列表占位容器 */}
          <div
            style={{
              height: `${totalHeight}px`,
              pointerEvents: 'none',
              opacity: 0,
            }}
          ></div>

          {/* 可视区域内容 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{
              transform: `translateY(${offsetY}px)`,
              width: '100%',
            }}
          >
            {visibleList.length === 0 ? (
              <div className="py-4 text-center text-gray-400">
                {listData.length === 0
                  ? '正在初始化...'
                  : '加载更多历史消息...'}
              </div>
            ) : (
              visibleList.map((item) => (
                <VirtualListItem
                  key={item.id}
                  item={item}
                  onUpdateHeight={handleItemHeightUpdate}
                />
              ))
            )}
          </div>

          {/* 没有更多数据的提示 */}
          {!hasMore && (
            <div className="absolute bottom-0 left-0 right-0 py-2 text-center text-sm text-gray-400 bg-white border-t">
              没有更多历史消息了
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

2. 子组件:

import React, {
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

export interface ListItemProps {
  item: {
    id: number;
    content: string;
    timestamp: string;
  };
  onUpdateHeight: (id: number, height: number) => void;
}

const VirtualListItem = forwardRef<HTMLDivElement, ListItemProps>(
  ({ item, onUpdateHeight }, ref) => {
    const itemRef = useRef<HTMLDivElement>(null);
    const resizeObserverRef = useRef<ResizeObserver | null>(null);

    useImperativeHandle(ref, () => {
      if (itemRef.current) {
        return itemRef.current;
      }
      // 提供一个安全的默认值
      const emptyDiv = document.createElement('div');
      return emptyDiv;
    });

    // 使用 ResizeObserver 监听尺寸变化
    useEffect(() => {
      const updateHeight = () => {
        if (itemRef.current) {
          const height = itemRef.current.offsetHeight;
          onUpdateHeight(item.id, height);
        }
      };

      // 立即执行一次初始测量
      updateHeight();

      if (!resizeObserverRef.current) {
        resizeObserverRef.current = new ResizeObserver(() => {
          // 防抖处理,避免频繁触发
          if (itemRef.current) {
            requestAnimationFrame(updateHeight);
          }
        });
      }

      if (itemRef.current && resizeObserverRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }

      // 额外的初始延迟测量,确保样式已应用
      const timer = setTimeout(() => {
        updateHeight();
      }, 10);

      return () => {
        if (resizeObserverRef.current && itemRef.current) {
          resizeObserverRef.current.unobserve(itemRef.current);
        }
        clearTimeout(timer);
      };
    }, [item.id, onUpdateHeight]);

    // 模拟不同的内容高度
    const itemStyle: React.CSSProperties = {
      height: item.id % 2 === 0 ? '150px' : '100px',
    };

    const itemClass = `${item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'}`;

    return (
      <div ref={itemRef} className={itemClass} style={itemStyle}>
        {item.id}
      </div>
    );
  }
);

VirtualListItem.displayName = 'VirtualListItem';
export default VirtualListItem;

3. 效果图:

react虚拟列表向上加载.gif


五、 注意事项

  • 浏览器干扰:必须设置 overflow-anchor: none。现代浏览器尝试自动调整滚动位置,这会与我们的手动补偿冲突。

  • 索引边界检查:对切片索引执行 Math.max(0, ...)Math.min(total, ...) 的区间收敛,防止因 startIndexendIndex 越界导致的渲染异常。

  • 初始化时机:首次加载数据后,应调用 scrollToBottom()。为了确保渲染完成,建议采用 requestAnimationFrame + setTimeout 的双重保险。

  • 无感加载策略:执行头部数据插入前,需快照记录当前的 scrollHeight。数据推送至渲染引擎后,通过 newScrollHeight - oldScrollHeight 算得 空间增量,并将其累加至当前滚动偏移量上。该补偿逻辑需在渲染刷新前完成,以实现“无感加载”

  • 性能瓶颈:随着 listData 增加到数万条,cumulativeHeights 的计算可能变慢。此时可考虑分段计算维护高度。


“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

2026年2月8日 17:14

“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

案件概述

异常现象:当我用 iframe.srcdoc动态生成一个报告页面,并想自动调起打印时,打印窗口死活不弹出来,打印完成的回调函数也永远不会执行。代码看起来没问题,但就是无效。

初步怀疑:是不是 srcdoc把我刚绑定的事件监听器给“冲走了”?


第一现场:重现“案发”过程

这是当时“案发”的代码片段:

// 1. 给 iframe 灌入新内容
let frame = document.getElementById('myFrame');
frame.srcdoc = `<h1>我的报告</h1><p>请打印我</p>`;

// 2. 立刻绑定打印完成后的回调
frame.contentWindow.addEventListener('afterprint', function() {
  console.log('打印完成!'); // 🚨 这条日志从未出现!
});

// 3. 立刻下令打印
frame.contentWindow.print(); // 🚨 打印窗口毫无反应!

直观感受:代码执行了,但像石沉大海,没有任何效果和报错。


侦查实验:逐一排除嫌疑

我们做了几个关键实验来排查。

实验一:事件监听器真的被“冲走了”吗?

我们在设置新内容前后,绑定一个自己能控制的“信号弹”(自定义事件)。

frame.addEventListener('信号弹', () => console.log('监听器A在'));
frame.srcdoc = `<h1>新内容</h1>`;
frame.addEventListener('信号弹', () => console.log('监听器B也在'));

// 发射信号弹
frame.dispatchEvent(new Event('信号弹'));
// 控制台输出:监听器A在 | 监听器B也在

✅ 结论:监听器没有消失。两个都还在正常工作。所以“冲走监听器”的嫌疑被排除了。

实验二:如果等一会儿再打印呢?

我们怀疑是不是命令下得太急了。

frame.srcdoc = `<h1>新内容</h1>`;
setTimeout(() => {
  frame.contentWindow.print(); // 🕐 延迟1秒后:打印窗口弹出了!
  console.log('打印调用成功,但 afterprint 仍不触发');
}, 1000);

⚠️ 新发现等待足够时间后,打印命令能执行了,但 afterprint事件依然不触发。 这说明事件绑定的时机可能也有问题。

实验三:找到那个“正确时机”

我们尝试在 iframe 自己宣布“我准备好了”的时候再行动。

frame.srcdoc = `<h1>新内容</h1>`;

// 监听 iframe 的“准备好”信号
frame.onload = function() {
  // 等它喊“准备好”了,我们再绑定和打印
  frame.contentWindow.addEventListener('afterprint', function() {
    console.log('✅✅✅ 打印完成!'); // 这次成功了!
  });
  frame.contentWindow.print(); // 打印窗口正常弹出
};

✅ 决定性证据:在 onload事件里操作,一切完全正常


案情复盘:到底发生了什么?

我们可以把 iframe.srcdoc = ‘...’这个过程,想象成给一个房间(iframe)进行彻底的重装修

  1. 拆旧:浏览器先把房间里(iframe 内)所有旧的家具、管道(旧的文档、窗口)全清空。

  2. 异步装修:然后开始根据你给的新图纸(HTML字符串)异步施工。这需要时间,水电、墙面、家具都在同步安排。

  3. 施工中:在装修队喊“完工啦!”(触发 load事件)之前,这个房间处于施工状态

    • 你对着一个还在铺水泥的墙面(不稳定的内部窗口)喊“打印!”(print()),工人会无视你。
    • 你告诉一面还没砌好的墙“打印完喊我一声”(绑 afterprint),这个请求可能会丢失。
  4. 竣工:只有等 onload事件触发,才代表房间完全装修好,水电全通,可以正式投入使用。这时你的所有指令都能被正确接收和执行。

所以,核心不是监听器被“删除”,而是你对着一个“半成品”发出了指令。


解决方案:两个可靠的行动指南

方案一:等待“竣工典礼”(最推荐)

做法:用 srcdoc设置内容,但所有操作都放到 iframe.onload回调函数里。

优点:逻辑清晰,是现代 API 的标准用法。

iframe.srcdoc = ‘你的HTML内容’;
iframe.onload = function() {
  // 在这里进行所有“室内操作”
  iframe.contentWindow.addEventListener(‘afterprint’, 你的回调);
  iframe.contentWindow.print();
};

方案二:使用“魔法瞬间重建”

做法:不用 srcdoc,改用传统的 document.write()来同步写入内容。

原理document.write()会在你写下内容的同一时刻,同步、立即地重建整个文档,没有“施工中”的等待期。写完后立即可用。

优点:无需等待 onload,立即生效。

let doc = iframe.contentWindow.document;
doc.open();
doc.write(‘你的完整HTML内容’); // 魔法发生,内容瞬间被替换
doc.close();
// 紧接着就可以操作,因为文档已经就绪
iframe.contentWindow.print();

构建无障碍组件之Alert Dialog Pattern

作者 anOnion
2026年2月8日 16:43

Alert Dialog Pattern 详解:构建无障碍中断式对话框

Alert Dialog 是 Web 无障碍交互的重要组件。本文详解其 WAI-ARIA 实现要点,涵盖角色声明、键盘交互、最佳实践,助你打造中断式对话框,让关键信息触达每位用户。

一、Alert Dialog 的定义与核心功能

Alert Dialog(警告对话框)是一种模态对话框,它会中断用户的工作流程以传达重要信息并获取响应。与普通的 Alert 通知不同,Alert Dialog 需要用户明确与之交互后才能继续其他操作。这种设计适用于需要用户立即关注和做出决定的场景。

在实际应用中,Alert Dialog 广泛应用于各种需要用户确认或紧急通知的场景。例如,删除操作前的确认提示、表单提交失败的错误确认、离开页面时的未保存更改提醒等。这些场景都需要用户明确响应才能继续操作,因此 Alert Dialog 成为最佳选择。

二、Alert Dialog 的特性与注意事项

Alert Dialog 组件具有几个重要的特性,这些特性决定了它的适用场景和实现方式。首先,Alert Dialog 会获取键盘焦点,确保用户的注意力集中在对话框上。其次,Alert Dialog 通常会阻止用户与页面的其他部分交互,直到用户关闭对话框。这种模态特性确保了用户必须处理重要信息才能继续操作。

Alert Dialog 组件的设计还需要考虑几个关键因素。首先,Alert Dialog 应该始终包含一个明确的关闭方式,如确认按钮或取消按钮。其次,对话框应该有一个清晰的标题,通过 aria-labelledbyaria-label 关联。另外,对话框的内容应该通过 aria-describedby 关联,以便屏幕阅读器能够正确读取完整信息。这些属性的正确使用对于无障碍体验至关重要。

三、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍 Alert Dialog 组件的技术基础。Alert Dialog 组件的 ARIA 要求包含多个属性的配合使用。

role="alertdialog" 是 Alert Dialog 组件的必需属性,它向辅助技术表明这个元素是一个警告对话框。这个属性使浏览器和辅助技术能够将 Alert Dialog 与其他类型的对话框区分开来,从而提供特殊的处理方式,如播放系统提示音。

aria-labelledbyaria-label 用于标识对话框的标题。如果对话框有可见的标题标签,应该使用 aria-labelledby 引用该标题元素;如果没有可见标题,则使用 aria-label 提供标签。

aria-describedby 用于引用包含警告消息的元素。这确保屏幕阅读器能够朗读完整的对话框内容,包括详细的说明和操作提示。

<!-- Alert Dialog 基本结构 -->
<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="actions">
      <button value="confirm">确认删除</button>
      <button value="cancel">取消</button>
    </div>
  </form>
</dialog>

值得注意的是,Alert Dialog 与普通 Dialog 的主要区别在于 Alert Dialog 用于紧急或重要信息,并且通常包含确认/取消按钮。用户无法忽略 Alert Dialog,必须做出响应才能继续操作。

四、键盘交互规范

Alert Dialog 的键盘交互遵循模态对话框的交互模式。用户可以通过多种方式与 Alert Dialog 进行交互。

  • EnterSpace 用于激活默认按钮,通常是对话框中的主要操作按钮。
  • Tab 键用于在对话框内的焦点元素之间切换,焦点会循环停留 在对话框内部。
  • Escape 键通常用于关闭对话框,相当于点击取消按钮。
// ESC 键关闭对话框示例
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && dialog.open) {
    dialog.close();
  }
});

焦点管理是 Alert Dialog 的关键部分。当对话框打开时,焦点应该立即移动到对话框内部或默认按钮上。当对话框关闭时,焦点应该返回到打开对话框的元素。这种焦点管理确保了键盘用户能够保持其工作上下文。

五、完整示例

以下是一个完整的 Alert Dialog 实现示例,展示了正确的 HTML 结构、ARIA 属性和焦点管理。

<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="dialog-actions">
      <button
        class="btn btn-ghost"
        value="cancel">
        取消
      </button>
      <button
        class="btn btn-error"
        value="confirm">
        删除
      </button>
    </div>
  </form>
</dialog>

<button
  id="delete-btn"
  class="btn btn-error">
  删除文件
</button>

<script>
  const dialog = document.getElementById('confirm-dialog');
  const deleteBtn = document.getElementById('delete-btn');
  let previousActiveElement;

  deleteBtn.addEventListener('click', function () {
    previousActiveElement = document.activeElement;
    dialog.showModal();
  });

  dialog.addEventListener('close', function () {
    if (dialog.returnValue === 'confirm') {
      console.log('文件已删除');
    }
    previousActiveElement.focus();
  });
</script>

六、最佳实践

6.1 实现方式对比

Alert Dialog 可以通过两种方式实现:使用 div 配合 ARIA 属性,或使用原生 <dialog> 元素。

传统方式(div + ARIA)
<div
  role="alertdialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <h2 id="dialog-title">确认删除</h2>
  <p id="dialog-desc">您确定要删除这个文件吗?</p>
  <button>确认</button>
  <button>取消</button>
</div>

这种方式需要开发者手动处理焦点管理、ESC 键关闭、背景锁定等逻辑。

推荐方式(原生 dialog)
<dialog
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?</p>
    <button value="confirm">确认</button>
    <button value="cancel">取消</button>
  </form>
</dialog>

HTML 原生 <dialog> 元素简化了实现,它提供了:

  • 自动焦点管理
  • 内置 ESC 键支持
  • 自动模态背景
  • 内置 ARIA 属性

<dialog> 元素的默认 roledialog,表示普通对话框。对于 Alert Dialog,需要显式设置 role="alertdialog" 来告诉辅助技术这是一个需要紧急处理的对话框,从而获得系统提示音等特殊处理。

6.2 焦点管理

正确的焦点管理对于键盘用户和无障碍体验至关重要。打开对话框时,焦点应该移动到对话框内部或默认按钮。关闭对话框时,焦点应该返回到触发对话框的元素。

// 焦点管理最佳实践
function openDialog(dialog) {
  const previousFocus = document.activeElement;
  dialog.showModal();

  // 移动焦点到对话框内
  const focusableElements = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  );
  if (focusableElements.length > 0) {
    focusableElements[0].focus();
  }

  // 保存关闭时的焦点元素
  dialog.dataset.previousFocus = previousFocus;
}

function closeDialog(dialog) {
  dialog.close();
  const previousFocus = document.querySelector(
    `[data-focus-id="${dialog.dataset.focusId}"]`,
  );
  if (previousFocus) {
    previousFocus.focus();
  }
  dialog.remove();
}

6.3 避免过度使用

Alert Dialog 会中断用户的工作流程,因此应该谨慎使用。只有在真正需要用户立即响应的情况下才使用 Alert Dialog。对于非紧急信息,应该考虑使用普通的 Alert 或 Toast 通知。

<!-- 不推荐:过度使用 Alert Dialog -->
<dialog
  open
  role="alertdialog">
  <h2>提示</h2>
  <p>您的设置已保存。</p>
  <button onclick="this.closest('dialog').close()">确定</button>
</dialog>

<!-- 推荐:使用普通 Alert -->
<div role="alert">您的设置已保存。</div>

6.4 屏幕阅读器兼容性

确保 <dialog> 对屏幕阅读器用户友好。<dialog> 元素内置了无障碍支持,但仍然建议对 Alert Dialog 设置 role="alertdialog" 来区分紧急对话框。

<!-- 屏幕阅读器友好的 dialog -->
<dialog
  id="session-dialog"
  role="alertdialog">
  <form method="dialog">
    <h2>重要提醒</h2>
    <p>您的会话将在 5 分钟后过期。请尽快保存您的工作。</p>
    <div class="actions">
      <button value="continue">继续使用</button>
      <button value="exit">退出</button>
    </div>
  </form>
</dialog>

七、Alert 与 Alert Dialog 的区别

理解 AlertAlert Dialog 的区别对于正确选择通知组件至关重要。虽然两者都是用于传达重要信息,但它们服务于不同的目的和使用场景。

Alert 是一种被动通知组件,它不需要用户进行任何交互操作。Alert 会在不被中断用户工作流程的前提下自动通知用户重要信息。用户可以继续当前的工作,Alert 只是在视觉和听觉上提供通知。这种设计适用于不紧急、不需要用户立即响应的信息,例如操作成功确认、后台处理完成通知等。

Alert Dialog 则是一种需要用户主动响应的对话框组件。当用户需要做出决定或者提供确认时,应该使用 Alert Dialog。Alert Dialog 会中断用户的工作流程,获取键盘焦点,要求用户必须与之交互才能继续其他操作。这种设计适用于紧急警告、确认删除操作、放弃更改确认等需要用户明确响应的场景。

选择建议:如果信息需要用户立即响应并做出决定,使用 Alert Dialog;如果只是被动通知信息,使用 Alert。

八、总结

构建无障碍的对话框组件需要关注元素选择、焦点管理、键盘交互三个层面的细节。从元素选择角度,推荐优先使用原生 <dialog> 元素,它内置了无障碍支持和焦点管理。从焦点管理角度,需要确保打开和关闭时焦点的正确移动。从用户体验角度,应该避免过度使用对话框,只在真正需要用户响应时使用。

WAI-ARIA Alert Dialog Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的对话框,都是提升用户体验和确保重要信息有效传达的重要一步。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

《 Koa.js 》教程 | 一份不可多得的 Node.js 的 Web 框架 Koa.js 教程

2026年2月8日 15:49

第一章 安装和配置 koa

Koa 是一个轻量级、现代化的框架, 由 Express 原班人马开发

初始化配置文件 package.json

npm init -y

配置 package.json (ESM规范)

{
     "type": "module",
     "name": "demo",
     "version": "1.0.0",
     "main": "index.js",
     "scripts": {
          "dev":"nodemon index.js",
           "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [],
     "author": "",
     "license": "ISC",
     "description": ""
}

npm 官网

     www.npmjs.com

安装koa      

npm i koa

     全局安装 nodemon

  .  npm i nodemon -g

     当 nodemon 检测到监视的文件发生更改时, 会自动重新启动应用

第二章 创建并启动 http 服务器

中间件

中间件是处理 HTTP 请求和响应的函数,它们可以做以下操作:

  • 处理请求(例如解析请求体、验证用户身份等)
  • 修改响应(例如设置响应头、发送响应体等)
  • 执行后续中间件

中间件 - 很重要的概念 !!!!!!!

注意 : app.use() 方法用于注册 中间件

中间件 是处理 http 请求和响应的函数 , 当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件

上下文对象 ctx

在 Koa 中,ctx(上下文)对象是每个中间件函数的核心,它包含了请求和响应的所有信息。所有的 HTTP 请求和响应都通过 ctx 进行处理。

上下文对象 ctx ( context ) 包含了与当前 http 请求相关的所有信息

如: http方法、url、请求头、请求体、查询参数等

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

app.use(async ctx => {
    ctx.body = "juejin.cn" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第三章 洋葱模型

洋葱模型

当你处理一个请求时,

可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分

这个过程涉及对 请求 的多个层面进行解析、验证、处理

在处理完洋葱(请求)后,

构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,

从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)

image.png

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

/*
    app.use() 方法用于注册中间件
    中间件是处理 http 请求和响应的函数
    当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件
    
    上下文对象 ctx(context) 包含了与当前 http 请求相关的所有信息
    如: http方法、url、请求头、请求体、查询参数等
*/
app.use(async (ctx,next) => {
    console.log(1)
    await next() //若中间件调用了next(),会暂停当前中间件的执行,将控制权传递给下一个中间件
    console.log(2)
})

app.use(async (ctx,next) => { 
    console.log(3)
    await next()
    console.log(4)
})

//当中间件没有再调用next(),则不需要再将控制权传递给下一个中间件,控制权会按照相反的顺序执行
app.use(async (ctx,next) => {
    console.log(5)
    ctx.body = "dengruicode.com" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第四章 安装和配置路由 - get请求

在 Koa 中,koa-router 是一个轻量级的路由中间件,它可以帮助你定义路由、处理 HTTP 请求并解析请求参数。通过使用 koa-router,你可以创建一个灵活的路由系统,轻松地组织和管理 Koa 应用的各个部分。

安装 koa-router

首先,你需要安装 koa-router

npm install @koa/router       # 注意:新版 koa-router 包名是 @koa/router
import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router() //实例化一个 Router 对象

//------ get请求
//路由是根据客户端发送的请求(包括请求的路径、方法等)调用与之匹配的处理函数
//根路由 http://127.0.0.1:8008/
router.get('/', async ctx => { //get请求
    ctx.body = "dengruicode.com"
})

//查询参数 http://127.0.0.1:8008/test?id=001&web=dengruicode.com
router.get('/test', async ctx => { //get请求
    let id = ctx.query.id
    let web = ctx.query.web
    ctx.body = id + " : " + web
})

//路径参数 http://127.0.0.1:8008/test2/id/002/web/www.dengruicode.com
router.get('/test2/id/:id/web/:web', async ctx => {
    let id = ctx.params.id
    let web = ctx.params.web
    ctx.body = id + " : " + web
})

//重定向路由 http://127.0.0.1:8008/test3
router.redirect('/test3', 'https://www.baidu.com')

app.use(router.routes()) //将定义在 router 对象中的路由规则添加到 app 实例中

//------ 路由分组
//http://127.0.0.1:8008/user/add
//http://127.0.0.1:8008/user/del

const userRouter = new Router({ prefix: '/user' })
userRouter.get('/add', async ctx => {
    ctx.body = "添加用户"
})
userRouter.get('/del', async ctx => {
    ctx.body = "删除用户"
})
app.use(userRouter.routes())

// 在所有路由之后添加404处理函数
app.use(async ctx => {
    if (!ctx.body) { //若没有设置 ctx.body, 则说明没有到匹配任何路由
        ctx.status = 404
        ctx.body = '404 Not Found'
    }
})

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第五章 post请求

安装 koa-body

Koa 原生不支持解析 POST 请求体,需安装 koa-body 中间件:

npm install koa-body

POST 请求处理示例

修改 src/index.js,新增 POST 路由:

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';

const app = new Koa();
const router = new Router();
const port = 8008;

// 注册 koa-body 中间件:解析 JSON、表单、文件类型的 POST 数据
app.use(koaBody({
  multipart: true, // 支持文件上传(后续第八章用)
  json: true, // 解析 JSON 格式
  urlencoded: true // 解析表单格式(application/x-www-form-urlencoded)
}));

// 1. 处理 JSON 格式 POST 请求
router.post('/api/json', async (ctx) => {
  const { name, age } = ctx.request.body;
  ctx.body = {       // ctx.request.body 是 koa-body 解析后的 POST 数据
    code: 200,
    msg: "JSON 数据接收成功",
    data: { name, age }
  };
});

// 2. 处理表单格式 POST 请求
router.post('/api/form', async (ctx) => {
  const { username, password } = ctx.request.body;
  ctx.body = {
    code: 200,
    msg: "表单数据接收成功",
    data: { username, password }
  };
});

app.use(router.routes());

// 404 处理
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = '404 Not Found';
});

app.listen(port, () => {
  console.log(`POST 服务器启动:http://localhost:${port}`);
});

测试 POST 请求(两种方式)

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/json

  • 请求方法:POST

  • 请求体:选择 raw > JSON,输入:

    { "name": "张三", "age": 20 }
    
  • 响应:{"code":200,"msg":"JSON 数据接收成功","data":{"name":"张三","age":20}}

方式 2:curl 命令测试

# 测试 JSON 格式
curl -X POST -H "Content-Type: application/json" -d '{"name":"张三","age":20}' http://localhost:8008/api/json

# 测试表单格式
curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/form

第六章 错误处理

import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router()

//http://127.0.0.1:8008/
router.get('/', async ctx => {
    throw new Error("测试")
})

/*
    将 '错误处理中间件' 放在 '路由处理中间件' 之前, 当一个请求到达时,
    会先经过 '错误处理中间件', 然后才会进入 '路由处理中间件',
    是为了确保可以捕获错误
*/
app.use(async (ctx, next) => {  // 错误处理中间件
    try {
        await next()
    } catch (err) {
        //console.log('err:', err)
        ctx.status = 500
        ctx.body = 'err: ' + err.message
    }
})

app.use(router.routes())   // 路由处理中间件

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第七章 允许跨域请求

安装跨域中间件

npm install @koa/cors

跨域配置示例

import Koa from 'koa';
import Router from '@koa/router';
import Cors from '@koa/cors';

const app = new Koa();
const router = new Router();
const port = 8008;

app.use(Cors()) //允许跨域请求

// 测试跨域路由
router.get('/api/cors', async (ctx) => {
  ctx.body = {
    code: 200,
    msg: "跨域请求成功"
  };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`跨域服务器启动:http://localhost:${port}`);
});

测试跨域

在任意前端项目(如 Vue / React / HTML 文件)中发送请求:

// 前端代码示例
fetch('http://localhost:8008/api/cors')
  .then(res => res.json())
  .then(data => console.log(data)) // 输出 {code:200, msg:"跨域请求成功"}
  .catch(err => console.error(err));

无跨域报错即配置成功。

第八章 上传图片

依赖准备(复用 koa-body)

koa-body 已支持文件上传,无需额外安装依赖,只需确保配置 multipart: true

图片上传示例

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';
import fs from 'fs';
import path from 'path';

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 创建上传目录(不存在则创建)
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 2. 配置 koa-body 支持文件上传
app.use(koaBody({
  multipart: true, // 开启文件上传
  formidable: {
    uploadDir: uploadDir, // 临时存储目录
    keepExtensions: true, // 保留文件扩展名(如 .png/.jpg)
    maxFieldsSize: 2 * 1024 * 1024, // 限制文件大小 2MB
    filename: (name, ext, part, form) => {
      // 自定义文件名:时间戳 + 原扩展名,避免重复
      return Date.now() + ext;
    }
  }
}));

// 3. 图片上传接口
router.post('/api/upload', async (ctx) => {
  // ctx.request.files 是上传的文件对象
  const file = ctx.request.files.file; // 前端上传的文件字段名需为 file
  if (!file) {
    ctx.status = 400;
    ctx.body = { code: 400, msg: "请选择上传的图片" };
    return;
  }

  // 返回文件信息
  ctx.body = {
    code: 200,
    msg: "图片上传成功",
    data: {
      filename: file.newFilename, // 自定义后的文件名
      path: `/uploads/${file.newFilename}`, // 访问路径
      size: file.size // 文件大小(字节)
    }
  };
});

// 4. 静态文件访问:让上传的图片可通过 URL 访问
app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/uploads/')) {
    const filePath = path.join(uploadDir, ctx.path.replace('/uploads/', ''));
    if (fs.existsSync(filePath)) {
      ctx.type = path.extname(filePath).slice(1); // 设置响应类型(如 png/jpg)
      ctx.body = fs.createReadStream(filePath); // 读取文件并返回
      return;
    }
    ctx.status = 404;
    ctx.body = "文件不存在";
    return;
  }
  await next();
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`图片上传服务器启动:http://localhost:${port}`);
});

测试图片上传

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/upload
  • 请求方法:POST
  • 请求体:选择 form-data,Key 为 file,Type 选 File,上传一张图片。
  • 响应:返回文件路径,如 http://localhost:8008/uploads/1738987654321.png,访问该 URL 可查看图片。

方式 2:curl 命令测试

终端输入 bash 命令

curl -X POST -F "file=@/你的图片路径/xxx.png" http://localhost:8008/api/upload

第九章 cookie

Cookie 是存储在客户端浏览器的小型文本数据,Koa 内置 ctx.cookies API 可以操作 Cookie。

Cookie 操作示例

import Koa from 'koa'
import Router from '@koa/router'
 
const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 设置 Cookie
router.get('/cookie/set', async (ctx) => {
  // ctx.cookies.set(名称, 值, 配置)
  ctx.cookies.set(
    'username', 
    encodeURIComponent('张三'), 
    {
      maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天(毫秒)
      httpOnly: true, // 仅允许服务端访问,防止 XSS 攻击
      secure: false, // 开发环境设为 false(HTTPS 环境设为 true)
      path: '/', // 生效路径(/ 表示全站)
      sameSite: 'lax' // 防止 CSRF 攻击
    }
  );
  ctx.body = { code: 200, msg: "Cookie 设置成功" };
});

// 2. 获取 Cookie
router.get('/cookie/get', async (ctx) => {
  const username = ctx.cookies.get('username');
  ctx.body = {
    code: 200,
    msg: "Cookie 获取成功",
    data: { username }
  };
});

// 3. 删除 Cookie
router.get('/cookie/delete', async (ctx) => {
  ctx.cookies.set('username', '', { maxAge: 0 }); // 设置 maxAge 为 0 即删除
  ctx.body = { code: 200, msg: "Cookie 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Cookie 服务器启动:http://localhost:${port}`);
});

测试 Cookie

  1. 访问 http://localhost:8008/cookie/set → 设置 Cookie;
  2. 访问 http://localhost:8008/cookie/get → 获取 Cookie,输出 {username: "张三"}
  3. 访问 http://localhost:8008/cookie/delete → 删除 Cookie,再次获取则为 undefined

第十章 session

安装 Session 中间件

Koa 原生不支持 Session,需安装 koa-session

npm install koa-session

Session 配置示例

import Koa from 'koa'
import Router from '@koa/router'
import session  from 'koa-session'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 配置 Session 密钥(生产环境需改为随机字符串)
app.keys = ['dengruicode_secret_key'];

// 2. Session 配置
const CONFIG = {
  key: 'koa:sess', // Session Cookie 名称
  maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天
  autoCommit: true,
  overwrite: true,
  httpOnly: true, // 仅服务端访问
  signed: true, // 签名 Cookie,防止篡改
  rolling: false, // 不刷新过期时间
  renew: false, // 快过期时自动续期
  secure: false, // 开发环境 false
  sameSite: 'lax'
};

// 3. 注册 Session 中间件
app.use(session(CONFIG, app));

// 4. Session 操作
// 设置 Session
router.get('/session/set', async (ctx) => {
  ctx.session.user = {
    id: 1,
    name: "张三",
    age: 20
  };
  ctx.body = { code: 200, msg: "Session 设置成功" };
});

// 获取 Session
router.get('/session/get', async (ctx) => {
  const user = ctx.session.user;
  ctx.body = {
    code: 200,
    msg: "Session 获取成功",
    data: { user }
  };
});

// 删除 Session
router.get('/session/delete', async (ctx) => {
  ctx.session = null; // 清空 Session
  ctx.body = { code: 200, msg: "Session 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Session 服务器启动:http://localhost:${port}`);
});

测试 Session

  1. 访问 http://localhost:8008/session/set → 设置 Session;
  2. 访问 http://localhost:8008/session/get → 获取 Session,输出用户信息;
  3. 访问 http://localhost:8008/session/delete → 清空 Session,再次获取则为 undefined

注意:koa-session 是基于 Cookie 的内存 Session,生产环境建议使用 koa-redis 将 Session 存储到 Redis,避免服务重启丢失数据。

第十一章 jwt

安装 JWT 依赖

npm install jsonwebtoken koa-jwt
  • jsonwebtoken:生成 / 解析 JWT 令牌;
  • koa-jwt:验证 JWT 令牌的中间件。

JWT 完整示例

import Koa from 'koa'
import Router from '@koa/router'
import jwt  from 'jsonwebtoken'
import koaJwt  from 'koa-jwt'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. JWT 密钥(生产环境需加密存储)
const JWT_SECRET = 'dengruicode_jwt_secret';
// JWT 过期时间:1 小时(秒)
const JWT_EXPIRES_IN = 3600;

// 2. 登录接口:生成 JWT 令牌
router.post('/api/login', async (ctx) => {
  // 模拟验证用户名密码(生产环境需查数据库)
  const { username, password } = ctx.request.body;
  if (username === 'admin' && password === '123456') {
    // 生成 JWT 令牌
    const token = jwt.sign(
      { id: 1, username }, // 载荷:存储用户信息(不要存敏感数据)
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );
    ctx.body = {
      code: 200,
      msg: "登录成功",
      data: { token }
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 401, msg: "用户名或密码错误" };
  }
});

// 3. 受保护的接口:需要 JWT 验证
// koa-jwt 中间件会自动解析 Authorization 头中的 token
app.use(koaJwt({ secret: JWT_SECRET }).unless({
  path: [/^/api/login/] // 排除登录接口,无需验证
}));

// 4. 获取用户信息接口(需验证 JWT)
router.get('/api/user/info', async (ctx) => {
  // ctx.state.user 是 koa-jwt 解析后的 JWT 载荷
  const { id, username } = ctx.state.user;
  ctx.body = {
    code: 200,
    msg: "获取用户信息成功",
    data: { id, username }
  };
});

app.use(router.routes());

// 5. JWT 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = { code: 401, msg: "token 无效或过期" };
    } else {
      throw err;
    }
  }
});

app.listen(port, () => {
  console.log(`JWT 服务器启动:http://localhost:${port}`);
});

测试 JWT

步骤 1:登录获取 token

curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/login
# 响应:{"code":200,"msg":"登录成功","data":{"token":"xxx.xxx.xxx"}}

步骤 2:携带 token 访问受保护接口

curl -H "Authorization: Bearer 你的token" http://localhost:8008/api/user/info
# 响应:{"code":200,"msg":"获取用户信息成功","data":{"id":1,"username":"admin"}}

步骤 3:token 无效 / 过期测试

携带错误 token 或过期 token 访问,会返回 {"code":401,"msg":"token 无效或过期"}

总结

  1. 核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;

  2. 关键依赖@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT);

  3. 生产建议

    • Session/JWT 密钥需随机生成并加密存储;

    • 文件上传需限制大小和类型,防止恶意上传;

    • 跨域需指定具体域名,而非 *

    • JWT 载荷不要存敏感数据,过期时间不宜过长。

pnpm-workspace.yaml

作者 code_YuJun
2026年2月8日 15:32

pnpm-workspace.yamlpnpm 的“项目组织与调度中枢”,告诉 pnpm:哪些目录是同一个 workspace,以及这些包之间如何协同工作。

定义哪些包属于同一个仓库

packages:
  - packages/*
  - apps/*
  • packages/* 下面每个有 package.json 的目录,都是一个包
  • apps/* 下面每个 app 也是一个包

Workspace 内包本地互相引用

packages/
  utils/
  ui/
apps/
  admin/

apps/admin/package.json 里:

{
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

效果是:

  • 不去 npm 下载
  • 直接 软链接到本地 packages/utils
  • 改代码立刻生效

这是 monorepo 的灵魂能力。

依赖统一安装、统一锁定

在根目录执行pnpm install
pnpm 会:

  • 扫描 pnpm-workspace.yaml 里的所有包
  • 统一生成 一份 pnpm-lock.yaml
  • 所有包共享同一个依赖树

支持 catalog

pnpm-workspace.yaml 里可以这样写:

catalog:
  vite: ^5.1.0
  vue: ^3.4.0
  typescript: ^5.3.3

子包中:

"devDependencies": {
  "vite": "catalog:",
  "vue": "catalog:"
}

版本集中管理,企业级工程标配.

支持 workspace 协议(workspace:*)

"@my/ui": "workspace:*"     // 任意版本
"@my/ui": "workspace:^"     // 遵循 semver
"@my/ui": "workspace:~"

批量执行命令

pnpm -r build
pnpm -r test
pnpm -r lint
  • -r = recursive
  • 对 workspace 里的 所有包 执行

corepack 作用

作者 code_YuJun
2026年2月8日 15:06

corepack 可以把它理解成 Node.js 自带的“包管理器管理器”
corepack 用来管理和锁定项目使用的包管理器(比如 pnpm / yarn),而不是管理依赖本身。

为什么会有 corepack

以前的情况很乱:

  • 有的人用 npm
  • 有的人用 yarn
  • 有的人用 pnpm
  • 同一个项目里,不同人用的 包管理器版本还不一样

结果就是:

“我这能跑,你那为啥装不起来?”

corepack 的出现,就是为了解决 “到底用哪个包管理器、用哪个版本” 这个问题。

corepack 能干什么

1️⃣ 统一项目使用的包管理器

package.json 里可以写:

{
  "packageManager": "pnpm@8.15.4"
}

含义是:

这个项目 必须pnpm,而且版本是 8.15.4

这时候:

  • pnpm install
  • 同事 npm install
  • CI 里跑 pnpm install

👉 corepack 会自动帮你下载并使用正确版本的 pnpm

不用大家手动装。


2️⃣ 自动安装 & 切换 yarn / pnpm

你甚至不需要提前全局装 pnpm:

corepack enable
pnpm install

如果项目声明的是:

"packageManager": "yarn@3.6.1"

corepack 会:

  • 自动下载 yarn 3.6.1
  • 用它来执行命令

你本地有没有 yarn 👉 不重要


3️⃣ 防止“包管理器版本不一致”的坑

比如:

  • A 用 pnpm 7
  • B 用 pnpm 8
  • lock 文件结构都不一样

corepack 可以 强制版本一致,从源头避免:

  • lockfile 被反复改
  • CI 跑不过
  • “我这没问题啊”的玄学 bug

corepack 和 npm / yarn / pnpm 的关系

可以这么理解👇

corepack
  ├── 管理 pnpm
  ├── 管理 yarn
  └── 管理 npm(间接)
  • npm / yarn / pnpm:真正干活的
  • corepack:负责“发工具、管版本、做协调”

常用命令速览 🧠

# 启用 corepack(Node 16+ 自带)
corepack enable

# 查看当前 corepack 版本
corepack --version

# 指定并激活某个包管理器版本
corepack prepare pnpm@8.15.4 --activate

什么时候一定要用 corepack

非常推荐用在这些场景👇

  • 团队协作项目
  • monorepo(pnpm / yarn workspace)
  • CI / Docker / 线上构建
  • 你已经被 “lockfile 一直变” 折磨过 😅

一句话总结

corepack 不是用来装依赖的,是用来“管包管理器的版本和使用权”的。
它让“这个项目该用哪个包管理器、哪个版本”变成一件确定的事。

CSS盒模型实战:用代码透视 `border-box`与 `content-box`的天壤之别

作者 Lee川
2026年2月8日 14:57

CSS盒模型实战:用代码透视 border-boxcontent-box的天壤之别

理解CSS盒模型是前端布局的必修课,而 box-sizing属性则是掌控盒模型计算规则的钥匙。本文将通过您文档中生动的代码示例,直观展示其核心区别。

场景一:标准盒模型的“扩张”困扰(content-box

在默认的 content-box模型下,您为元素设置的 widthheight仅作用于其内容区域。让我们看一个例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.content-box {
            width: 200px;       /* 仅指内容的宽度 */
            height: 100px;      /* 仅指内容的高度 */
            padding: 20px;      /* 内边距 */
            border: 5px solid black; /* 边框 */
            margin: 20px;       /* 外边距 */
            box-sizing: content-box; /* 这是默认值,也可不写 */
            background-color: lightgreen;
        }
    </style>
</head>
<body>
    <div class="box content-box">Box with content-box</div>
</body>
</html>

关键代码分析

  • width: 200px; height: 100px;:这里定义的仅仅是绿色内容区域的尺寸。
  • 添加的 paddingborder向外扩张盒子的总尺寸。

计算结果

  • 盒子的总宽度 = 200(width) + 20 * 2(padding) + 5 * 2(border) = 250px
  • 盒子的总高度 = 100(height) + 20 * 2(padding) + 5 * 2(border) = 150px

此时,盒子在页面上的实际占位是 250px * 150px,远大于你直觉上认为的 200px * 100px。这在多列布局时极易导致意外换行或溢出。

场景二:怪异盒模型的“收缩”智慧(border-box

为了解决上述问题,border-box模型采用了更直观的计算方式:你设定的 widthheight直接定义了这个盒子的总边框盒尺寸。对比示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.border-box {
            width: 200px;       /* 指整个盒子的总宽度! */
            height: 100px;      /* 指整个盒子的总高度! */
            padding: 20px;
            border: 5px solid black;
            margin: 20px;
            box-sizing: border-box; /* 核心:切换为 border-box */
            background-color: lightblue;
        }
    </style>
</head>
<body>
    <div class="box border-box">Box with border-box</div>
</body>
</html>

关键代码分析

  • 同样的 width: 200px; height: 100px;声明,但因为 box-sizing: border-box;的存在,这里的 200px 和 100px 被解释为包含内容、内边距和边框的总尺寸
  • 添加的 paddingborder向内挤压内容区域的空间。

计算结果

  • 盒子的总宽度 = 200px(由 width直接定义)
  • 盒子的总高度 = 100px(由 height直接定义)
  • 内容区域的实际宽度 = 200 - 20 * 2 - 5 * 2 = 150px
  • 内容区域的实际高度 = 100 - 20 * 2 - 5 * 2 = 50px

无论你如何调整 paddingborder,这个浅蓝色盒子的外轮廓都严格保持为你设定的 200px * 100px,这使得精确控制布局变得轻而易举。

实战应用:为什么 border-box是布局神器

让我们看一个经典应用场景——创建两个等宽并列的盒子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .container {
            width: 1200px;
            margin: 0 auto;
        }
        .box {
            box-sizing: border-box; /* 使用 border-box 模型 */
            width: 580px; /* 总宽580px */
            height: 100px;
            margin: 0 10px; /* 左右外边距各10px */
            border: 1px solid #000; /* 边框 */
            padding: 5px; /* 内边距 */
            display: inline-block;
            background-color: green;
        }
        .box:nth-child(2) {
            background-color: yellow;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="box">1</div><div class="box">2</div>
    </div>
</body>
</html>

核心优势解析

  1. 尺寸可预测:每个 .box的总宽度是明确的 580px,无论其 borderpadding如何变化。

  2. 布局计算简单

    • 单个盒子占位:580px(width) + 10 * 2(margin) = 600px
    • 两个盒子总占位:600px + 600px = 1200px
    • 容器宽度为 1200px,完美容纳。

如果此处使用 content-box,会发生什么?

每个盒子的实际总宽度会变成:580(width) + 5 * 2(padding) + 1 * 2(border) = 592px,再加上左右 margin各10px,单个盒子就占用了 612px,两个盒子就需要 1224px,会立即撑破 1200px的容器,导致第二个盒子掉到下一行。border-box彻底避免了这种烦人的计算。

总结与最佳实践

通过以上代码的对比演示,可以清晰地看到:

  • **content-box** 是“加法模型”(实际尺寸 = 设定尺寸 + padding + border),易导致布局失控。
  • **border-box** 是“减法模型”(内容尺寸 = 设定尺寸 - padding - border),让元素的占位尺寸完全可预测。

因此,在现代前端开发中,一个公认的最佳实践是在CSS起始位置就全局应用 border-box模型:

*,
*::before,
*::after {
  box-sizing: border-box;
}

这条简单的规则,能让你在后续的整个开发过程中,彻底告别因 paddingborder导致的布局尺寸计算烦恼,将更多精力投入到创意和逻辑的实现中。

❌
❌