阅读视图

发现新文章,点击刷新页面。

使用 Tiptap 编写一个富文本编辑器为什么对很多人来说很难 🤔🤔🤔

我正在开发 DocFlow,一个完整的 AI 全栈协同文档项目。这个项目涵盖了前端富文本编辑器(基于 Tiptap)、后端服务、AI 集成、实时协作等多个技术栈。在开发过程中,我积累了丰富的实战经验,包括 Tiptap 的深度定制、性能优化、协作功能实现等核心难点。如果你对 AI 全栈开发、Tiptap 富文本编辑器定制、或者 DocFlow 项目的完整技术方案感兴趣,想系统学习相关技术栈和最佳实践,欢迎加我微信 yunmz777 私聊咨询。

20251209053826

用 Tiptap 做富文本编辑器,听起来挺美好,实际上却让不少人头疼。虽然它比从零开始写编辑器要友好得多,但真正上手后你会发现,想要做出一个能用的编辑器,难度远超预期。

这篇文章想聊聊,为什么 Tiptap 对很多人来说这么难,以及在实际项目中会遇到哪些坑。

为什么很多人选择 Tiptap

先说说 Tiptap 吸引人的地方。

基于 ProseMirror 的稳健基础 是它最大的卖点。ProseMirror 这个底层框架被 Notion、Linear 这些产品验证过,稳定性没问题。Tiptap 在它上面做了封装,提供了 starter-kit、基础扩展、getHTML、getJSON 这些常用 API,让你不用直接面对 ProseMirror 的复杂性。

模块化设计 也很讨喜。按需加载,基础功能可以保持 bundle 很小,需要的时候再扩展。这种设计符合现代前端开发习惯,你可以根据项目需求灵活选择功能。

框架集成 做得不错。React、Vue、Svelte 都有官方包,可以在熟悉的框架生态里工作,不用重新学习一套东西。

社区活跃度 也还行。GitHub 上 star 不少,issue 讨论也很多,遇到问题至少能找到一些线索。

所以 Tiptap 确实降低了门槛,但降低门槛不等于消除复杂性。这是理解它为什么仍然困难的关键。

然而,"难"的原因很多

"降低门槛" ≠ "变成容易"。下面这些挑战,往往在项目推进过程中才会逐渐暴露。

1. 底层复杂:ProseMirror 的抽象与复杂性

Tiptap 虽然封装了 ProseMirror,但本质上还是基于它。有文章提到:

"Tiptap is built on top of ProseMirror, a robust yet difficult to master editor."

这话不假。一旦你需要做高级定制,比如自定义节点、插件、命令、协作同步,你就得深入 ProseMirror。那些抽象概念对很多前端工程师来说并不直观。

抽象概念的学习曲线

Document Model 是第一个坎。ProseMirror 用自己的一套文档模型,不是直接操作 DOM。这是个树状结构,每个节点有类型、属性、内容。理解这个模型需要思维转换,习惯了 DOM 操作的人一开始会不适应。

Schema 设计也不简单。它定义了文档里能有哪些节点和标记,以及它们之间的关系。设计一个合理的 schema 需要深入理解文档结构,遇到表格嵌套、列表嵌套这些复杂情况时尤其头疼。

Transaction 系统是另一个难点。ProseMirror 用 transaction 处理所有文档变更,每个编辑操作都会创建一个 transaction,描述从旧状态到新状态的变化。要实现撤销、重做、协作这些功能,必须理解 transaction 的工作原理。

Selection 管理比想象中复杂。不同浏览器对 selection 的处理不一致,文档结构复杂时(比如表格、列表),selection 的行为经常不符合预期。你以为选中了,实际上可能选中的是别的东西。

Node View 更麻烦。当你需要自定义节点渲染和交互时(比如代码块、表格、嵌入内容),你得同时理解 React、Vue 组件系统和 ProseMirror 的节点系统,两者之间的桥接并不总是直观。

实际开发中的具体困难

很多人一开始觉得"写个富文本编辑器不就是个 textarea + toolbar 吗",等真正要兼容多个格式、插入图片表格、撤销重做、协作、粘贴处理、HTML 和 JSON 转换时,才发现坑深如海。

举个例子,实现一个简单的图片上传功能,你需要:

  1. 创建自定义 Image 节点类型
  2. 实现 Node View 来渲染图片
  3. 处理图片上传逻辑(拖拽、粘贴、URL 输入等多种方式)
  4. 处理图片加载失败的情况
  5. 实现图片大小调整、对齐等功能
  6. 处理响应式布局
  7. 处理图片的序列化和反序列化

这还只是图片功能。如果再加上表格、代码块、嵌入内容,复杂度会成倍增加。

2. 功能多样,需求场景差异极大

富文本编辑器不是"一个按钮能解决所有问题"。不同应用对文档编辑的需求千差万别,这种多样性让"万能"解决方案变得不现实。

从 Word、Google Docs 复制粘贴的噩梦

从 Word 复制内容到编辑器,听起来简单,实际上是个噩梦。

浏览器接收到的是一堆内联样式、嵌套的 div 和 span、字体信息、颜色信息。这些内容需要被"清洗"和转换为你编辑器中的节点结构。你得识别和转换段落、标题、列表,处理表格(包括合并单元格、嵌套表格),处理图片和嵌入内容,清理不必要的样式和标签,处理特殊字符和编码问题。

很多编辑器(包括 Tiptap)对此处理并不理想。你往往需要自己实现或扩展粘贴处理逻辑,这需要深入理解 ProseMirror 的 paste rules 和 clipboard 处理机制。

媒体与复杂内容支持的工作量被低估

你可能需要支持图片、视频、embed、附件、表格、表单,还要支持拖拽、上传、调整、重排、自定义样式、响应式、兼容多端。这意味着不仅是文本,还要管理复杂结构。

以表格为例,实现一个功能完整的表格编辑器需要:

  • 表格的创建、删除、行列的增删
  • 单元格的合并和拆分
  • 表格样式的自定义(边框、背景色、对齐方式等)
  • 表格内容的编辑(包括嵌套其他节点类型)
  • 表格的响应式处理(在小屏幕上如何显示)
  • 表格的序列化和反序列化
  • 表格的复制粘贴处理

这还只是表格。如果再加上图片的拖拽调整大小、视频的播放控制、嵌入内容的交互,工作量会急剧增加。很多人低估了这部分工作量,以为"用个扩展就行",实际上往往需要大量的定制开发。

协作功能的指数级复杂度

如果还要加协作(多人实时编辑)、版本历史、评论、建议编辑、合并、冲突解决,那就更复杂了。有文章指出:"无论你选择哪种富文本编辑器,把协作功能加进去都是一件非常具有挑战性的事情。"

协作编辑涉及:

  • 实时同步:需要 WebSocket 或类似技术来实时同步编辑操作
  • 冲突解决:当多个用户同时编辑同一部分时,如何解决冲突
  • 操作转换(OT)或 CRDT:需要算法来保证最终一致性
  • 版本历史:需要记录每次变更,支持回滚和查看历史
  • 权限管理:不同用户可能有不同的编辑权限
  • 评论系统:需要在文档中插入评论,并管理评论的生命周期
  • 建议编辑:允许用户提出编辑建议,而不是直接修改

即使使用 Y.js 这样的协作框架,你仍然需要处理很多细节,比如如何将 ProseMirror 的 transaction 转换为 Y.js 的操作,如何处理网络延迟和断线重连,如何处理冲突等。

通用 + 万能 + 高可定制 + 多功能,是个"理想"与"魔鬼"并存的组合。很多团队和个人在最初没评估清楚就上了车,最后才发现问题。

3. 性能、UX、浏览器兼容的复杂度

性能问题的真实存在

有人反映,当文档很长(上百、几百条目、复杂结构)且含多种格式、嵌入、样式、块时,Tiptap 的响应会变慢。有使用者说:

"One of the big gotchas I've had with TipTap is that it's extremely sluggish once you get to more than 300 words or so and you're using different formatting options."

这个问题的根源在于:

  • DOM 操作的开销:每次编辑操作都可能触发 DOM 的更新,当文档很大时,这些更新会变得很慢
  • 重渲染的成本:React、Vue 组件的重渲染可能不够优化,导致不必要的更新
  • 内存占用:大文档会占用大量内存,特别是在移动设备上

解决性能问题需要按需渲染、虚拟滚动、优化更新策略、垃圾回收优化、防抖和节流。这些优化都需要深入理解 ProseMirror 和框架的工作原理,不是简单的配置就能解决的。

跨浏览器兼容性的挑战

不同浏览器对 content-editable、selection、复制粘贴行为的处理并不一致。兼容性问题、剪贴板行为差异、样式冲突、默认样式覆盖、CSS reset、响应式布局等等,都容易让人头疼。

具体问题包括:

  • Selection API 的差异:不同浏览器对 selection 的处理方式不同,特别是在处理复杂节点(如表格、列表)时
  • Clipboard API 的差异:复制粘贴的行为在不同浏览器中可能不同
  • ContentEditable 的行为差异:不同浏览器对 content-editable 元素的处理方式不同
  • 移动端的特殊问题:移动端的键盘、触摸事件、输入法等都会影响编辑体验
  • 样式兼容性:不同浏览器的默认样式不同,需要大量的 CSS reset 和兼容性处理

这正是很多人不想"从头搞一个富文本编辑器"的原因。

生产环境的额外复杂度

如果你的编辑器用于生产系统,还可能涉及数据持久化、存储格式选择、解析和回显、版本控制、迁移、与后端兼容、安全(XSS)、国际化、多语言、字体、排版、方向(RTL)等。

当这些积累起来,远超"几行代码 + 一个 toolbar + 一个富文本输入框"的复杂度。

4. 社区与文档 ≠ "覆盖所有场景"

虽然 Tiptap 本身文档、示例、社区不错,但对于特定复杂场景(比如"带表格 + 嵌入 + 自定义 node + 协作 + Undo、Redo + 导出 + 插件系统 + 自定义样式、主题、布局、响应式、多端"),几乎不存在一个"现成解决方案"。

文档的局限性

Tiptap 的官方文档主要覆盖了基础用法和常见场景,但对于复杂场景,文档往往只能提供方向性的指导,具体的实现细节需要你自己去探索。

比如如何实现一个自定义的表格编辑器,支持合并单元格、调整列宽等功能?如何实现一个代码块编辑器,支持语法高亮、行号、代码折叠?如何实现一个协作编辑系统,处理冲突和同步?如何实现一个导出系统,将编辑器内容导出为 PDF、Word 等格式?

这些场景的文档往往不够详细,或者根本没有文档。你需要阅读源码来理解工作原理,在 GitHub 上搜索相关的 issue 和讨论,在社区中提问(但可能得不到及时回复),自己实验和调试。

需要自己设计的部分

多数时候你得自己设计 Schema、Node View、Commands、插件体系、交互逻辑、前端-后端同步、数据库存储方案等。

复杂度被隐藏在"源码 + 配置 + 扩展 + 兼容性 + 性能调优 + 测试 + 边界 case 处理" 后面。

从"用个库就好"到"坑太多"的转变

很多人在刚开始抱着"用个库就好"的想法,但不到真正要满足复杂需求的时候,就发现坑太多。

你以为插入图片很简单,结果发现需要处理上传、加载失败、响应式、拖拽调整大小等多个问题。你以为实现表格很简单,结果发现需要处理合并单元格、嵌套表格、复制粘贴等复杂情况。你以为实现协作很简单,结果发现需要处理冲突、同步、权限等多个问题。

有人甚至建议,如果你的需求很基础(只是简单文字 + 粗体斜体 + 列表 + 少量图片),用 Markdown 编辑器 + 轻量富文本或简易配置反而更稳。

5. 心理预期 vs 现实落地

很多产品经理或不熟悉富文本底层的人,会把富文本编辑器当成"轻量版 Word、Google Docs、所见即所得编辑器"。但事实是,浏览器里的 contenteditable + 富文本编辑器,受限于 DOM、Selection、样式、多端差异、复制粘贴行为、HTML → Model → HTML 的 round-trip、标准兼容性、性能、插件生态等等。

Word 级别的功能需要 Word 级别的投入

要做到"像 Word 那么强大又稳定",就算用了 Tiptap,也至少要投入非常多开发 + 测试 + 兼容 + 优化 + 维护成本。

Word 的表格编辑器是几十年的积累,而 Web 编辑器要实现类似功能需要从零开始。Word 的协作功能是 Microsoft 团队多年研发的结果,而 Web 编辑器要实现类似功能需要自己实现或集成第三方服务。Word 的导出功能支持多种格式,而 Web 编辑器要实现类似功能需要集成多个库或自己实现。

"跳进兔子洞"的风险

这是为什么很多从头搞的人(尤其业务方、非编辑器专注团队)最终放弃的原因。甚至有开发者在讨论里直言:构建富文本编辑器"像跳进 rabbit hole"(兔子洞),风险太大,不靠谱。

这个比喻很形象:你以为只是挖一个小洞,结果发现下面是一个深不见底的兔子洞,越挖越深,越挖越复杂,最终发现自己投入的时间和精力远超预期。

对开发者、项目经理、内容生产者来说:为什么依旧容易被难住

对于不同类型的角色,Tiptap 的困难点可能有所不同,但都会面临一些共同的挑战。

内容存储格式的复杂性

你需要考虑 HTML、JSON、markdown、自定义格式。Tiptap 支持 JSON、HTML 输出、回显,但如果你要自定义节点(例如 embed、图文混排、特殊 block、meta 数据、AI-辅助内容),那就需要你设计 schema,处理序列化、反序列化逻辑,以及前后端如何统一处理这些内容结构。

格式选择的权衡

每种格式都有其优缺点:

  • HTML:最直观,但可能包含大量冗余信息,不利于存储和传输
  • JSON:结构化好,易于处理,但需要自定义解析逻辑
  • Markdown:简洁,但表达能力有限,不适合复杂内容
  • 自定义格式:最灵活,但需要自己实现所有逻辑

选择哪种格式需要根据你的具体需求来决定,这个决定会影响整个系统的架构。

Schema 设计的挑战

设计一个好的 schema 需要考虑哪些节点类型是必需的、节点之间的包含关系、节点的属性、节点的默认样式和行为、如何扩展 schema 以支持未来需求。这需要深入理解你的内容模型和业务需求。

Tiptap 文档中虽有基础 API,但自定义越深,你要写的东西就越多。

可视化与发布的多个层面

如果你希望支持可视化、可发布(类似公众号、社交媒体、Markdown 转 HTML、文章导出、静态页面),你还可能涉及样式渲染、响应式、兼容性、多端适配、HTML sanitation、安全(XSS)、图片、资源管理、SEO、导出与预览等。

每个层面都需要深入的工作,不是简单的配置就能解决的。

协作功能的指数级复杂度

若你还想加协作(多人编辑、实时、历史、评论、AI 辅助、版本、导出、回滚),那复杂度呈指数级增长。

基础框架只是开始

即便 Tiptap + Y.js + WebSocket、后端,也只是基础框架。你可能还需要额外处理冲突解决、数据同步、权限管理、undo、redo、版本存储、合并逻辑、并发控制、UI、UX 体验、多端协作(桌面、移动)等。

"要么你自己写,要么妥协"

很多细节是"要么你自己写,要么妥协"。如果你需要显示其他用户的光标位置,你需要自己实现这个功能。如果你需要支持评论和建议编辑,你需要自己实现这些功能。如果你需要支持版本历史,你需要自己实现版本管理系统。

事实证明,哪怕是经验丰富的团队也要花费相当多时间。

性能与用户体验的持续挑战

当文档较大、格式复杂、包含多种节点、嵌入、图片、表格、样式时,要保证编辑体验流畅、不卡顿、不闪烁、不延迟,是个挑战。

性能问题可能出现在输入延迟、滚动卡顿、渲染延迟、内存占用、CPU 占用等多个维度。

尤其对移动端、低性能设备,或者需要即时渲染、同步、预览时,卡顿体验对用户友好度伤害很大。移动端的挑战包括性能更有限、触摸交互与鼠标交互不同、键盘输入体验不同、网络可能不稳定、电池续航需要考虑等。

这些挑战是核心需求,不是边缘用例

如果你未来可能会做"复杂内容 + 社交、公众号、文章、AI 辅助、可能多人协作、排版导出"的系统,这些挑战并不是边缘用例,而可能是核心需求。这意味着你不能简单地"先做一个简单版本,以后再优化",而是需要在设计阶段就考虑这些挑战。

总结:为什么"对很多人来说,Tiptap 很难"

富文本编辑本身是高度复杂的问题

格式多样(文本、段落、列表、样式、嵌入、媒体、表格...)+ 用户期望高(像 Word、Google Docs、CMS 编辑那样自然)+ 浏览器、DOM、标准、兼容性、性能各类限制,这些因素叠加在一起,使得富文本编辑成为一个高度复杂的问题。

底层复杂度依然存在

虽然 Tiptap 封装了一些基础,但底层仍是复杂,高度定制、复杂功能、实用级需求基本都需要"深入 ProseMirror + 自己实现"。Tiptap 降低了入门门槛,但没有消除复杂性。

成本压力大

对大多数中小团队、个人开发者、希望快速上线的人来说,真正把富文本编辑器"做好、做稳、做可维护",工时、测试、兼容性、性能、维护、边缘 case 全都压得人喘不过气。这个成本往往被低估。

维护成本持续累积

当需求不断变化(新功能、新格式、新媒体、新协作、新导出、新兼容)的时候,维护成本会不断累积,很多人最终发现"再也不想碰富文本编辑器"。这是一个持续的过程,不是一次性的投入。

心理预期与现实的差距

很多人对富文本编辑器的期望是"像 Word 那样强大",但现实是 Web 编辑器的能力有限,要实现类似功能需要大量投入。这个差距导致了很多项目的失败。

建议:基于实际需求的实践路径

虽然 Tiptap 很难,但这并不意味着你不应该使用它。关键是要有正确的预期和合理的规划。

1. 先做 Minimal Viable Editor (MVE)

用 Tiptap + StarterKit 做基础(段落、标题、粗体、斜体、列表、链接、图片)。如果你主要是发文、写文章、有一定样式要求,这足够。不要一开始就试图实现所有功能。

2. 根据需要逐步扩展

只当确实需要"嵌入、表格、特殊布局、导出、协作、AI 插件、自定义 node"时,再考虑扩展,而不是一开始就设计"万能 + 所有可能功能"。每个功能的添加都需要评估其成本和收益。

3. 把 Schema、数据结构设计做好

选好内容存储格式(JSON、HTML、自定义 + metadata),并确保前后端、导入导出、兼容、未来扩展都考虑进去。一个好的数据结构设计可以避免很多后续问题。

4. 权衡收益 vs 成本

对于一些不常用或复杂功能(比如复杂表格、拖拽布局、多人协作、版本控制、复杂导出...)要评估清楚,是不是值得投入,或者可以通过 Markdown + 插件 + 后处理导出来替代。不是所有功能都需要在编辑器中实现。

5. 做好测试与回退、兼容策略

包括浏览器兼容、移动端、自定义样式、theme、内容导出、清洗(sanitize)、未来迁移、升级。这些工作虽然繁琐,但对于生产环境是必需的。

6. 考虑替代方案

如果你的需求相对简单,考虑使用 Markdown 编辑器 + 后处理的方式,可能比直接使用富文本编辑器更简单、更稳定。Markdown 虽然表达能力有限,但对于很多场景已经足够,而且实现和维护成本要低得多。

7. 寻求专业帮助

如果项目预算允许,考虑寻求专业的编辑器开发团队或咨询服务的帮助。富文本编辑器是一个专业领域,有经验的人可以帮你避免很多坑。

总结

Tiptap 是一个优秀的富文本编辑器框架,它确实降低了开发门槛,但这并不意味着它简单。理解其复杂性,有正确的预期,做好规划,是成功使用 Tiptap 的关键。

如果你正在考虑使用 Tiptap,希望这篇文章能帮助你更好地评估项目的复杂度和成本,做出明智的决策。

前端性能加速器:Vue Router懒加载与组件分包的极致优化

引言:当应用变"重"时,我们需要更聪明的加载策略

在现代前端开发中,随着Vue应用功能日益丰富,组件数量不断增加,一个常见的问题逐渐浮现:初始加载时间越来越长。用户打开应用时,浏览器需要下载整个应用的JavaScript代码,包括那些用户可能永远不会访问的页面和功能。这不仅浪费了带宽,更直接影响了用户体验。有没有一种方法,可以"按需分配"资源,只在用户需要时才加载相应代码?这就是我们今天要探讨的Vue Router懒加载与组件分包优化的核心价值。

第一部分:传统路由加载的问题剖析

在深入了解优化方案前,让我们先看看传统路由配置的局限性:

// 传统的静态导入方式
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Contact from '@/views/Contact.vue'
import Dashboard from '@/views/Dashboard.vue'
// ...可能还有几十个组件

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/contact', component: Contact },
  { path: '/dashboard', component: Dashboard },
  // ...所有路由在应用初始化时就已经被加载
]

这种模式下,无论用户访问哪个页面,所有路由组件都会被打包进同一个JavaScript文件中。这意味着:

  1. 首屏加载时间长:用户需要等待所有代码下载完成
  2. 资源浪费严重:用户可能永远不会访问某些页面,但这些页面的代码已经被加载
  3. 缓存效率低:任何微小改动都会导致整个包需要重新下载

第二部分:懒加载路由——按需加载的艺术

Vue Router的懒加载功能通过动态导入(Dynamic Import)实现了路由组件的按需加载:

// 使用懒加载的动态导入语法
const routes = [
  { 
    path: '/', 
    component: () => import('@/views/Home.vue') 
  },
  { 
    path: '/about', 
    component: () => import('@/views/About.vue') 
  },
  { 
    path: '/contact', 
    component: () => import('@/views/Contact.vue') 
  },
  { 
    path: '/dashboard', 
    component: () => import('@/views/Dashboard.vue') 
  }
]

懒加载的工作原理

当用户访问应用时,只有首页(Home)组件会被加载。当用户点击导航到"/about"时,Vue Router才会动态请求About组件的代码块。这种机制带来了显著的优化效果:

  1. 减少初始包体积:首屏只需要加载必要的代码
  2. 并行加载能力:不同路由的代码可以并行下载
  3. 智能缓存策略:每个路由组件可以独立缓存

第三部分:高级分包策略——让优化更进一步

基本的懒加载已经带来了显著提升,但我们可以通过更精细的分包策略进一步优化:

1. Webpack魔法注释:命名与分组

const routes = [
  {
    path: '/user/profile',
    component: () => import(/* webpackChunkName: "user-pages" */ '@/views/user/Profile.vue')
  },
  {
    path: '/user/settings',
    component: () => import(/* webpackChunkName: "user-pages" */ '@/views/user/Settings.vue')
  },
  {
    path: '/admin/dashboard',
    component: () => import(/* webpackChunkName: "admin-module" */ '@/views/admin/Dashboard.vue')
  }
]

通过webpackChunkName注释,我们可以:

  • 相关组件打包到一起:减少HTTP请求数量
  • 创建有意义的文件名:便于调试和维护
  • 实现更精细的缓存控制

2. 预加载与预获取策略

Vue Router 4.x 提供了更智能的预加载机制:

// 配置预加载策略
const router = createRouter({
  routes,
  // 预加载视口内链接对应的路由
  scrollBehavior(to, from, savedPosition) {
    if (to.matched.length) {
      // 预加载路由组件
      to.matched.forEach(record => {
        if (typeof record.components.default === 'function') {
          record.components.default()
        }
      })
    }
    return savedPosition || { top: 0 }
  }
})

3. 按用户角色分包

针对不同用户角色进行代码分割:

// 根据用户角色动态加载不同的模块
const getUserRoleModule = (role) => {
  switch(role) {
    case 'admin':
      return () => import('@/modules/admin')
    case 'editor':
      return () => import('@/modules/editor')
    default:
      return () => import('@/modules/user')
  }
}

// 在路由守卫中应用
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAdmin) {
    const adminModule = getUserRoleModule('admin')
    adminModule().then(() => next())
  } else {
    next()
  }
})

第四部分:最佳实践与性能监控

分包优化检查清单

  1. 路由层级分包:按功能模块划分代码块
  2. 第三方库分离:将Vue、Vuex、Vue Router等库单独打包
  3. 公共组件提取:提取跨路由使用的组件到公共块
  4. 动态导入Vue组件:在组件内部也使用动态导入

性能监控与度量

// 添加路由加载性能监控
const loadStartTime = Date.now()

router.beforeEach((to, from, next) => {
  const startTime = performance.now()
  
  next()
  
  // 监控路由切换性能
  const loadTime = performance.now() - startTime
  if (loadTime > 1000) {
    console.warn(`路由 ${to.path} 加载时间过长: ${loadTime}ms`)
  }
})

第五部分:Vite环境下的优化差异

如果你使用Vite而非Webpack,懒加载的实现更加简洁:

// Vite中的动态导入(无需特殊配置)
const routes = [
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue')
  }
]

Vite利用原生ES模块特性,在开发环境中提供极快的热更新,在生产构建时自动进行代码分割。

结语:打造轻盈而强大的Vue应用

Vue Router懒加载与组件分包优化不仅是一种技术实现,更是一种用户体验至上的开发哲学。通过将"一次性加载"转变为"按需加载",我们不仅减少了初始加载时间,还创造了更加流畅、响应更快的应用体验。

记住,优化的核心在于平衡:代码分割的粒度越细,HTTP请求越多;分割的粒度越粗,加载冗余越多。优秀的开发者需要根据具体应用场景找到最佳平衡点。

在当今追求极致用户体验的时代,掌握懒加载与分包优化技术,意味着你能够打造出既功能丰富又响应迅速的前端应用。这不仅是技术能力的体现,更是对用户时间与体验的尊重。开始优化你的Vue应用吧,让每一个字节的加载都有其价值,让每一次用户交互都流畅自然。

优化之路,永无止境;用户体验,始终至上。

每日一题-统计特殊三元组🟡

给你一个整数数组 nums

特殊三元组 定义为满足以下条件的下标三元组 (i, j, k)

  • 0 <= i < j < k < n,其中 n = nums.length
  • nums[i] == nums[j] * 2
  • nums[k] == nums[j] * 2

返回数组中 特殊三元组 的总数。

由于答案可能非常大,请返回结果对 109 + 7 取余数后的值。

 

示例 1:

输入: nums = [6,3,6]

输出: 1

解释:

唯一的特殊三元组是 (i, j, k) = (0, 1, 2),其中:

  • nums[0] = 6, nums[1] = 3, nums[2] = 6
  • nums[0] = nums[1] * 2 = 3 * 2 = 6
  • nums[2] = nums[1] * 2 = 3 * 2 = 6

示例 2:

输入: nums = [0,1,0,0]

输出: 1

解释:

唯一的特殊三元组是 (i, j, k) = (0, 2, 3),其中:

  • nums[0] = 0, nums[2] = 0, nums[3] = 0
  • nums[0] = nums[2] * 2 = 0 * 2 = 0
  • nums[3] = nums[2] * 2 = 0 * 2 = 0

示例 3:

输入: nums = [8,4,2,8,4]

输出: 2

解释:

共有两个特殊三元组:

  • (i, j, k) = (0, 1, 3)
    • nums[0] = 8, nums[1] = 4, nums[3] = 8
    • nums[0] = nums[1] * 2 = 4 * 2 = 8
    • nums[3] = nums[1] * 2 = 4 * 2 = 8
  • (i, j, k) = (1, 2, 4)
    • nums[1] = 4, nums[2] = 2, nums[4] = 4
    • nums[1] = nums[2] * 2 = 2 * 2 = 4
    • nums[4] = nums[2] * 2 = 2 * 2 = 4

 

提示:

  • 3 <= n == nums.length <= 105
  • 0 <= nums[i] <= 105

3583. 统计特殊三元组

解法

思路和算法

由于特殊三元组的定义为左边元素值与右边元素值都等于中间元素值的两倍,因此可以遍历中间元素下标并计算每个中间元素下标对应的特殊三元组的数目。

数组 $\textit{nums}$ 的长度是 $n$。对于 $1 \le i < n - 1$ 的每个下标 $i$,如果在下标 $i$ 的左边有 $x$ 个元素 $\textit{nums}[i] \times 2$ 且下标 $i$ 的右边有 $y$ 个元素 $\textit{nums}[i] \times 2$,则以下标 $i$ 作为中间下标的特殊三元组的数目是 $x \times y$。

使用哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 分别记录从左到右遍历数组 $\textit{nums}$ 和从右到左遍历数组 $\textit{nums}$ 的过程中的每个元素的出现次数,创建长度为 $n$ 的数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$,对于 $0 \le i < n$ 的每个下标 $i$,$\textit{leftSpecial}[i]$ 表示下标 $i$ 左边的元素值 $\textit{nums}[i] \times 2$ 的个数,$\textit{rightSpecial}[i]$ 表示下标 $i$ 右边的元素值 $\textit{nums}[i] \times 2$ 的个数。根据如下操作计算相应的值。

  1. 从左到右遍历数组 $\textit{nums}$。对于遍历到的每个下标 $i$,从哈希表 $\textit{leftCounts}$ 中得到元素 $\textit{nums}[i] \times 2$ 的出现次数填入 $\textit{leftSpecial}[i]$,然后在哈希表 $\textit{leftCounts}$ 中将元素 $\textit{nums}[i]$ 的出现次数增加 $1$。

  2. 从右到左遍历数组 $\textit{nums}$。对于遍历到的每个下标 $i$,从哈希表 $\textit{rightCounts}$ 中得到元素 $\textit{nums}[i] \times 2$ 的出现次数填入 $\textit{rightSpecial}[i]$,然后在哈希表 $\textit{rightCounts}$ 中将元素 $\textit{nums}[i]$ 的出现次数增加 $1$。

上述操作中,应首先计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$,然后在哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 中更新 $\textit{nums}[i]$ 的出现次数。理由如下:计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$ 时,下标 $i$ 作为特殊三元组的中间下标,因此哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 应分别只能包含下标范围 $[0, i - 1]$ 和 $[i + 1, n - 1]$ 中的所有元素的出现次数。如果计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$ 与更新哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 的顺序颠倒,则当 $\textit{nums}[i] = 0$ 时会导致错误地计算下标重合的三元组。

由于特殊三元组的中间下标不可能是 $0$ 或 $n - 1$,因此只需要考虑 $1 \le i < n - 1$ 的每个下标 $i$ 作为中间下标的特殊三元组的数目。计算数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$ 之后,从左到右遍历 $1 \le i < n - 1$ 的每个下标 $i$,以下标 $i$ 作为中间下标的特殊三元组的数目是 $\textit{leftSpecial}[i] \times \textit{rightSpecial}[i]$,将该数目加到答案。遍历结束之后,即可得到数组 $\textit{nums}$ 中特殊三元组的总数。

代码

###Java

class Solution {
    static final int MODULO = 1000000007;

    public int specialTriplets(int[] nums) {
        Map<Integer, Integer> leftCounts = new HashMap<Integer, Integer>();
        Map<Integer, Integer> rightCounts = new HashMap<Integer, Integer>();
        int n = nums.length;
        int[] leftSpecial = new int[n];
        int[] rightSpecial = new int[n];
        for (int i = 0; i < n; i++) {
            leftSpecial[i] = leftCounts.getOrDefault(nums[i] * 2, 0);
            leftCounts.put(nums[i], leftCounts.getOrDefault(nums[i], 0) + 1);
        }
        for (int i = n - 1; i >= 0; i--) {
            rightSpecial[i] = rightCounts.getOrDefault(nums[i] * 2, 0);
            rightCounts.put(nums[i], rightCounts.getOrDefault(nums[i], 0) + 1);
        }
        int specialCount = 0;
        for (int i = 1; i < n - 1; i++) {
            int currSpecialCount = (int) ((long) leftSpecial[i] * rightSpecial[i] % MODULO);
            specialCount = (specialCount + currSpecialCount) % MODULO;
        }
        return specialCount;
    }
}

###C#

public class Solution {
    const int MODULO = 1000000007;

    public int SpecialTriplets(int[] nums) {
        IDictionary<int, int> leftCounts = new Dictionary<int, int>();
        IDictionary<int, int> rightCounts = new Dictionary<int, int>();
        int n = nums.Length;
        int[] leftSpecial = new int[n];
        int[] rightSpecial = new int[n];
        for (int i = 0; i < n; i++) {
            leftSpecial[i] = leftCounts.ContainsKey(nums[i] * 2) ? leftCounts[nums[i] * 2] : 0;
            leftCounts.TryAdd(nums[i], 0);
            leftCounts[nums[i]]++;
        }
        for (int i = n - 1; i >= 0; i--) {
            rightSpecial[i] = rightCounts.ContainsKey(nums[i] * 2) ? rightCounts[nums[i] * 2] : 0;
            rightCounts.TryAdd(nums[i], 0);
            rightCounts[nums[i]]++;
        }
        int specialCount = 0;
        for (int i = 1; i < n - 1; i++) {
            int currSpecialCount = (int) ((long) leftSpecial[i] * rightSpecial[i] % MODULO);
            specialCount = (specialCount + currSpecialCount) % MODULO;
        }
        return specialCount;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组常数次,遍历过程中对于每个元素的操作时间都是 $O(1)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 以及数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$ 的空间是 $O(n)$。

两种方法:枚举中间 / 一次遍历(Python/Java/C++/Go)

方法一:枚举中间

三变量问题,一般枚举中间的变量最简单。为什么?对比一下:

  • 枚举 $i$,后续计算中还需保证 $j < k$。
  • 枚举 $j$,那么 $i$ 和 $k$ 自动被 $j$ 隔开,互相独立,后续计算中无需关心 $i$ 和 $k$ 的位置关系。

枚举中间的 $j$,问题变成:

  • 在 $[0,j-1]$ 中,$\textit{nums}[j]\cdot 2$ 的出现次数。
  • 在 $[j+1,n-1]$ 中,$\textit{nums}[j]\cdot 2$ 的出现次数。
  • 在这些出现次数中,左右两边各选一个。根据乘法原理,把这两个出现次数相乘,加到答案中。

用哈希表(或者数组)统计 $j$ 左右每个数的出现次数。

  • 右边的元素出现次数,可以先统计整个数组,然后再次遍历数组,撤销 $[0,j]$ 中统计的元素出现次数,即为 $[j+1,n-1]$ 中的元素出现次数。
  • 左边的元素出现次数,可以一边遍历 $\textit{nums}$,一边统计。

由于答案不超过 $n\cdot 10^5\cdot 10^5 \le 10^{15}$,可以只在返回时取模。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def specialTriplets(self, nums: List[int]) -> int:
        MOD = 1_000_000_007
        suf = Counter(nums)

        ans = 0
        pre = defaultdict(int)  # 比 Counter 快
        for x in nums:  # x = nums[j]
            suf[x] -= 1  # 撤销
            # 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += pre[x * 2] * suf[x * 2]
            pre[x] += 1
        return ans % MOD

###java

// 更快的写法见【Java 数组】
class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        Map<Integer, Integer> suf = new HashMap<>();
        for (int x : nums) {
            suf.merge(x, 1, Integer::sum); // suf[x]++
        }

        long ans = 0;
        Map<Integer, Integer> pre = new HashMap<>();
        for (int x : nums) { // x = nums[j]
            suf.merge(x, -1, Integer::sum); // suf[x]-- // 撤销
            // 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += (long) pre.getOrDefault(x * 2, 0) * suf.getOrDefault(x * 2, 0);
            pre.merge(x, 1, Integer::sum); // pre[x]++
        }
        return (int) (ans % MOD);
    }
}

###java

class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        int mx = 0;
        for (int x : nums) {
            mx = Math.max(mx, x);
        }

        int[] suf = new int[mx + 1];
        for (int x : nums) {
            suf[x]++;
        }

        long ans = 0;
        int[] pre = new int[mx + 1];
        for (int x : nums) {
            suf[x]--;
            if (x * 2 <= mx) {
                ans += (long) pre[x * 2] * suf[x * 2];
            }
            pre[x]++;
        }
        return (int) (ans % MOD);
    }
}

###cpp

class Solution {
public:
    int specialTriplets(vector<int>& nums) {
        const int MOD = 1'000'000'007;
        unordered_map<int, int> suf;
        for (int x : nums) {
            suf[x]++;
        }

        long long ans = 0;
        unordered_map<int, int> pre;
        for (int x : nums) { // x = nums[j]
            suf[x]--; // 撤销
            // 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += 1LL * pre[x * 2] * suf[x * 2];
            pre[x]++;
        }
        return ans % MOD;
    }
};

###go

func specialTriplets(nums []int) (ans int) {
const mod = 1_000_000_007
suf := map[int]int{}
for _, x := range nums {
suf[x]++
}

pre := map[int]int{}
for _, x := range nums { // x = nums[j]
suf[x]-- // 撤销
// 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
ans += pre[x*2] * suf[x*2]
pre[x]++
}
return ans % mod
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

方法二:枚举右,维护左(一次遍历)

枚举 $k$,设 $x=\textit{nums}[k]$,问题变成:

  • 有多少个二元组 $(i,j)$,满足 $i<j<k$ 且 $\textit{nums}[i]=x$ 且 $\textit{nums}[j] = \dfrac{x}{2}$。用哈希表 $\textit{cnt}_{12}$ 记录这样的二元组个数。

这个问题也可以枚举右维护左,即枚举 $j$,问题变成:

  • 在 $j$ 左边有多少个数等于 $\textit{nums}[j]\cdot 2$?用哈希表 $\textit{cnt}_{1}$ 记录。

###py

class Solution:
    def specialTriplets(self, nums: List[int]) -> int:
        MOD = 1_000_000_007
        cnt1 = defaultdict(int)
        cnt12 = defaultdict(int)
        cnt123 = 0
        for x in nums:
            if x % 2 == 0:
                cnt123 += cnt12[x // 2]  # 把 x 当作 nums[k]
            cnt12[x] += cnt1[x * 2]  # 把 x 当作 nums[j]
            cnt1[x] += 1  # 把 x 当作 nums[i]
        return cnt123 % MOD

###java

class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        Map<Integer, Integer> cnt1 = new HashMap<>();
        Map<Integer, Long> cnt12 = new HashMap<>();
        long cnt123 = 0;
        for (int x : nums) {
            if (x % 2 == 0) {
                cnt123 += cnt12.getOrDefault(x / 2, 0L); // 把 x 当作 nums[k]
            }
            cnt12.merge(x, (long) cnt1.getOrDefault(x * 2, 0), Long::sum); // 把 x 当作 nums[j]
            cnt1.merge(x, 1, Integer::sum); // 把 x 当作 nums[i]
        }
        return (int) (cnt123 % MOD);
    }
}

###cpp

class Solution {
public:
    int specialTriplets(vector<int>& nums) {
        const int MOD = 1'000'000'007;
        unordered_map<int, int> cnt1;
        unordered_map<int, long long> cnt12;
        long long cnt123 = 0;
        for (int x : nums) {
            if (x % 2 == 0) {
                cnt123 += cnt12[x / 2]; // 把 x 当作 nums[k]
            }
            cnt12[x] += cnt1[x * 2]; // 把 x 当作 nums[j]
            cnt1[x]++; // 把 x 当作 nums[i]
        }
        return cnt123 % MOD;
    }
};

###go

func specialTriplets(nums []int) (cnt123 int) {
const mod = 1_000_000_007
cnt1 := map[int]int{}
cnt12 := map[int]int{}
for _, x := range nums {
if x%2 == 0 {
cnt123 += cnt12[x/2] // 把 x 当作 nums[k]
}
cnt12[x] += cnt1[x*2] // 把 x 当作 nums[j]
cnt1[x]++ // 把 x 当作 nums[i]
}
return cnt123 % mod
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

更多相似题目,见下面数据结构题单的「§0.2 枚举中间」和动态规划题单的「专题:前后缀分解」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

2025 年 TC39 都在忙什么?Import Bytes、Iterator Chunking 来了

TC39 2025:Import Bytes、Iterator Chunking 和那些即将落地的新特性

写跨平台的 JS 代码时,读个二进制文件都得写三套逻辑:

// 浏览器
const bytes = await fetch('./photo.png').then(r => r.arrayBuffer());

// Node.js
const bytes = require('fs').readFileSync('./photo.png');

// Deno
const bytes = await Deno.readFile('./photo.png');

同样的需求,三种写法。想写个同构的图片处理库?先把这三套 API 适配一遍再说。

好消息是,TC39 在 2025 年推进了好几个提案来解决这类问题。这篇文章聊聊其中最值得关注的几个:Import Bytes、Iterator Chunking,以及今年已经进入 Stage 4 的新特性。

Import Bytes:一行代码搞定二进制导入

现在是什么状态

Stage 2.7(截至 2025 年 9 月),离正式标准就差临门一脚了。提案负责人是 Steven Salat,Guy Bedford 是共同作者。

核心语法

import bytes from "./photo.png" with { type: "bytes" };
// bytes 是 Uint8Array,底层是不可变的 ArrayBuffer

动态导入也支持:

const bytes = await import("./photo.png", { with: { type: "bytes" } });

就这么简单。不管你在浏览器、Node.js 还是 Deno,同一行代码,同样的结果。

为什么返回 Uint8Array 而不是 ArrayBuffer

提案选择返回 Uint8Array 而不是裸的 ArrayBuffer,理由挺实在的:

  1. 少一步操作 - 拿到 ArrayBuffer 你还得自己创建 TypedView,Uint8Array 直接就能用
  2. 跟现有 API 保持一致 - Response.bytes()Blob.bytes() 都返回 Uint8Array
  3. Node.js Buffer 兼容 - Buffer 本身就是 Uint8Array 的子类

为什么底层是不可变的 ArrayBuffer

这个设计决定挺有意思的。底层 ArrayBuffer 被设计成不可变的,原因有三:

  1. 避免共享可变状态 - 多个模块导入同一个文件,拿到的是同一个对象。如果可变,一个模块改了数据,其他模块全受影响
  2. 嵌入式场景 - 不可变数据可以直接放 ROM 里
  3. 安全性考虑 - 防止模块间通过共享 buffer 建立隐蔽通信通道

实际能干什么

图片处理

import imageBytes from "./logo.png" with { type: "bytes" };
// 用 satori 之类的同构库处理
processImage(imageBytes);

加载字体

import fontBytes from "./custom.woff" with { type: "bytes" };
// Canvas 或 PDF 生成时用
registerFont(fontBytes);

机器学习模型

import modelBytes from "./model.bin" with { type: "bytes" };
loadModel(modelBytes);

工具链支持

好消息是,主流工具已经在跟进了。Deno 2.4、Bun 1.1.7 都有类似实现,Webpack、esbuild、Parcel 也支持类似的二进制导入机制。等提案正式落地,统一语法只是时间问题。

Iterator Chunking:迭代器分块终于有原生方案了

现在是什么状态

Stage 2.7(截至 2025 年 9 月),由 Michael Ficarra 主导。

两个核心方法

chunks(size) - 非重叠分块

const numbers = [1, 2, 3, 4, 5, 6, 7].values();
const chunked = numbers.chunks(3);

for (const chunk of chunked) {
  console.log(chunk);
}
// [1, 2, 3]
// [4, 5, 6]
// [7]

windows(size) - 滑动窗口

const numbers = [1, 2, 3, 4].values();
const windowed = numbers.windows(2);

for (const window of windowed) {
  console.log(window);
}
// [1, 2]
// [2, 3]
// [3, 4]

区别很直观:chunks 是切成一块一块互不重叠,windows 是滑动窗口每次移动一格。

解决什么问题

以前想做分块操作,要么自己写,要么引入 lodash:

// lodash 方案
import chunk from 'lodash/chunk';
const chunks = chunk([1, 2, 3, 4], 2);

// 原生方案
const chunks = [1, 2, 3, 4].values().chunks(2);

原生方案的优势:

  • 不用装依赖
  • 惰性求值,内存友好
  • 跟整个迭代器生态无缝衔接
  • 支持异步迭代器

实际场景

批量 API 请求

async function batchProcess(items) {
  const batches = items.values().chunks(50);

  for (const batch of batches) {
    await Promise.all(batch.map(item => api.process(item)));
    await sleep(1000); // 避免触发限流
  }
}

移动平均计算

function movingAverage(numbers, windowSize) {
  return numbers
    .values()
    .windows(windowSize)
    .map(w => w.reduce((a, b) => a + b) / windowSize)
    .toArray();
}

const prices = [100, 102, 98, 105, 103, 107];
const ma3 = movingAverage(prices, 3);
// 3日移动平均

N-gram 生成

function generateNGrams(text, n) {
  const words = text.split(' ');
  return words.values()
    .windows(n)
    .map(w => w.join(' '))
    .toArray();
}

const bigrams = generateNGrams("The quick brown fox", 2);
// ["The quick", "quick brown", "brown fox"]

边界情况的讨论

这个提案在推进过程中遇到了一个有意思的问题:如果迭代器元素少于窗口大小,windows() 应该返回什么?

const small = [1, 2].values();
const result = small.windows(3); // 只有2个元素,请求3个的窗口

// 选项1:不返回任何窗口
// 选项2:返回 [1, 2] 作为不完整窗口

委员会讨论后认为两种场景都有合理的使用需求,所以决定把 windows() 拆分成多个方法来分别处理这两种情况。这也是提案从 Stage 2 到 Stage 2.7 花了点时间的原因。

2025 年进入 Stage 4 的特性

除了上面两个还在推进的提案,2025 年还有好几个特性已经正式"毕业"了:

RegExp.escape(2 月)

安全转义正则表达式字符串,防止注入:

const userInput = "user@example.com (admin)";
const safePattern = RegExp.escape(userInput);
const regex = new RegExp(safePattern);
// 不用担心括号被解析成分组了

这个需求太常见了,以前都得自己写转义函数或者用第三方库。

Float16Array(2 月)

半精度浮点数的 TypedArray:

const f16Array = new Float16Array([1.5, 2.7, 3.1]);

主要面向机器学习和图形处理场景。模型权重经常用 fp16 存储,有了原生支持就不用自己做转换了。

Error.isError(5 月)

可靠地判断一个值是不是 Error:

if (Error.isError(value)) {
  console.log(value.message);
}

为什么不用 instanceof Error?因为跨 realm(比如 iframe 或 Node.js 的 vm 模块)的 Error 实例会被判成 false。这个方法解决了这个历史问题。

Math.sumPrecise(7 月)

高精度求和:

const sum = Math.sumPrecise([0.1, 0.2, 0.3]);
// 比普通累加更精确,减少浮点误差累积

做金融计算或科学计算的应该会喜欢这个。

Uint8Array Base64 编解码(7 月)

原生的 Base64 编解码:

const bytes = Uint8Array.fromBase64('SGVsbG8=');
const base64 = bytes.toBase64();
// 还有 fromHex() 和 toHex()

终于不用为了 Base64 转换去找第三方库了。

Explicit Resource Management(已 Stage 4)

using 关键字,自动资源清理:

using file = await openFile('data.txt');
// 离开作用域自动关闭,不用手动 finally

借鉴了 Python 的 with 和 C# 的 using,解决了 JS 里资源管理一直很混乱的问题。

还有几个值得关注的 Stage 2 提案

Seeded PRNG(5 月进入 Stage 2)

可种子化的随机数生成器:

const random = new Random(12345); // 种子
const value = random.next();
// 同样的种子,同样的序列

游戏开发、测试、仿真这些场景经常需要可重现的随机序列。

Error Stack Accessor(5 月进入 Stage 2)

标准化错误堆栈的访问方式。现在各个引擎的 error.stack 格式都不一样,这个提案要统一它。

提案流程简单回顾

TC39 的提案分 5 个阶段:

  • Stage 0:想法
  • Stage 1:正式提案,开始讨论
  • Stage 2:规范草案,API 基本稳定
  • Stage 2.7:规范文本接近完成,准备写测试
  • Stage 3:等待实现反馈
  • Stage 4:正式纳入标准

Import Bytes 和 Iterator Chunking 都到了 Stage 2.7,离 Stage 3 就差 test262 测试和浏览器实现承诺了。

总结

2025 年 TC39 的进展还是挺给力的:

  • Import Bytes 解决了跨平台二进制导入的老大难问题,同构库开发终于能省心了
  • Iterator Chunking 补上了迭代器工具链的空白,chunks 和 windows 覆盖了大部分分块场景
  • 一堆特性进入 Stage 4:RegExp.escape、Float16Array、Math.sumPrecise、Base64 编解码、资源管理...

这些特性有的已经可以通过 Babel 或 TypeScript 提前尝鲜了。如果你在用 Deno 或 Bun,Import Bytes 类似的功能现在就能用。


顺手安利几个我的开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

参考链接

"讲讲原型链" —— 面试官最爱问的 JavaScript 基础

JavaScript 原型与原型链:从困惑到完全理解

以前在看 JavaScript 代码的时候,经常会遇到一个问题:

const arr = [1, 2, 3];
arr.push(4);      // 4
arr.join(',');    // "1,2,3,4"
arr.toString();   // "1,2,3,4"

我明明只创建了一个数组,为什么它能调用 pushjointoString 这些方法?这些方法是从哪来的?

再看这段代码:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, I'm ${this.name}`);
};

const person = new Person('张三');
person.sayHello(); // "Hello, I'm 张三"

person 对象本身没有 sayHello 方法,但却能调用它。这背后的机制就是原型链。


先搞清楚几个概念

在深入之前,先把几个容易混淆的概念理清楚:

[[Prototype]]__proto__prototype 的区别

概念 是什么 属于谁 作用
[[Prototype]] 内部属性 所有对象 指向对象的原型,隐藏属性
__proto__ 访问器属性 所有对象 暴露 [[Prototype]],非标准但广泛支持
prototype 普通属性 函数 存放给实例共享的属性和方法

简单说:

  • prototype函数才有的属性,用来存放共享方法
  • __proto__所有对象都有的属性,指向它的原型对象
  • [[Prototype]]__proto__ 的内部实现
function Foo() {}
const foo = new Foo();

// prototype 只有函数才有
console.log(Foo.prototype);      // {constructor: ƒ}
console.log(foo.prototype);      // undefined

// __proto__ 所有对象都有
console.log(foo.__proto__ === Foo.prototype);  // true

现代写法:Object.getPrototypeOf()

__proto__ 虽然好用,但它不是 ECMAScript 标准的一部分,只是各浏览器都实现了。推荐用标准方法:

// 获取原型
Object.getPrototypeOf(foo) === Foo.prototype  // true

// 设置原型
Object.setPrototypeOf(obj, prototype)

// 创建时指定原型
Object.create(prototype)

原型是什么

JavaScript 里每个函数都有一个 prototype 属性,指向一个对象。这个对象叫做原型对象,它的作用是让该函数创建的所有实例共享属性和方法。

function Car(brand) {
  this.brand = brand;
}

// 方法定义在原型上,所有实例共享
Car.prototype.start = function() {
  console.log(`${this.brand} 启动了`);
};

const car1 = new Car('丰田');
const car2 = new Car('本田');

car1.start(); // 丰田 启动了
car2.start(); // 本田 启动了

// 两个实例用的是同一个方法
console.log(car1.start === car2.start); // true

这就是原型的核心价值:方法只需要定义一次,所有实例都能用

如果把方法定义在构造函数里,每创建一个实例就会新建一个函数,浪费内存:

// 不推荐的写法
function BadCar(brand) {
  this.brand = brand;
  this.start = function() {  // 每个实例都有一份
    console.log(`${this.brand} 启动了`);
  };
}

const bad1 = new BadCar('丰田');
const bad2 = new BadCar('本田');
console.log(bad1.start === bad2.start); // false,两个不同的函数

new 关键字到底做了什么

理解原型链之前,得先搞清楚 new 的工作原理。当你写 new Foo() 时,JavaScript 引擎会执行以下四个步骤:

flowchart LR
    A['1. 创建空对象']:::step --> B['2. 设置原型链']:::step
    B --> C['3. 执行构造函数']:::step
    C --> D['4. 返回对象']:::success

    classDef step fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef success fill:#d4edda,stroke:#28a745,color:#155724

详细步骤

function Person(name) {
  this.name = name;
}
Person.prototype.greet = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const john = new Person('John');

Step 1:创建一个空对象

// 内部创建:{}

Step 2:将空对象的 [[Prototype]] 指向构造函数的 prototype

// 内部操作:newObj.__proto__ = Person.prototype

Step 3:用这个空对象作为 this 执行构造函数

// 内部操作:Person.call(newObj, 'John')
// 执行后 newObj 变成 { name: 'John' }

Step 4:返回对象

  • 如果构造函数返回一个对象,就用那个对象
  • 否则返回 Step 1 创建的对象

手写一个 new

理解了原理,可以自己实现一个:

function myNew(Constructor, ...args) {
  // 1. 创建空对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);

  // 2. 执行构造函数,this 绑定到新对象
  const result = Constructor.apply(obj, args);

  // 3. 如果构造函数返回对象,就用它;否则用新创建的对象
  return result instanceof Object ? result : obj;
}

// 测试
const p = myNew(Person, 'Alice');
p.greet(); // Hi, I'm Alice
console.log(p instanceof Person); // true

原型链的查找机制

当访问对象的属性或方法时,JavaScript 会按照这个顺序查找:

  1. 先在对象自身找
  2. 找不到,去对象的原型 (__proto__) 上找
  3. 还找不到,继续往上一级原型找
  4. 直到 Object.prototype,再往上就是 null

这条查找链路就是原型链

flowchart TB
    A["dog 实例<br/>{ name: 'Buddy' }"]:::instance -->|__proto__| B["Dog.prototype<br/>{ bark: ƒ }"]:::proto
    B -->|__proto__| C["Animal.prototype<br/>{ speak: ƒ }"]:::proto
    C -->|__proto__| D["Object.prototype<br/>{ toString: ƒ, ... }"]:::rootProto
    D -->|__proto__| E["null"]:::endNode

    classDef instance fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef proto fill:#d4edda,stroke:#28a745,color:#155724
    classDef rootProto fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef endNode fill:#f8d7da,stroke:#dc3545,color:#721c24

代码示例

function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a sound`);
};

function Dog(name) {
  Animal.call(this, name);
}

// 建立原型链:Dog.prototype -> Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log('Woof!');
};

const dog = new Dog('Buddy');

// 查找过程:
dog.name;    // 在 dog 自身找到
dog.bark();  // 在 Dog.prototype 找到
dog.speak(); // 在 Animal.prototype 找到
dog.toString(); // 在 Object.prototype 找到

用代码验证这条链:

console.log(dog.__proto__ === Dog.prototype);                 // true
console.log(Dog.prototype.__proto__ === Animal.prototype);    // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null);             // true

这就解释了开头的问题。数组能调用 pushjoin,是因为这些方法定义在 Array.prototype 上。能调用 toString,是因为顺着原型链能找到 Object.prototype.toString(虽然 Array 重写了这个方法)。


完整的原型链图谱

JavaScript 的原型链比想象中更复杂,函数本身也是对象,也有自己的原型链:

flowchart TB
    subgraph IL[实例层]
        foo["foo 实例"]:::instance
    end

    subgraph PL[原型层]
        FooP["Foo.prototype"]:::proto
        ObjP["Object.prototype"]:::rootProto
    end

    subgraph FL[函数层]
        Foo["Foo 函数"]:::func
        Obj["Object 函数"]:::func
        Func["Function 函数"]:::func
    end

    subgraph FPL[函数原型层]
        FuncP["Function.prototype"]:::funcProto
    end

    foo -->|__proto__| FooP
    FooP -->|__proto__| ObjP
    ObjP -->|__proto__| NULL["null"]:::endNode

    Foo -->|prototype| FooP
    Foo -->|__proto__| FuncP

    Obj -->|prototype| ObjP
    Obj -->|__proto__| FuncP

    Func -->|prototype| FuncP
    Func -->|__proto__| FuncP

    FuncP -->|__proto__| ObjP

    classDef instance fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef proto fill:#d4edda,stroke:#28a745,color:#155724
    classDef rootProto fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef func fill:#e2d9f3,stroke:#6f42c1,color:#432874
    classDef funcProto fill:#fce4ec,stroke:#e91e63,color:#880e4f
    classDef endNode fill:#f8d7da,stroke:#dc3545,color:#721c24

    style IL fill:#e8f4fc,stroke:#0d6efd
    style PL fill:#e8f5e9,stroke:#28a745
    style FL fill:#f3e5f5,stroke:#6f42c1
    style FPL fill:#fce4ec,stroke:#e91e63

几个关键点

1. 所有函数都是 Function 的实例

console.log(Foo.__proto__ === Function.prototype);    // true
console.log(Object.__proto__ === Function.prototype); // true
console.log(Function.__proto__ === Function.prototype); // true(自己创建自己)

2. Function.prototype 也是对象,它的原型是 Object.prototype

console.log(Function.prototype.__proto__ === Object.prototype); // true

3. Object.prototype 是原型链的终点

console.log(Object.prototype.__proto__ === null); // true

4. 一个有趣的循环

// Object 是函数,所以它的 __proto__ 是 Function.prototype
console.log(Object.__proto__ === Function.prototype); // true

// Function.prototype 是对象,所以它的 __proto__ 是 Object.prototype
console.log(Function.prototype.__proto__ === Object.prototype); // true

// 这形成了一个有趣的"鸡生蛋蛋生鸡"的关系

属性遮蔽(Property Shadowing)

如果对象自身和原型上有同名属性,会发生什么?

function Person(name) {
  this.name = name;
}

Person.prototype.name = 'Default';
Person.prototype.greet = function() {
  console.log(`Hello, ${this.name}`);
};

const john = new Person('John');

// 自身属性遮蔽原型属性
console.log(john.name); // 'John',不是 'Default'

// 删除自身属性后,原型属性就露出来了
delete john.name;
console.log(john.name); // 'Default'

这就是属性遮蔽:自身属性会"遮住"原型链上的同名属性。

检查属性来源

const john = new Person('John');

// hasOwnProperty 只检查自身属性
console.log(john.hasOwnProperty('name'));  // true
console.log(john.hasOwnProperty('greet')); // false

// in 操作符检查整个原型链
console.log('name' in john);  // true
console.log('greet' in john); // true

实现继承

理解了原型链,继承就好办了。核心就两步:

  1. 调用父构造函数,继承实例属性
  2. 设置原型链,继承原型方法
function Vehicle(type) {
  this.type = type;
  this.speed = 0;
}

Vehicle.prototype.accelerate = function(amount) {
  this.speed += amount;
  console.log(`${this.type} 加速到 ${this.speed} km/h`);
};

function Car(brand) {
  Vehicle.call(this, '汽车');  // 继承实例属性
  this.brand = brand;
}

Car.prototype = Object.create(Vehicle.prototype);  // 继承原型方法
Car.prototype.constructor = Car;

// 添加子类特有的方法
Car.prototype.honk = function() {
  console.log(`${this.brand} 鸣笛`);
};

// 重写父类方法
Car.prototype.accelerate = function(amount) {
  Vehicle.prototype.accelerate.call(this, amount);
  if (this.speed > 120) {
    console.log('超速警告');
  }
};

const myCar = new Car('丰田');
myCar.accelerate(50);   // 汽车 加速到 50 km/h
myCar.accelerate(80);   // 汽车 加速到 130 km/h
                        // 超速警告
myCar.honk();           // 丰田 鸣笛

为什么用 Object.create() 而不是直接赋值

// 错误写法
Car.prototype = Vehicle.prototype;
// 问题:修改 Car.prototype 会影响 Vehicle.prototype

// 错误写法
Car.prototype = new Vehicle();
// 问题:会执行 Vehicle 构造函数,可能有副作用

// 正确写法
Car.prototype = Object.create(Vehicle.prototype);
// 创建一个新对象,原型指向 Vehicle.prototype

ES6 的 class 语法

ES6 引入了 class 关键字,写起来更清爽:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a sound`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  bark() {
    console.log('Woof!');
  }
}

const dog = new Dog('Buddy', 'Labrador');
dog.speak(); // Buddy makes a sound
dog.bark();  // Woof!

但要清楚,class 只是语法糖,底层还是原型链那套:

console.log(typeof Dog); // "function"
console.log(dog.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true

class 的一些特性

class Example {
  // 实例属性(ES2022+)
  instanceProp = 'instance';

  // 私有属性(ES2022+)
  #privateProp = 'private';

  // 静态属性
  static staticProp = 'static';

  // 静态方法
  static staticMethod() {
    return 'static method';
  }

  // getter/setter
  get value() {
    return this.#privateProp;
  }
}

几个容易踩的坑

1. 引用类型放原型上会共享

function Student(name) {
  this.name = name;
}

Student.prototype.hobbies = [];  // 所有实例共享这个数组

const s1 = new Student('张三');
const s2 = new Student('李四');

s1.hobbies.push('reading');
console.log(s2.hobbies); // ['reading']  // s2 也有了,出问题了

引用类型(数组、对象)应该放在构造函数里:

function Student(name) {
  this.name = name;
  this.hobbies = [];  // 每个实例独立
}

2. 别直接替换 prototype 对象

function Foo() {}

// 直接替换 prototype 会丢失 constructor
Foo.prototype = {
  method: function() {}
};

const foo = new Foo();
console.log(foo.constructor === Foo); // false,变成 Object 了

要么记得补上 constructor,要么用属性添加的方式:

// 方式一:补上 constructor
Foo.prototype = {
  constructor: Foo,
  method: function() {}
};

// 方式二:直接添加属性(推荐)
Foo.prototype.method = function() {};

3. 箭头函数不能用作构造函数

const Foo = () => {};
const foo = new Foo(); // TypeError: Foo is not a constructor

箭头函数没有 prototype 属性,也没有自己的 this,所以不能用 new

4. instanceof 的局限性

// instanceof 检查的是原型链
console.log([] instanceof Array);  // true
console.log([] instanceof Object); // true

// 跨 iframe/realm 时会失效
// iframe 里的 Array 和主页面的 Array 不是同一个

更可靠的类型检查:

Object.prototype.toString.call([]);  // "[object Array]"
Array.isArray([]);  // true

性能考虑

原型链查找有开销

属性查找会沿着原型链向上,链越长开销越大。虽然现代引擎有优化,但还是要注意:

// 如果频繁访问原型链上的属性,可以缓存
const method = obj.someMethod;
for (let i = 0; i < 1000000; i++) {
  method.call(obj);  // 比 obj.someMethod() 快
}

Object.create(null) 创建纯净对象

// 普通对象会继承 Object.prototype
const obj = {};
console.log(obj.toString); // ƒ toString() { [native code] }

// 纯净对象没有原型链
const pureObj = Object.create(null);
console.log(pureObj.toString); // undefined

// 适合用作字典/哈希表,不用担心键名冲突
const dict = Object.create(null);
dict['hasOwnProperty'] = 'safe';  // 不会覆盖原型方法

小结

原型链说穿了就是一条查找链:找属性时从对象自身开始,顺着 __proto__ 一路往上找,直到 null

几个要点:

  • prototype 是函数的属性,用于存放共享的方法
  • __proto__(或 [[Prototype]])是对象的属性,指向它的原型
  • 推荐用 Object.getPrototypeOf() 代替 __proto__
  • new 关键字做了四件事:创建对象、设置原型、执行构造函数、返回对象
  • 方法定义在原型上,省内存
  • class 是语法糖,底层还是原型链
  • Object.prototype 是原型链的终点,它的 __proto__null

理解了这个机制,再看 JavaScript 的面向对象就清晰多了。框架源码里大量使用原型链,比如 Vue 2 的响应式系统、各种插件的 mixin 实现,都是基于这套机制。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

参考资料

【Virtual World 04】我们的目标,无限宇宙!!

这是纯前端手搓虚拟世界第四篇。

前端佬们,前三篇的基础打好,基本创建一个单体的应用没啥问题了。但前端佬肯定发现一个很尴尬的问题!

93B1A00879A9B67271080936B8A2D89CE1D69417_size242_w423_h220.gif

那就是我们的 Canvas 只有 600x600。

如果你想直接搞一幅《清明上河图》,那画着画着,就会发现——没地儿了

立Flag

按照最简单的方法,把canvas加大,把屏幕加大,这样就不只600x600了。

But!!!!

装X装到头,抠抠搜搜加那点资源,还是不够用。既然是虚拟世界,我们的目前,就应该无限宇宙!

这篇,就是要把想法变成现实,一个通向无限世界的“窗口”,实现一个虚拟摄像机(Virtual Camera) 。你看到的只是摄像机拍到的画面,而世界本身,你的梦想有多大,那么就有多大。

timg (1).gif


战略思考

上了class的贼船,首先想法就是抽象一个类,来管理这个。Viewport(视口) ,专门处理这些事情。

它的核心功能只有两个:

  1. 坐标转换:搞清楚“鼠标点在屏幕上的 (100,100)”到底对应“虚拟世界里的哪一个坐标”。
  2. 平移交互(Pan) :按住 空格键 + 鼠标左键拖拽,移动摄像机,浏览世界的其他角落。

嗯,够逼格了!

timg (25).gif


基础原理

要个无限空间其实是一个视觉小把戏,首先我们得确认浏览器是不会允许你创建一个 width: 99999999px 的 Canvas 的,这样浏览器的内存会直接嗝屁。

然后我们移动鼠标位置是固定不变的,大致示意图如下:

image.png

虚拟层就是在数学层面上的模拟层,所有的移动,添加都是放到虚拟层上。

我们脑子里始终要记得两套坐标系:

  • 屏幕坐标系 (Screen Space)

    • 这是物理现实
    • 原点 (0,0) 永远在 Canvas 的左上角。
    • 范围有限,比如 600x600
    • 用途:接收鼠标事件 (evt.offsetX, evt.offsetY),最终渲染图像。
  • 世界坐标系 (World Space)

    • 这是虚拟数据
    • 原点 (0,0) 是世界的中心。
    • 范围无限,你的点可以是 (-5000, 99999)
    • 用途:存储 PointSegment 的真实位置。

视口变换的原理,并不是真的把 Canvas 的 DOM 元素拖走了,而是我们在绘制之前,对所有的坐标做了一次数学偏移。

想象一下你拿着一个相机(视口)在拍风景,如果你想看右边的树,你需要把相机向移,但在相机取景框里,那棵树看起来是向移了。

嗯,大概就是这样了。


上代码

听不懂??直接上代码!!!!

0.gif

视口控制器:Viewport

src 下新建文件夹 view,然后创建 viewport.js

这玩意的数学原理其实就是一个简单的减法:

世界坐标 = 屏幕坐标 - 视口中心偏移量

如果我把视口往右移 100px,那原本的世界原点 (0,0) 现在就在屏幕的 (100,0) 位置。

JavaScript

// src/view/viewport.js
import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    // 视口的初始缩放级别(为下一篇做铺垫)
    this.zoom = 1;
    // 视口的中心偏移量(相当于摄像机的位置)
    // 默认让 (0,0) 在画布中心,这样更符合数学直觉
    this.center = new Point2D(canvas.width / 2, canvas.height / 2);
    // 另一种更通用的做法是记录 offset,也就是 panning 的距离
    this.offset = new Point2D(0, 0);

    // 拖拽状态管理
    this.drag = {
      start: new Point2D(0, 0), // 鼠标按下的起始点
      end: new Point2D(0, 0),   // 鼠标当前的结束点
      offset: new Point2D(0, 0),// 这一瞬间拖了多远
      active: false             // 是否正在拖拽
    };

    this.#addEventListeners();
  }

  // 核心数学题:屏幕坐标 -> 世界坐标
  getMouse(evt) {
    // 现在的计算逻辑:(鼠标位置 - 中心点) * 缩放系数 - 偏移量
    // 暂时先不加缩放,只处理平移
    return new Point2D(
      (evt.offsetX - this.center.x) - this.offset.x,
      (evt.offsetY - this.center.y) - this.offset.y
    );
  }

  // 获取当前的偏移量,给 Canvas 用
  getOffset() {
     return new Point2D(
         this.center.x + this.offset.x, 
         this.center.y + this.offset.y
     );
  }

  #addEventListeners() {
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格(Space)且点击左键(0)时,才触发平移
      // 这里的 evt.button == 0 是左键
      // 这里的 evt.ctrlKey / shiftKey 等也可以判断,但我们要判断空格键状态
      // 由于 mousedown 里拿不到键盘持续状态,我们需要一个外部变量或者在 window 上监听键盘
      // 简单的做法:检查 evt 里是否包含按键信息?不包含。
      // 所以我们通常单独存一个键盘状态,或者直接利用 "wheel" 做缩放,用 "middle" 做平移
      // 但既然需求是 "空格+左键",我们需要配合 keydown/keyup
    });
  }
}

这里歇一歇,理理代码思路。

9.gif

**“按住空格”**这个逻辑在 DOM 事件里稍微有点麻烦。因为 mousedown 事件对象里不直接告诉你“空格键是不是正被按着”。

我们需要给 Viewport 增加键盘监听。

修正后的完整 src/view/viewport.js

import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    this.zoom = 1;
    // 这里的 offset 就是我们一共平移了多少距离
    this.offset = new Point2D(0, 0); 
    
    // 拖拽计算用的临时变量
    this.drag = {
      start: new Point2D(0, 0),
      end: new Point2D(0, 0),
      offset: new Point2D(0, 0),
      active: false
    };

    this.#addEventListeners();
  }

  // 计算:鼠标在屏幕上的点,对应的真实世界坐标在哪里
  getMouse(evt, subtractDragOffset = false) {
    const p = new Point2D(
      (evt.offsetX - this.canvas.width / 2) * this.zoom - this.offset.x,
      (evt.offsetY - this.canvas.height / 2) * this.zoom - this.offset.y
    );
    return p;
  }
  
  // 给 Canvas 渲染用的,告诉它该平移多少
  getOffset() {
      return new Point2D(
          this.offset.x + this.canvas.width / 2,
          this.offset.y + this.canvas.height / 2
      );
  }

  #addEventListeners() {
    // 记录空格键状态
    this.isSpacePressed = false;

    window.addEventListener("keydown", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = true;
       }
    });

    window.addEventListener("keyup", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = false;
       }
    });

    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格 + 左键,才开始拖拽视口
      if (this.isSpacePressed && evt.button === 0) {
        this.drag.start = this.getMouse(evt);
        this.drag.active = true;
      }
    });

    this.canvas.addEventListener("mousemove", (evt) => {
      if (this.drag.active) {
        this.drag.end = this.getMouse(evt);
        // 计算这一瞬间移动了多少
        this.drag.offset = new Point2D(
            this.drag.end.x - this.drag.start.x,
            this.drag.end.y - this.drag.start.y
        );
        // 累加到总偏移量里
        this.offset.x += this.drag.offset.x;
        this.offset.y += this.drag.offset.y;
        
        // 重要:重置 start,因为我们已经处理了这段位移
        // 如果不重置,你会发现画面飞得越来越快
        this.drag.start = this.getMouse(evt); 
      }
    });

    this.canvas.addEventListener("mouseup", () => {
      if (this.drag.active) {
        this.drag.active = false;
      }
    });
  }
}

修改 GraphEditor

这就是上篇埋的坑。之前我们在 GraphEditor 里直接用了 evt.offsetX。现在不行了,必须通过 viewport.getMouse(evt) 来获取坐标。

修改 src/editors/graphEditor.js

  1. 构造函数接收 viewport
  2. 事件监听改用 viewport.getMouse(evt)
  3. 冲突解决:如果按住了空格(正在拖拽视图),就不要触发“画点”的逻辑。
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";

export default class GraphEditor {
  constructor(canvas, graph, viewport) {
    this.canvas = canvas;
    this.graph = graph;
    this.viewport = viewport;

    this.ctx = canvas.getContext("2d");

    // 状态机
    this.selected = null; // 当前选中的点(用于连线起点)
    this.hovered = null; // 鼠标悬停的点
    this.dragging = false; // 预留给未来拖拽用
    this.mouse = null; // 当前鼠标位置

    // 启动监听
    this.#addEventListeners();
  }

  #addEventListeners() {
    // 1. 鼠标按下事件
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有左键(0)和右键(2)才处理
      if (evt.button == 2) {
        // 右键逻辑
        if (this.selected) {
          this.selected = null; // 取消当前选中,停止连线
        } else if (this.hovered) {
          this.#removePoint(this.hovered); // 删除点
        }
      }

      if (evt.button == 0) {
        // [新增] 如果视口正在被拖拽,或者是空格按下状态,不要画点
        if (this.viewport.drag.active || this.viewport.isSpacePressed) return;

        // 左键逻辑
        // 如果鼠标在某个点上,就选中它;如果不在,就新建一个点并选中它
        if (this.hovered) {
          this.#select(this.hovered);
          this.dragging = true;
          return;
        }
        this.graph.tryAddPoint(this.mouse);
        this.#select(this.mouse); // 自动选中新点,方便连续画线
        this.hovered = this.mouse;
        this.dragging = true;
      }
    });

    // 2. 鼠标移动事件
    this.canvas.addEventListener("mousemove", (evt) => {
      // 获取鼠标在 Canvas 里的坐标(即使 Canvas 缩放或偏移也能用)
      // 这里先简化处理,假设 Canvas 铺满或者无偏移
      // 实际上我们应该写个 getViewportPoint,但暂时先直接读取 offsetX/Y
      this.mouse = this.viewport.getMouse(evt);

      // 检查鼠标有没有悬停在某个点上
      this.hovered = this.#getNearestPoint(this.mouse);

      // 移动的时候不需要重绘吗?需要的,但我们会在 World 里统一驱动动画循环
    });

    // 3. 禁止右键菜单弹出
    this.canvas.addEventListener("contextmenu", (evt) => evt.preventDefault());

    // 4. 鼠标抬起(结束拖拽状态)
    this.canvas.addEventListener("mouseup", () => (this.dragging = false));
  }

  #select(point) {
    // 如果之前已经选中了一个点,现在又选了一个点,说明要连线
    if (this.selected) {
      // 尝试添加线段
      this.graph.tryAddSegment(new Segment(this.selected, point));
    }
    this.selected = point;
  }

  #removePoint(point) {
    this.graph.removePoint(point);
    this.hovered = null;
    if (this.selected == point) {
      this.selected = null;
    }
  }

  // 辅助函数:找离鼠标最近的点
  #getNearestPoint(point, minThreshold = 15) {
    let nearest = null;
    let minDist = Number.MAX_SAFE_INTEGER;

    for (const p of this.graph.points) {
      const dist = Math.hypot(p.x - point.x, p.y - point.y);
      if (dist < minThreshold && dist < minDist) {
        minDist = dist;
        nearest = p;
      }
    }
    return nearest;
  }

  // 专门负责画编辑器相关的 UI(比如高亮、虚线)
  display() {
    this.graph.draw(this.ctx);

    // 如果有悬停的点,画个特殊的样式
    if (this.hovered) {
      this.hovered.draw(this.ctx, { outline: true });
    }

    // 如果有选中的点,也高亮一下
    if (this.selected) {
      // 获取鼠标位置作为意图终点
      const intent = this.hovered ? this.hovered : this.mouse;
      // 画出“虚拟线条”:从选中点 -> 鼠标位置
      new Segment(this.selected, intent).draw(this.ctx, {
        color: "rgba(0,0,0,0.5)",
        width: 1,
        dash: [3, 3],
      });
      this.selected.draw(this.ctx, { outline: true, outlineColor: "blue" });
    }
  }
}

4.3. 重构 World:应用视口变换

最后,去 index.js 把这一切串起来。Canvas 的变换(Translate)需要包裹在 save()restore() 之间,否则你的 UI(如果有的话)也会跟着一起跑。

修改 src/index.js

import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.js";
import Viewport from "./view/viewport.js"; // 引入新成员

export default class World {
  constructor(canvas, width = 600, height = 600) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.canvas.width = width;
    this.canvas.height = height;

    this.graph = new Graph();
    // 1. 先初始化视口
    this.viewport = new Viewport(this.canvas);
    // 2. 把视口传给编辑器
    this.editor = new GraphEditor(this.canvas, this.graph, this.viewport);

    this.animate();
  }

  animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // [核心步骤] 保存当前状态 -> 移动画布 -> 画画 -> 恢复状态
    this.ctx.save();
    
    // 获取视口当前的偏移,并应用平移变换
    // 注意:这里我们用 scale(1/zoom) 是为了配合鼠标计算,暂时还没做缩放,
    // 但我们可以把 translate 先写好
    const offset = this.viewport.getOffset();
    this.ctx.translate(offset.x, offset.y);
    
    // 所有的绘制现在都在"移动后"的坐标系里进行了
    this.editor.display();
    
    this.ctx.restore();

    requestAnimationFrame(() => this.animate());
  }
}

大概示意图

image.png

嗯....NICE!

今天就这样了。

完整代码戳这里:github.com/Float-none/…

npm几个实用命令

前言

最近接到公司安全团队的项目风险表格,列出了当前前端项目的一些安全风险,大概有190多项,第一个大坨是XSS的,第二大坨就是npm包需要升级的,看了下,需要升级的大概有55个包,最近在做这些包升级的时候发现了NPM以前很少用,但是确是很实用的几个命令,特记录分享

实用命令

npm install package@version

此命令相对其它要介绍的几个命令应该是使用率算高的,它的功能就是指定安装特定的版本 先来看一下package.json版本规则符号对比表:

符号 示例 允许的版本范围 核心场景
无符号 1.4.2 1.4.2 版本敏感,禁止任何更新
^(插入号) ^1.4.2 1.4.2 → 1.9.9(主版本不变) 日常依赖,自动更功能/修复
~(波浪号) ~1.4.2 1.4.2 → 1.4.9(仅修订号更新) 保守更新,仅接受 bug 修复
>/< >=1.4.2 <3.0.0 1.4.2 → 2.9.9 精确控制版本区间
*(星号) 1.* 1.0.0 → 1.999.999 无版本依赖(极少用)
!(感叹号) >=1.4.0 !1.4.2 1.4.0、1.4.1、1.4.3+(排除 1.4.2) 禁止有问题的版本

如果你使用普通的npm install package,那它在package.json里写入的版本规则是第二项,如项目有lock文件的时候问题不大,但是没有的时候,你执行npm i,它会在可允许的范围内主动更新版本,如果你使用npm install package@version,那写入package.json的版本规则会是第一种,此次安全团队给出了要更新到的指定版本,所以在此处我选择的通过npm install package@version来实现安装指定版本并锁死版本号 使用示例:

npm install sass@1.94.2

npm ls package [--depth=0]

此命令用于查看你当前项目安装的package版本是多少,它会列出安装的所有的版本,此处我们以vue包为例,执行如下命令

npm ls vue

控制台会看到如下输出结果:

image.png

从上图我们还看到部分包后还带有deduped的灰色字,这个的意思是“去重” 的意思,是 de-duplicated 的缩写,代表这个包是 “重复依赖被合并后,复用了已安装的版本”,核心是 npm 优化依赖树的一种机制,当项目中多个依赖同时需要同一个包(且版本兼容)时,npm 会自动把重复的包合并到依赖树的更上层(比如根目录的 node_modules),避免重复安装。此时在 npm ls 中,被合并的重复包就会标注 deduped,表示 “这个包没单独装,而是复用了其他地方已有的版本” 上面命令还有一个可选参数就是--depth,控制依赖树的显示层级,避免默认npm ls输出过于冗长,用的不多,看二个例子:

  • npm ls vue --depth=0 只显示项目直接依赖(不显示子依赖)
  • npm ls vue --depth=1 显示直接依赖 + 一级子依赖

npm explain package

精准解释某个包被安装的原因(谁依赖它、依赖版本范围、安装路径),解决「明明没手动安装,却出现在node_modules」的困惑,个人感觉这个命令和上面ls有一点相似,都可以查看当前包安装了什么版本,哪一个版本被安装是因为哪一个依赖,我们还是以vue包来例

npm explain vue

在命令行看到如下结果:

image1.png

npm view package [version[s]]

此命令就是查看package包线上可用的版本,分带versiont和不带version,version又有分带不带s,

  • 不带version,从npm官方仓库(或当前配置的镜像源)中查询vue包的公开信息
  • 带version,并且带s就是列出所有可用历史版本
  • 带version,不带s就是当前最新的稳定可用版本 此处我们还是以vue包为例:
npm view vue

控制台输出如下:

image2.png

此命令还可以指定版本号,使用方式是npm view vue@version,就是查看指定版本的公开信息,这个命令给我的感觉就是在命令行中打开vue包的npm简版详情页

npm view vue versions

控制台会看到如下输出结果:

image3.png

如果不带s,控制台输出如下:

image4.png

npm view package[@version] dependencies [--json]

此命令就是上面npm view的另一种用法,它可以查看当前包当指版本的生产依赖和开发依赖,为什么单提出来说了,因为这个是我这一次用的最多的命令,因为安全团队给的需要你做升级的包里,它并不是当前项目的直接依赖,很多都是二级或者三级依赖,如果你要升三级依赖,那你就得去查看祖先级包哪一个版本可以让你这个依赖升级,单提出来的另一个原因是它还有一个在线web工具 此处我们还是以vue包为例:

npm view vue dependencies --json

命令行输出如下:

image5.png

它有在线版本:npmgraph.js.org/ 使用体验如下:

image6.png

一开始我是使用上在命令行查看的,后面发现这个在线的工具后,就使用的都是这个在线的工具了,顺便说个题外话,如果同样的功能可以通过cli来用,也可以通过GUI来用,你通常会选择哪一个来用了?当然,我是后者,你了?

小结

对于做前端开发的我们基本每天都有在用NPM命令,但是还是有很多好用的功能是没有发现的,所以我一直都很敬畏技术,就像我写个人简历我觉得我自己掌握了的技术我敢用的二个字就是熟练,从来不敢用精通,因为精通一个东西真的不是那么容易的 个人的知识和能力是有限度,你在使用npm的时候有没有发现其它的一些好用的但是使用不太高频的功能,期待你的留言分享,一起学习一起进步

优雅的React表单状态管理

优雅的React表单状态管理

🌸 你是否还在为React表单中的重复状态管理代码感到困扰?今天我们就来学习如何用自定义Hook来优雅地解决这个问题~

传统表单管理的痛点

在React中,我们通常这样管理表单:

import React, { useState } from 'react';

function LoginForm() {
  // 每个字段都需要单独的state
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [email, setEmail] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    // 处理表单提交
    console.log({ username, password, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)} // 每个字段都需要单独的onChange
        placeholder="用户名"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="密码"
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="邮箱"
      />
      <button type="submit">登录</button>
    </form>
  );
}

当表单字段增多时,这种写法会变得非常繁琐:

  • 每个字段都需要单独的state和setter
  • 每个input都需要重复的onChange逻辑
  • 表单数据分散在多个state中,不方便统一处理

优雅方案:自定义useForm Hook

让我们创建一个通用的useForm Hook来解决这些问题:

import { useState } from 'react';

/**
 * 通用表单状态管理Hook
 * @param {Object} initialValues - 表单初始值
 * @returns {Object} 包含表单值、onChange处理函数和重置函数
 */
const useForm = (initialValues) => {
  // 用一个对象统一管理所有表单字段
  const [values, setValues] = useState(initialValues);

  // 通用的onChange处理函数
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    // 处理复选框等特殊类型
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  // 重置表单到初始状态
  const resetForm = () => {
    setValues(initialValues);
  };

  return { values, handleChange, resetForm };
};

// 使用示例
function LoginForm() {
  // 一行代码初始化整个表单
  const { values, handleChange, resetForm } = useForm({
    username: '',
    password: '',
    email: ''
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('表单数据:', values);
    // 提交后重置表单
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 所有input共用一个handleChange */}
      <input
        type="text"
        name="username" // 注意name属性要和初始值的key对应
        value={values.username}
        onChange={handleChange}
        placeholder="用户名"
      />
      <input
        type="password"
        name="password"
        value={values.password}
        onChange={handleChange}
        placeholder="密码"
      />
      <input
        type="email"
        name="email"
        value={values.email}
        onChange={handleChange}
        placeholder="邮箱"
      />
      <button type="submit">登录</button>
      <button type="button" onClick={resetForm}>重置</button>
    </form>
  );
}

进阶:添加表单验证

我们可以进一步增强这个Hook,添加表单验证功能:

import { useState, useEffect } from 'react';

/**
 * 带验证的表单状态管理Hook
 * @param {Object} initialValues - 表单初始值
 * @param {Function} validate - 验证函数,返回错误信息对象
 * @returns {Object} 表单控制对象
 */
const useFormWithValidation = (initialValues, validate) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 当表单值变化时自动验证
  useEffect(() => {
    if (isSubmitting) {
      const validationErrors = validate(values);
      setErrors(validationErrors);
      setIsSubmitting(false);
    }
  }, [values, isSubmitting, validate]);

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value
    }));
    // 清除该字段的错误信息
    setErrors(prevErrors => ({
      ...prevErrors,
      [name]: ''
    }));
  };

  const handleSubmit = (callback) => (e) => {
    e.preventDefault();
    const validationErrors = validate(values);
    setErrors(validationErrors);
    setIsSubmitting(true);
    
    // 如果没有错误,执行提交回调
    if (Object.keys(validationErrors).length === 0) {
      callback(values);
    }
  };

  const resetForm = () => {
    setValues(initialValues);
    setErrors({});
  };

  return { values, errors, handleChange, handleSubmit, resetForm };
};

// 使用示例
function RegisterForm() {
  // 验证函数
  const validate = (values) => {
    const errors = {};
    
    if (!values.username.trim()) {
      errors.username = '用户名不能为空';
    } else if (values.username.length < 3) {
      errors.username = '用户名至少3个字符';
    }
    
    if (!values.email) {
      errors.email = '邮箱不能为空';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
      errors.email = '请输入有效的邮箱地址';
    }
    
    if (!values.password) {
      errors.password = '密码不能为空';
    } else if (values.password.length < 6) {
      errors.password = '密码至少6个字符';
    }
    
    return errors;
  };

  const { values, errors, handleChange, handleSubmit, resetForm } = useFormWithValidation(
    { username: '', email: '', password: '' },
    validate
  );

  const onSubmit = (formData) => {
    console.log('注册成功:', formData);
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          type="text"
          name="username"
          value={values.username}
          onChange={handleChange}
          placeholder="用户名"
        />
        {errors.username && <p style={{ color: 'red' }}>{errors.username}</p>}
      </div>
      
      <div>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          placeholder="邮箱"
        />
        {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
      </div>
      
      <div>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          placeholder="密码"
        />
        {errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
      </div>
      
      <button type="submit">注册</button>
    </form>
  );
}

核心优势

  1. 代码复用:一个Hook可以用于项目中的所有表单,减少重复代码
  2. 集中管理:所有表单数据集中在一个对象中,便于处理和提交
  3. 灵活扩展:可以根据需要添加验证、异步提交等功能
  4. 类型安全:结合TypeScript使用时,可以提供完整的类型提示

最佳实践

💡 使用时的小建议:

  1. 命名规范:确保input的name属性与初始值的key完全对应
  2. 复杂表单:对于非常复杂的表单,可以考虑使用成熟的表单库(如Formik、React Hook Form)
  3. 性能优化:对于包含大量字段的表单,可以考虑使用useReducer替代useState来优化性能
  4. 异步验证:如果需要异步验证(如检查用户名是否已存在),可以在自定义Hook中添加相应的处理逻辑

总结

通过自定义Hook,我们可以将表单状态管理的逻辑封装起来,使组件代码更加简洁优雅。这种方式不仅提高了代码的复用性和可维护性,还让我们能够更好地专注于业务逻辑的实现。

下次遇到表单时,不妨试试这个方法吧~相信你会爱上这种简洁的表单管理方式!😉


🫶 今天的小技巧就分享到这里啦!如果你有更好的表单管理方法,欢迎在评论区交流哦~

告别重复造轮子!业务组件多场景复用实战指南

引言:当"一个组件多场景复用"遇上现实复杂性

组件「复用」最容易被误解——
老板说“这不就一样卡片嘛,多加几个字段”,
产品说“就换个皮嘛,还不是一个业务逻辑”,
但你知道:这个 “一样”,往往等于一地鸡毛。

一开始你想着打个补丁(条件判断加一个)
后来你补补又补——再补还是不行,最后别说“复用”,旧组件都坏了: 逻辑耦合、prop 爆炸,一不小心走上封装“巨型黑箱”的不归路。

img_v3_02sp_71112c1d-3d1d-40a8-b0d7-4001053f4c4g.jpg

这不是工作疏忽,而是设计方式出错了。

历经多个项目实战探索,你会发现:复用一个组件,就像在组装复杂拼图——清晰边界、拆分功能、组合而不耦合,才是关键。

这篇文章就带你用“配置驱动设计”、“复合组件模式”、“策略模式”、“渲染函数”的思维路径,拆开、再组建出真正易复用的业务组件模型。

一、配置驱动设计:参数化的艺术

适用场景:功能开关与样式主题的灵活切换

当组件的差异主要体现在功能组合、视觉主题和简单行为变化时,配置驱动就像给组件装上了"控制面板",通过调节参数即可实现不同形态。

graph TD
    A[业务组件入口] --> B{读取场景配置}
    B --> C[管理员场景]
    B --> D[用户场景]
    B --> E[移动端场景]
    
    C --> F[配置解析器]
    D --> F
    E --> F
    
    F --> G[功能开关]
    F --> H[样式主题]
    F --> I[行为策略]
    
    G --> J[渲染引擎]
    H --> J
    I --> J
    
    J --> K[管理员界面]
    J --> L[用户界面]
    J --> M[移动端界面]
    
    style C fill:#e1f5e1
    style D fill:#fff3e0
    style E fill:#e3f2fd

设计哲学:分离变与不变

配置驱动的核心思想是将变化的部分参数化,而不变的部分固定化。通过一个集中管理的配置对象,我们可以在不修改组件内部代码的情况下,实现多种场景的适配。

关键实现技巧

// 配置的层次化设计
const createSceneConfig = (baseConfig, overrideConfig) => ({
  // 第一层:基础功能配置
  features: mergeDeep(baseConfig.features, overrideConfig.features),
  
  // 第二层:UI表现配置  
  ui: {
    ...baseConfig.ui,
    ...overrideConfig.ui,
    // 主题系统支持动态切换
    theme: createTheme(overrideConfig.ui?.theme)
  },
  
  // 第三层:业务规则配置
  businessRules: applyBusinessRules(
    baseConfig.businessRules,
    overrideConfig.businessRules,
    context
  ),
  
  // 第四层:性能优化配置
  performance: {
    // 根据场景启用不同的优化策略
    lazyLoad: overrideConfig.isMobile,
    virtualScroll: overrideConfig.isLargeDataset,
    memoization: true
  }
});

优势与考量

优势:

  1. 高度可控:通过配置即可调整组件行为,无需修改组件内部代码
  2. 易于扩展:新增场景只需添加配置,符合开闭原则
  3. 配置集中管理:便于维护和统一调整

考量:

  1. 配置对象可能会变得臃肿,需要良好的文档
  2. 复杂逻辑场景可能不适合纯配置驱动

最佳实践建议

  1. 配置验证:使用Schema验证确保配置的正确性
  2. 默认值处理:提供合理的默认值,避免配置缺失导致错误
  3. 配置版本化:支持配置的热更新和版本回滚
  4. 性能优化:对配置对象进行记忆化,避免不必要的重新渲染

二、复合组件模式:乐高积木式的组装艺术

适用场景:UI结构差异显著的跨平台应用

当不同场景的界面布局和交互模式差异较大,但底层数据逻辑相同时,复合组件模式就像是提供了一盒乐高积木,让使用者可以自由组装出所需的结构。

graph TB
    subgraph "复合组件架构"
        A[容器组件<br/>BusinessContainer] --> B[上下文提供者]
        B --> C[场景上下文]
        
        C --> D[头部选择器]
        C --> E[内容选择器]
        C --> F[底部选择器]
        
        D --> G[管理员头部]
        D --> H[移动端头部]
        D --> I[用户头部]
        
        E --> J[详细视图]
        E --> K[简洁视图]
        E --> L[卡片视图]
        
        F --> M[操作栏]
        F --> N[分页器]
        F --> O[空状态]
    end
    
    subgraph "使用组合"
        P[管理员场景] --> Q[组合示例]
        Q --> R[容器]
        R --> S[管理员头部]
        R --> T[详细视图]
        R --> U[操作栏]
    end
    
    G -.-> S
    J -.-> T
    M -.-> U

设计哲学:关注点分离与自由组合

复合组件的核心优势在于将复杂的组件拆解为独立的、可复用的子组件,每个子组件都有明确的职责,同时通过共享的上下文进行通信。

进阶模式:插槽与扩展点

// 支持插槽的复合组件
const BusinessComponentWithSlots = ({ children, slots = {} }) => {
  const defaultSlots = {
    header: DefaultHeader,
    content: DefaultContent,
    footer: DefaultFooter,
    sidebar: DefaultSidebar,
    toolbar: DefaultToolbar,
  };
  
  const resolvedSlots = { ...defaultSlots, ...slots };
  
  return (
    <div className="business-layout">
      <div className="header-slot">
        <resolvedSlots.header />
      </div>
      
      <div className="main-content">
        <div className="sidebar-slot">
          <resolvedSlots.sidebar />
        </div>
        
        <div className="content-slot">
          {children || <resolvedSlots.content />}
        </div>
      </div>
      
      <div className="toolbar-slot">
        <resolvedSlots.toolbar />
      </div>
      
      <div className="footer-slot">
        <resolvedSlots.footer />
      </div>
    </div>
  );
};

// 灵活的使用方式
const CustomizedView = () => (
  <BusinessComponentWithSlots
    slots={{
      header: CustomHeader,
      sidebar: StatisticsSidebar,
      toolbar: QuickActionsToolbar,
    }}
  >
    <DashboardContent />
  </BusinessComponentWithSlots>
);

优势与考量

优势:

  1. 高度灵活:可以像搭积木一样组合组件
  2. 关注点分离:每个子组件职责单一
  3. 上下文共享:状态管理更加清晰

考量:

  1. 学习曲线相对较高
  2. 需要良好的文档说明可用子组件

最佳实践建议

  1. 明确的上下文契约:使用TypeScript定义清晰的上下文类型,确保子组件正确使用共享状态
  2. 默认子组件实现:为每个子组件提供默认实现,降低使用门槛
  3. 文档和示例:为每个子组件提供详细的文档和多种使用示例
  4. 性能优化:合理拆分上下文,避免不必要的子组件重新渲染

三、策略模式 + 自定义 Hook:逻辑解耦的智慧

适用场景:业务规则多变的复杂系统

当不同场景的业务逻辑和数据处理方式差异显著时,策略模式可以将这些差异封装到独立的策略对象中,使主组件保持简洁。

graph LR
    subgraph "策略管理器"
        A[策略选择器] --> B{场景判断}
        B --> C[管理员策略]
        B --> D[用户策略]
        B --> E[移动端策略]
        B --> F[预览策略]
    end
    
    subgraph "策略实现"
        C --> G[数据处理管道]
        C --> H[权限验证器]
        C --> I[操作执行器]
        
        D --> J[简化数据处理]
        D --> K[基础验证]
        D --> L[受限操作]
        
        E --> M[离线数据同步]
        E --> N[触摸手势处理]
        E --> O[网络状态感知]
    end
    
    subgraph "组件集成"
        P[业务组件] --> Q[策略Hook]
        Q --> A
        R[UI渲染层] --> S[策略API]
        S --> G
        S --> J
        S --> M
    end
    
    G -.-> S
    J -.-> S
    M -.-> S

设计哲学:算法族的自由替换

策略模式的精髓在于定义一系列可互换的算法,让它们可以独立于使用它们的客户端变化。结合React Hook,我们可以创建出既灵活又易于测试的业务逻辑单元。

策略工厂模式

// 策略工厂:动态创建和组合策略
const createStrategy = (scene, options) => {
  // 基础策略
  const baseStrategy = {
    data: options.data,
    format: (data) => data,
    validate: () => true,
    execute: () => {},
  };
  
  // 场景特定的策略增强
  const enhancements = {
    admin: {
      format: (data) => enrichAdminData(data),
      validate: (action) => checkAdminPermissions(action),
      execute: (action) => {
        logAdminAction(action);
        return performAdminAction(action);
      },
    },
    mobile: {
      format: (data) => optimizeForMobile(data),
      validate: (action) => checkNetworkStatus(),
      execute: async (action) => {
        if (isOffline()) {
          return queueAction(action);
        }
        return performOnlineAction(action);
      },
    },
  };
  
  // 策略组合:可以混合多个场景的特性
  return composeStrategies(
    baseStrategy,
    enhancements[scene],
    options.customStrategies
  );
};

// 策略组合器
const composeStrategies = (...strategies) => {
  return strategies.reduce((combined, current) => {
    return {
      ...combined,
      ...current,
      // 特殊的组合逻辑
      format: (data) => {
        const prevResult = combined.format(data);
        return current.format ? current.format(prevResult) : prevResult;
      },
    };
  });
};

优势与考量

优势:

  1. 逻辑与UI解耦:业务逻辑可以独立测试
  2. 策略可替换:轻松切换不同场景的逻辑
  3. 代码复用:相似逻辑可以在不同策略间共享

考量:

  1. 需要良好设计的策略接口
  2. 策略之间可能存在重复代码

最佳实践建议

  1. 策略接口标准化:定义统一的策略接口,确保不同策略之间可以无缝替换
  2. 策略工厂管理:使用策略工厂统一创建和管理策略实例
  3. 依赖注入:通过依赖注入解耦策略的具体实现,便于测试和维护
  4. 策略组合:支持策略的组合使用,提高代码复用性

四、Render Props + 组件注入:极致灵活性的选择

适用场景:需要高度定制化的开放平台

当你设计的组件需要被不同团队甚至不同公司使用,且他们可能有完全不同的UI需求和交互模式时,Render Props提供了最大程度的灵活性。

graph TD
    A[FlexibleComponent] --> B[渲染上下文]
    A --> C[数据状态]
    A --> D[操作方法]
    
    B --> E{渲染模式选择}
    
    E --> F[Render Props模式]
    E --> G[Children模式]
    E --> H[组件注入模式]
    
    F --> I[函数式渲染]
    G --> J[声明式渲染]
    H --> K[组件式渲染]
    
    I --> L[完全控制UI]
    J --> M[部分定制]
    K --> N[替换组件]
    
    subgraph "使用示例"
        O[后台系统] --> L
        P[营销页面] --> M
        Q[第三方集成] --> N
    end

设计哲学:控制反转与依赖注入

Render Props模式实现了控制反转——将UI渲染的控制权交给使用者,而组件本身只负责提供数据和状态管理。

多模式兼容设计

// 支持多种使用方式的灵活组件
const UniversalComponent = (props) => {
  const {
    // 模式1: render props
    render,
    
    // 模式2: 组件注入
    components = {},
    
    // 模式3: 插槽系统
    children,
    slots = {},
    
    // 模式4: 配置驱动
    config,
    
    // 共享的状态和数据
    data,
    state,
    actions,
    context,
  } = useUniversalComponentLogic(props);
  
  // 渲染策略:优先级顺序
  const renderContent = () => {
    // 最高优先级:render props
    if (typeof render === 'function') {
      return render({ data, state, actions, context });
    }
    
    // 第二优先级:组件注入
    if (components.Content) {
      const ContentComponent = components.Content;
      return <ContentComponent data={data} actions={actions} />;
    }
    
    // 第三优先级:插槽系统
    if (slots.content) {
      return slots.content;
    }
    
    // 第四优先级:默认渲染 + children
    return children || <DefaultContent data={data} config={config} />;
  };
  
  return (
    <UniversalContext.Provider value={context}>
      <div className={`universal-component ${config?.theme || 'default'}`}>
        {/* 头部渲染策略 */}
        {components.Header || slots.header ? (
          components.Header ? 
            <components.Header /> : slots.header
        ) : (
          <DefaultHeader />
        )}
        
        {/* 内容区域 */}
        <main className="content-area">
          {renderContent()}
        </main>
        
        {/* 底部区域 */}
        {config?.showFooter !== false && (
          components.Footer || slots.footer ? (
            components.Footer ?
              <components.Footer /> : slots.footer
          ) : (
            <DefaultFooter actions={actions} />
          )
        )}
      </div>
    </UniversalContext.Provider>
  );
};

// 多样的使用方式
const Example1 = () => (
  // 方式1: render props(最大灵活性)
  <UniversalComponent
    data={data}
    render={({ data, actions }) => (
      <CustomLayout>
        <CustomHeader title="自定义标题" />
        <CustomContent data={data} onAction={actions.handleAction} />
      </CustomLayout>
    )}
  />
);

const Example2 = () => (
  // 方式2: 组件注入(类型安全)
  <UniversalComponent
    data={data}
    components={{
      Header: CustomHeader,
      Content: CustomContent,
      Footer: CustomFooter,
    }}
  />
);

const Example3 = () => (
  // 方式3: 插槽系统(类似Vue的插槽)
  <UniversalComponent data={data}>
    <template slot="header">
      <CustomHeader />
    </template>
    <main>
      这里是自定义内容
    </main>
  </UniversalComponent>
);

优势与考量

优势:

  1. 极致灵活性:允许使用者完全控制组件渲染,适应高度定制化需求
  2. 逻辑与UI完全分离:组件专注于业务逻辑,UI渲染完全可自定义
  3. 渐进式采用:提供默认渲染实现,可按需逐步替换特定部分
  4. 组件复用性高:同一业务组件可在不同项目、技术栈中复用
  5. 类型安全性强:结合TypeScript可精确声明渲染函数类型

考量:

  1. 使用复杂度高:需要深入理解组件状态逻辑,对开发者要求较高
  2. 性能优化困难:渲染函数容易导致不必要的重新渲染
  3. 代码冗余风险:相似UI在不同场景中可能导致代码重复
  4. 调试复杂度增加:渲染逻辑分散,调试需要追踪多个调用链
  5. API复杂度高:为提供灵活性需暴露大量状态和方法

最佳实践建议

  1. 渲染函数记忆化:使用useCallback包装渲染函数,避免不必要的重新渲染
  2. 默认组件集:提供一套默认的组件实现,简化基础使用
  3. 类型安全声明:使用TypeScript精确声明渲染函数和组件注入的props类型
  4. 性能监控:监控渲染性能,避免因过度灵活导致的性能问题

五、决策流程图:如何选择适合的方案

面对多场景复用需求时,如何选择最合适的方案?下面的决策流程图可以帮助你做出明智的选择:

deepseek_mermaid_20251204_079e14.png

混合模式:现实项目的最佳实践

在实际的大型项目中,单一模式往往难以应对所有场景。聪明的做法是组合使用多种模式,发挥各自的优势:

// 混合模式示例:配置驱动 + 策略模式 + 复合组件
const HybridBusinessComponent = ({ scene, config, children }) => {
  // 1. 配置驱动:处理简单的差异
  const baseConfig = useMemo(() => 
    mergeConfigs(DEFAULT_CONFIG, SCENE_CONFIGS[scene], config)
  , [scene, config]);
  
  // 2. 策略模式:处理复杂的业务逻辑差异
  const strategy = useBusinessStrategy(scene, {
    data: baseConfig.data,
    features: baseConfig.features,
  });
  
  // 3. 复合组件:提供灵活的UI组合
  return (
    <BusinessContainer
      variant={scene}
      config={baseConfig}
      contextValue={strategy}
    >
      {baseConfig.customHeader ? (
        <BusinessContainer.Header>
          {baseConfig.customHeader}
        </BusinessContainer.Header>
      ) : null}
      
      <BusinessContainer.Content>
        {children || (
          <BusinessContent
            data={strategy.data}
            renderItem={baseConfig.renderItem}
            onAction={strategy.handleAction}
          />
        )}
      </BusinessContainer.Content>
      
      {baseConfig.showActions && (
        <BusinessContainer.Actions
          actions={strategy.getAvailableActions()}
          onAction={strategy.executeAction}
        />
      )}
    </BusinessContainer>
  );
};

总结:组件设计的平衡艺术

Balance-The-Principles-of-Art.jpg

多场景业务组件的设计本质上是一场平衡的艺术,需要在多个维度中找到最佳平衡点:

  1. 灵活性与易用性的平衡:既要足够灵活以适应各种场景,又要对使用者简单友好
  2. 一致性与特异性的平衡:保持核心体验一致,同时允许必要的场景定制
  3. 抽象程度与性能的平衡:过度抽象会增加复杂度,不足抽象会导致代码重复
  4. 当前需求与未来扩展的平衡:为未来变化留出空间,但不过度设计

一些原则

  1. 遵循开放封闭原则:对扩展开放,对修改封闭

  2. 保持单一职责:每个组件/模块只做一件事

  3. 提供明确接口:清晰的API和类型定义

  4. 支持渐进增强:从简单场景开始,逐步支持复杂场景

  5. 注重开发者体验:良好的文档和错误提示

最好的设计不是最复杂的设计,而是最适合团队和项目的设计。 从简单的配置驱动开始,随着场景复杂度的增加逐步引入更高级的模式,这种渐进式的演进往往能带来最健康的代码base。

最终,优秀的组件设计不仅让代码更易于维护和扩展,更重要的是提升团队的开发效率和协作体验。当新成员能够快速理解如何使用和扩展组件,当产品需求变化时组件能够灵活适应,当你自己几个月后回头看代码仍然清晰易懂——这就是成功的组件设计带来的真正价值。

高性能的懒加载与无限滚动实现

高性能的懒加载与无限滚动实现

🤔 为什么需要懒加载和无限滚动?

在现代前端开发中,我们经常需要处理大量的图片或列表数据。如果一次性加载所有内容,会导致:

  • 页面加载速度慢,用户等待时间长
  • 带宽浪费,加载了用户可能永远不会看到的内容
  • 内存占用过高,影响页面流畅度

懒加载(Lazy Loading)和无限滚动(Infinite Scroll)就是为了解决这些问题而生的。它们可以:

  • 只加载用户当前可见区域的内容
  • 滚动时动态加载新内容
  • 显著提升页面加载性能和用户体验

💡 Intersection Observer API:现代浏览器的解决方案

传统的实现方式是监听 scroll 事件,然后通过 getBoundingClientRect() 计算元素位置。这种方式存在性能问题:

  • scroll 事件触发频率高,容易导致页面卡顿
  • getBoundingClientRect() 会强制重排(reflow),影响性能

而 Intersection Observer API 是浏览器提供的原生 API,它可以:

  • 异步监听元素与视口的交叉状态
  • 避免频繁的 DOM 操作和重排
  • 提供更好的性能和更简洁的代码

🚀 基础实现:图片懒加载

1. 基础 HTML 结构

<!-- 使用 data-src 存储真实图片地址 -->
<img 
  class="lazy-image" 
  data-src="https://example.com/real-image.jpg" 
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200'%3E%3Crect width='200' height='200' fill='%23f0f0f0'/%3E%3C/svg%3E" 
  alt="示例图片"
>

2. JavaScript 实现

// 创建 Intersection Observer 实例
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    // 当元素进入视口时
    if (entry.isIntersecting) {
      const img = entry.target;
      // 将 data-src 赋值给 src
      img.src = img.dataset.src;
      // 加载完成后停止观察
      observer.unobserve(img);
      // 添加加载完成的动画类
      img.classList.add('loaded');
    }
  });
});

// 获取所有需要懒加载的图片
const lazyImages = document.querySelectorAll('.lazy-image');

// 观察每个图片元素
lazyImages.forEach(img => {
  observer.observe(img);
});

3. 基本 CSS 样式

.lazy-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
  transition: opacity 0.3s ease;
  opacity: 0.7;
}

.lazy-image.loaded {
  opacity: 1;
}

🎯 进阶实现:无限滚动列表

1. HTML 结构

<div class="infinite-scroll-container">
  <ul class="list-container" id="listContainer">
    <!-- 初始加载的列表项 -->
    <li>列表项 1</li>
    <li>列表项 2</li>
    <li>列表项 3</li>
    <!-- ... -->
  </ul>
  <!-- 加载指示器 -->
  <div class="loading-indicator" id="loadingIndicator">
    <div class="spinner"></div>
    <span>加载中...</span>
  </div>
</div>

2. JavaScript 实现

// 列表容器和加载指示器
const listContainer = document.getElementById('listContainer');
const loadingIndicator = document.getElementById('loadingIndicator');

// 模拟数据
let page = 1;
const pageSize = 10;
const totalItems = 100;

// 创建 Intersection Observer 实例,用于监听加载指示器
const observer = new IntersectionObserver((entries) => {
  const entry = entries[0];
  // 当加载指示器进入视口时,加载更多数据
  if (entry.isIntersecting && !isLoading) {
    loadMoreData();
  }
}, {
  // 配置选项:在加载指示器进入视口前 100px 就开始加载
  rootMargin: '0px 0px 100px 0px'
});

// 观察加载指示器
observer.observe(loadingIndicator);

// 加载状态
let isLoading = false;

// 加载更多数据的函数
async function loadMoreData() {
  if (isLoading) return;
  
  isLoading = true;
  loadingIndicator.style.display = 'flex';
  
  try {
    // 模拟 API 请求延迟
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    // 计算当前需要加载的数据范围
    const startIndex = (page - 1) * pageSize + 1;
    const endIndex = Math.min(page * pageSize, totalItems);
    
    // 创建新的列表项
    const newItems = [];
    for (let i = startIndex; i <= endIndex; i++) {
      const li = document.createElement('li');
      li.textContent = `列表项 ${i}`;
      newItems.push(li);
    }
    
    // 将新列表项添加到容器中
    listContainer.append(...newItems);
    
    // 增加页码
    page++;
    
    // 如果已经加载完所有数据,停止观察
    if (endIndex >= totalItems) {
      observer.unobserve(loadingIndicator);
      loadingIndicator.textContent = '已加载全部内容';
      loadingIndicator.style.display = 'block';
    }
  } catch (error) {
    console.error('加载数据失败:', error);
    loadingIndicator.textContent = '加载失败,请重试';
  } finally {
    isLoading = false;
  }
}

3. CSS 样式

.infinite-scroll-container {
  max-height: 600px;
  overflow-y: auto;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

.list-container {
  padding: 0;
  margin: 0;
  list-style: none;
}

.list-container li {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  transition: background-color 0.2s;
}

.list-container li:hover {
  background-color: #fafafa;
}

.loading-indicator {
  display: none;
  justify-content: center;
  align-items: center;
  padding: 20px;
  color: #666;
}

.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #f3f3f3;
  border-top: 2px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-right: 10px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

🎨 React 中使用 Intersection Observer

1. 自定义 Hook:useIntersectionObserver

import { useEffect, useRef, useState } from 'react';

function useIntersectionObserver(options = {}) {
  const ref = useRef(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);

    const currentRef = ref.current;
    if (currentRef) {
      observer.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [options]);

  return [ref, isIntersecting];
}

export default useIntersectionObserver;

2. 懒加载图片组件

import React from 'react';
import useIntersectionObserver from './useIntersectionObserver';

const LazyImage = ({ src, alt, placeholder, ...props }) => {
  const [ref, isIntersecting] = useIntersectionObserver({
    rootMargin: '50px 0px'
  });

  return (
    <img
      ref={ref}
      src={isIntersecting ? src : placeholder}
      alt={alt}
      onLoad={() => {
        if (isIntersecting) {
          // 图片加载完成后的处理
        }
      }}
      {...props}
    />
  );
};

export default LazyImage;

3. 无限滚动列表组件

import React, { useEffect, useState } from 'react';
import useIntersectionObserver from './useIntersectionObserver';

const InfiniteScrollList = () => {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  
  // 使用自定义 Hook 监听加载更多按钮
  const [loadMoreRef, isVisible] = useIntersectionObserver({
    rootMargin: '0px 0px 100px 0px'
  });

  // 加载数据的函数
  const loadData = async (currentPage) => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    
    try {
      // 模拟 API 请求
      const response = await fetch(`/api/items?page=${currentPage}&limit=10`);
      const newItems = await response.json();
      
      setItems(prevItems => [...prevItems, ...newItems]);
      setHasMore(newItems.length > 0);
      setPage(currentPage + 1);
    } catch (error) {
      console.error('加载数据失败:', error);
    } finally {
      setLoading(false);
    }
  };

  // 初始加载
  useEffect(() => {
    loadData(1);
  }, []);

  // 当加载更多按钮可见时,加载下一页
  useEffect(() => {
    if (isVisible) {
      loadData(page);
    }
  }, [isVisible, page]);

  return (
    <div className="infinite-scroll-list">
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item.name}</li>
        ))}
      </ul>
      
      {hasMore && (
        <div ref={loadMoreRef} className="loading-indicator">
          {loading ? '加载中...' : '滚动加载更多'}
        </div>
      )}
      
      {!hasMore && (
        <div className="no-more-data">
          没有更多数据了
        </div>
      )}
    </div>
  );
};

export default InfiniteScrollList;

⚠️ 注意事项

1. 浏览器兼容性

Intersection Observer API 在现代浏览器中得到广泛支持,但在一些旧浏览器中可能不支持。你可以使用 polyfill 来解决这个问题:

npm install intersection-observer

然后在代码中引入:

import 'intersection-observer';

2. 可访问性(Accessibility)

懒加载和无限滚动可能会影响页面的可访问性:

  • 屏幕阅读器用户可能不知道有新内容加载
  • 键盘用户可能难以导航到新加载的内容

解决方案:

  • 使用 ARIA 标签(如 aria-live)通知屏幕阅读器
  • 提供分页导航作为替代方案
  • 确保新加载的内容可以通过键盘访问

3. 性能优化

  • 批量加载:不要每次只加载一个项目,而是批量加载多个项目
  • 节流和防抖:虽然 Intersection Observer 已经优化了性能,但在处理大量元素时仍需注意
  • 清理:不再需要观察的元素要及时停止观察,避免内存泄漏

4. 图片懒加载的额外考虑

  • 占位符:使用合适的占位符,避免页面布局跳动
  • 加载失败处理:提供图片加载失败的回退方案
  • SEO 影响:确保搜索引擎能够正确索引懒加载的图片

📝 总结

Intersection Observer API 是实现高性能懒加载和无限滚动的现代解决方案。它的优点包括:

  1. 性能优越:异步观察,避免了频繁的 DOM 操作和重排
  2. 使用简单:API 设计简洁,易于理解和使用
  3. 功能强大:支持多种配置选项,满足不同需求
  4. 浏览器原生支持:无需依赖第三方库

通过合理使用懒加载和无限滚动,我们可以显著提升页面性能和用户体验。无论是原生 JavaScript 还是 React、Vue 等框架,都可以轻松实现这些功能。

希望这个小技巧对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言讨论 🤗


相关资源:

标签: #前端性能优化 #IntersectionObserver #懒加载 #无限滚动 #React

React useState 解读

🧠 01|useState 本质:React 给你分配“状态槽位”

useState 会在当前组件的 Fiber 上分配一个 Hook 节点,用来存储状态和更新队列。每次渲染中,React 按固定顺序读取这些节点,因此你总能拿到正确的最新 state。

核心关键点如下👇

1. 函数组件每次渲染都是“重新执行”

组件里写的:

const [count, setCount] = useState(0);

每次渲染都会执行一次。但 state 不存放在函数里,而存在 Fiber 结构里。

2. 每个组件 Fiber 都维护一个 hooks 链表

可以想象成这样:

Fiber
 └─ memoizedState
       ├─ HookNode1  (对应第一个 useState)
       ├─ HookNode2  (对应第二个 useState)
       └─ ...

React 每次调用 Hook 时,会沿着链表走一个节点。

3. setState 只是把更新“入队”

每个 HookNode 都有个 update queue:

queue: [update1, update2, ...]

下一次渲染前,React 会把这些更新依次应用,然后再返回最新值。

4. 不能在条件中使用hook

因为每次渲染 React 都是按顺序“走链表”:

第一次:

useState A → 节点1
useState B → 节点2

第二次如果你写了条件分支导致 B 不执行:

useState A → 节点1

React 就会把节点2 的状态错读为节点1,状态直接乱套。

Hook 顺序必须固定才能保证取到正确的 state。

🔍 02|useState 的参数与返回值(TypeScript 思维)

function useState<S>(
  initialState: S | (() => S)
): [S, (update: S | ((prev: S) => S)) => void]
  • initialState 支持懒初始化函数(只在首次执行)
  • setState 支持两种更新方式:直接值、更新函数

① 直接值

const [count, setCount] = useState(0);
  • 类型是你传入的值类型,比如 0 → number,"abc" → string。
  • 每次组件重新渲染时,这个值不会再被计算一次。
  • 如果初始值计算开销大,每次渲染直接写计算式可能性能不佳。

② 懒初始化函数(lazy initializer)

const [data, setData] = useState(() => expensiveComputation());
  • 函数只在第一次渲染调用,返回值作为初始 state。
  • 好处:延迟计算开销,避免每次渲染都执行复杂逻辑。

🧩 03|state 的特点

自动批处理

React 会自动批处理(batch),多个 setState → 只渲染一次(视版本和环境而定)

替换非合并

useState 是“替换”,不像 class setState 那样“合并”

对象必须手动展开:

setForm(prev => ({ ...prev, name: 'xx' }));

函数式更新可以避免读到旧值

setCount(c => c + 1);

📦 04|mini-hooks

let currentFiber = null;

// 渲染入口
function render(component) {
  currentFiber = component.fiber;
  currentFiber.hookIndex = 0;

  if (!currentFiber.hooks) {
    currentFiber.hooks = [];
    currentFiber.isMount = true;
  } else {
    currentFiber.isMount = false;
  }

  const result = component();
  console.log("Rendered:", result);
  return result;
}

// 创建组件(带 fiber)
function createComponent(fn) {
  fn.fiber = { hooks: null, hookIndex: 0 };
  return fn;
}

function useState(initialState) {
  const fiber = currentFiber;
  const hookIndex = fiber.hookIndex++;

  if (fiber.isMount) {
    const hook = {
      memoizedState:
        typeof initialState === "function" ? initialState() : initialState,
      queue: [],
    };
    fiber.hooks.push(hook);
  }

  const hook = fiber.hooks[hookIndex];

  hook.queue.forEach((update) => {
    const prev = hook.memoizedState;
    hook.memoizedState =
      typeof update === "function" ? update(prev) : update;
  });

  hook.queue = [];

  const setState = (action) => {
    hook.queue.push(action);
    rerender();
  };

  return [hook.memoizedState, setState];
}

// 调度器
let rerenderScheduled = false;
function rerender() {
  if (!rerenderScheduled) {
    rerenderScheduled = true;
    setTimeout(() => {
      rerenderScheduled = false;
      render(App);
    }, 0);
  }
}

const App = createComponent(function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("hello");

  return {
    count,
    text,
    inc: () => setCount((c) => c + 1),
    changeText: () => setText((t) => t + "!"),
  };
});

// 首次渲染
const app = render(App);

// 调用更新
app.inc();
app.inc();
app.changeText();

别等了!用 Vue 3 让 AI 边想边说,字字蹦到你脸上

🚀 Vue 3 流式输出实战:从零开始玩转LLM对话!

你以为AI对话边生成边输出是魔法?不!这是前端工程师的浪漫操作!✨


🌟 一、整体功能:让AI在你眼前“活”起来

想象一下:你输入"讲一个喜洋洋和灰太狼的故事,200字",然后每个字都像打字机一样蹦出来,而不是等整段答案蹦出来!这就是我们今天要实现的流式输出。用Vue 3 + DeepSeek API,打造一个能实时吐字的AI小助手,让科技感拉满!🎯

废话不多说,看效果:

QQ2025128-02424.gif


💻 二、项目初始化:Vite,前端脚手架的扛把子!

"Vite 是最近最优秀的前端脚手架" —— 这句话我反复念了10遍才敢信!😱

  1. npm init vite@latest

用最新版的 Vite 脚手架初始化一个新项目!执行这行命令后,终端会问你几个问题:

Project name: » my-ai-chat      ← 你起的项目名
Select a framework: » Vue       ← 选 Vue
Select a variant: » JavaScript  ← 用 JS(不是 TS// 选 **Vue 3** + **JavaScript**(别选TypeScript,新手友好!)

2. cd your-project + npm install + npm run dev

切换到刚刚创建的项目文件夹+下载项目需要的所有依赖(第三方库)+启动开发服务器在浏览器里看到网页

  1. 项目结构瞬间搭建好:src/ 是你的主战场!📁

所有组件、逻辑、样式都在这里写

my-ai-chat/
├── src/
│   ├── App.vue        ← 根组件(整个应用的入口)
│   └── main.js        ← 启动文件(把 App.vue 挂到 HTML 上)
├── index.html         ← 单页应用的 HTML 入口
├── vite.config.js     ← Vite 配置文件(比如代理设置)
└── package.json       ← 项目信息 + 依赖列表 + 脚本命令

💡 为什么Vite这么香?
传统脚手架要编译几秒,Vite直接秒开!就像从自行车升级到火箭🚀


📝 三、App.vue:三明治结构,前端的"灵魂"!

<script setup> <!-- 逻辑层(核心!) -->
// ja代码
</script>

<template> <!-- 视图层(UI) -->
    // html代码
  <div>输入框+按钮</div>
</template>

<style scoped> <!-- 样式层(美颜) -->
// css代码
</style>

三明治精髓
你只管写业务逻辑(script),Vue自动帮你管DOM(template)!
以前要写document.getElementById的苦日子,一去不复返!😭


💡 四、响应式数据:从DOM操作到"写代码像写日记"的革命

1、过去:手动操作 DOM 的“机械时代”:

在没有 Vue/React 的年代,想实现一个“点击按钮数字+1”的功能,你得这样写:

✅ 原生 JavaScript(HTML + JS 分离)

html

<!-- index.html -->
<div>
  <p id="count">0</p>
  <button id="btn">+1</button>
</div>

<script>
let count = 0; // 数据

// 1. 找到按钮和显示区域
const btn = document.getElementById('btn');
const countEl = document.getElementById('count');

// 2. 监听事件
btn.addEventListener('click', () => {
  // 3. 修改数据
  count++;
  // 4. 手动同步到 DOM
  countEl.innerText = count;
});
</script>

问题在哪?

  • 要先找 DOM 元素(getElementById
  • 数据变了,必须手动更新 UI
  • 如果有多个地方显示 count,你得改 N 次!
  • 代码像“操作手册”:先做A,再做B,最后做C……繁琐且毫无乐趣!

2、现在:Vue 3 的“响应式浪漫时代”:

在 Vue 3 中,同样的功能,只需关注数据本身,UI 自动跟着变!

✅ Vue 3 示例(使用 <script setup>

vue

<!-- App.vue -->
<script setup>
import { ref } from 'vue'

// 1. 定义响应式数据
const count = ref(0)

// 2. 定义业务逻辑(只关心数据!)
function increment() {
  count.value++ // 注意:要加 .value!
}
</script>

<template>
  <!-- 3. 模板直接消费数据 -->
  <div>
    <p>{{ count }}</p>          <!-- 显示数据 -->
    <button @click="increment">+1</button> <!-- 绑定事件 -->
  </div>
</template>

神奇之处:

  • 只写了 count.value++ ,没碰任何 DOM!
  • 页面上 {{ count }} 自动更新
  • 即使有 10 个地方用了 count,也全部同步,零额外代码

💖 这就是 “声明式编程” vs “命令式编程” 的魅力:

  • 旧时代: “你要怎么做” (步骤清单)
  • 新时代: “你想要什么” (描述状态)

🔥 五、对代码的深度解读:流式响应处理(重点!)

1️⃣ 请求三件套:向 LLM 发起“召唤”

js

const endpoint = '/api/chat/completions'
const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
const body = { 
  model: 'deepseek-chat', 
  stream: true, 
  messages: [{ role: 'user', content: question.value }] 
}
🔑 关键点解释:
  1. /api/chat/completions

    • 这不是直接调 DeepSeek 官方 API!
    • 而是通过 Vite 开发服务器代理(在 vite.config.js 中配置)。
    • 为什么?→ 绕过浏览器 CORS 限制(后面详述)。
  2. stream: true

    • 告诉 LLM:“请一个字一个字地吐出来,别等全部生成完!”

    • 服务器会以 SSE(Server-Sent Events)  格式返回数据:

      text
      
      data: {"choices": [{"delta": {"content": "喜"}}]}
      data: {"choices": [{"delta": {"content": "羊"}}]}
      data: [DONE]
      
  3. VITE_DEEPSEEK_API_KEY

    • Vite 只会暴露以 VITE_ 开头的环境变量到前端。
    • ⚠️ 注意:生产环境中绝不能直接在前端用 API Key!
      (这里仅用于开发,真实项目应由后端代理)
  4. Content-Type

    • 这是 HTTP 请求头(Headers)中的一个字段,用于声明请求体(body)的数据格式

    • DeepSeek、OpenAI、Anthropic 等主流 LLM API 都遵循 OpenAI 兼容协议

    • 它们只接受 JSON 格式的请求体

    • 如果你不声明 Content-Type: application/json,服务器可能:

      • 拒绝请求(400 Bad Request)
      • 无法正确解析你的 body(当成纯文本处理)

2️⃣ 流式响应处理:核心循环逻辑

📌 核心代码(关键部分):
let buffer = '' // !核心变量!
while (!done) {
  const { value, done: doneReading } = await reader.read()
  done = doneReading
  const chunkValue = buffer + decoder.decode(value) // 拼接缓冲
  buffer = ''
  
  // 按行分割
  const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
  
  for (const line of lines) {
    const incoming = line.slice(6) // 去掉 "data: "
    if (incoming === '[DONE]') break
    
    try {
      const data = JSON.parse(incoming)
      content.value += data.choices[0].delta.content
    } catch {
      buffer += `data: ${incoming}` // !关键!存回缓冲
    }
  }
}
📦 初始化关键变量
js

let buffer = ''        // 👑 灵魂变量:暂存“没说完”的数据
let done = false       // 标记流是否结束
const reader = response.body.getReader()
const decoder = new TextDecoder() // 把二进制转成字符串

💡 response.body 是一个 ReadableStream,只能顺序读取。


🔁 主循环:一块一块读数据
js

while (!done) {
  const { value, done: doneReading } = await reader.read()
  done = doneReading
  • reader.read() 返回一个 Promise,解析后得到:

    • value: 当前 chunk(Uint8Array 二进制数据)
    • done: 是否已读完(最后一个 chunk 后为 true)

🧩 第一步:拼接缓冲区 —— buffer 登场!
js

const chunkValue = buffer + decoder.decode(value)
buffer = ''
  • 把 上一轮没解析完的数据(buffer)  + 当前新 chunk 拼成完整字符串。
  • 然后清空 buffer(准备接收下一轮“残片”)。

✅ 举例:

  • 上次剩:'data: {"choices": [{"delta": {"content": "喜羊羊'
  • 本次收到:'和灰太狼"}}]}\n'
  • 拼接后:'data: {"choices": [{"delta": {"content": "喜羊羊和灰太狼"}}]}\n' → 完整!

✂️ 第二步:按行分割,只处理有效行
js

const lines = chunkValue.split('\n')
  .filter(line => line.startsWith('data: '))
  • SSE 协议以 \n 分隔每一行。
  • 只保留以 data:  开头的行(忽略空行或其他控制信息)。

🧪 第三步:逐行解析 JSON
js

for (const line of lines) {
  const incoming = line.slice(6) // 去掉 "data: "
  
  if (incoming === '[DONE]') {
    done = true
    break
  }
  
  try {
    const data = JSON.parse(incoming) // 反序列化,将JSON字符串转化为真正的JavaScript对象
    // 若是一个完整的JSON字符串,这里就可以反序列化成功,否则进入catch
    const delta = data.choices[0].delta.content
    if (delta) content.value += delta
  } catch (err) {
    // ❗ 解析失败 → 说明这行不完整!
    buffer += `data: ${incoming}`
  }
}
🎯 关键细节:
  1. line.slice(6)

    • 去掉 data:  前缀,得到纯 JSON 字符串。
  2. [DONE] 终止信号

    • LLM 流结束时会发送 data: [DONE],此时退出循环。
  3. try...catch + buffer 回写

    • 如果 JSON.parse 失败(比如 incoming = '{"choices": [{"delta": {"conten'),说明这一行被截断了。
    • 于是把它原样加回 buffer(注意加上 data:  前缀),等下次 chunk 到达再拼!

💡 这就是 buffer 的魔法:永不丢弃任何数据片段,直到它变成合法 JSON!


📊 streamdonebuffer 的角色总览

变量 作用 通俗比喻
stream 是否开启流式 开/关"打字机模式"
done 流是否结束 "AI说完了没?"
buffer 暂存未完成的JSON "等你把话说完再听"

🎯 关键逻辑

  • 每次读取新数据 → 拼到 buffer
  • \n 分割 → 逐行解析
  • 解析失败 → 丢回 buffer(等待下次拼接)

🔄 六、流式 vs 非流式:体验大不同!

类型 体验 代码 适用场景
流式 每个字蹦出来(像真人打字) stream: true 需要实时反馈的场景(AI对话)
非流式 等整段答案出来 stream: false 简单查询(如天气API)

💡 为什么流式更香?
用户体验:等待3秒 vs 等3秒+实时看到"正在生成..."!
"思考中...""喜羊羊和灰太狼去野餐...""结果被红太狼发现了!"
体验感直接拉满!🎯


⚠️ 七、CORS 错误:浏览器的"安全门卫"在作妖!

错误提示
"浏览器阻止了从 http://localhost:5173 请求 api.deepseek.com"
🔥 原因:浏览器安全策略(CORS),阻止直接跨域请求!

❌ 为什么不能直接写?

fetch('https://api.deepseek.com/chat/completions') // ❌ 被浏览器拦了!

✅ 正确方案:Vite 代理!

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.deepseek.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

🌈 代理原理

  1. 前端请求 /api/chat/completions
  2. Vite 代理服务器转成 https://api.deepseek.com/chat/completions
  3. 浏览器只看到 localhost,不会触发CORS!
    安全 + 体验双杀! 🔥

💎 八、总结:从"写代码"到"写故事"的蜕变

项目 以前 现在
项目初始化 配置Webpack、Babel... npm init vite → 3秒搞定!
响应式 手动操作DOM ref + v-model → 业务优先!
流式输出 无法实现 buffer + ReadableStream → 字字清晰!
CORS 搞不定 Vite代理 → 一行配置搞定!

终极感悟
Vue 3 不是框架,是前端工程师的浪漫——
你只管写"我想让AI说'喜羊羊和灰太狼'...",
Vue默默帮你把"打字机效果"实现得丝滑到哭!
代码写得像写诗,体验做得像魔术! 🌟

一文详解 Taro / 小程序 IntersectionObserver 参数

只要理解“两个矩形的重叠区域”这件事,IntersectionObserver 完全不难

IntersectionObserver 的本质是观察 两个区域是否相交,当相交区域产生或变化时,就会触发回调

relativeTo(string selector, Object margins) 参数解析

官方解释:developers.weixin.qq.com/miniprogram…

参数中的 margins 解释不是很详细,个人理解图示如下:

正值为向外扩张边界,负值为向内收缩边界。

收缩.png

调用方式示例:

observer.relativeToViewport({
  top: -50,
  bottom: 100,
});

observe(string targetSelector, function callback) 中的回调函数参数解析

官方解释:developers.weixin.qq.com/miniprogram…

function callback 中的 intersectionRectboundingClientRectrelativeRect 参数解释:

区域 说明
目标区域 boundingClientRect 你要观察的元素的位置与大小
参照区域 relativeRect 通常是视口(屏幕可见部分),也可以是自定义容器
相交区域 intersectionRect 目标区域与参照区域的重叠部分

每个 rect 中的top/bottom/left/right 参数解释:

首先理解这代表一个坐标,坐标系知识(理解 top/bottom 很关键),以屏幕左上角为原点,x 向右递增,y 向下递增

个人理解如图所示:

image.png

易混淆重点:margins 的 top ≠ 回调里的 top

名称 出现位置 表示的含义 简单理解
margins 中的 top/bottom/left/right .relativeToViewport({ ... }) 参照区域边界的扩张或缩小 扩大/缩小观察范围
回调中的 top/bottom/left/right observe(..., callback(res)) 某个矩形相对屏幕的坐标位置 当前元素在哪里

在可视化区域中(也就是第一象限正象限)

bottom = top + height

right = left + width

示例解析:

返回值 含义
top: -20 元素顶部在屏幕上边缘 上方 20px(不可见部分)
bottom: -20 元素底部在屏幕上边缘 下方 20px

thresholds(阈值)是什么?

设置观察触发规则:

new IntersectionObserver(callback, {
  thresholds: [0, 0.5, 1]
})
threshold 意味
0 刚相交/离开 触发
0.5 至少一半在参照区域才触发
1 完全进入参照区域才触发

举个例子 thresholds 为 0.8 表示目标区域和参照区域的相交部分由0.79999 -> 0.8 或者由 0.80001 -> 0.8 时触发回调。

也可以根据微信小程序文档 developers.weixin.qq.com/miniprogram… 下载demo自己修改参数打印res,就可以更生动的体会。

构建异步任务队列:高效批量化获取淘宝关键词搜索结果的实践

在电商数据分析、竞品调研或商品监控等场景中,批量获取淘宝关键词搜索结果是高频需求。但传统同步请求方式存在效率低、易阻塞、容错性差等问题 —— 当关键词数量达到数百甚至数千个时,同步请求会因网络延迟、接口限流等因素导致整体耗时成倍增加,且单个请求失败可能中断整个流程。

本文将结合 Python 的异步编程和任务队列技术,构建一套高效、可扩展的淘宝关键词搜索结果批量获取系统,解决同步方式的痛点,实现高并发、高可用的批量数据采集。

一、技术选型与核心思路

1. 核心技术栈

  • 异步框架aiohttp(异步 HTTP 客户端,支持高并发请求)
  • 任务队列asyncio.Queue(轻量级异步队列,实现任务解耦与调度)
  • 数据解析parsel(高效解析 HTML/JSON,替代 BeautifulSoup)
  • 反反爬辅助fake-useragent(生成随机 User-Agent)、asyncio.Semaphore(控制并发量,避免触发限流)

2. 核心思路

  1. 构建异步任务队列,将待搜索的关键词批量入队;
  2. 启动多个异步消费者协程,从队列中取出关键词并发起异步 HTTP 请求;
  3. 对返回结果进行解析,提取商品核心信息(标题、价格、销量、店铺名等);
  4. 统一处理异常(网络超时、接口限流、解析失败等),保证队列消费不中断;
  5. 将解析后的结果落地(本文以本地 JSON 文件为例,可扩展至数据库)。

二、完整实现代码

1. 环境准备

首先安装依赖包:

pip install aiohttp parsel fake-useragent python-dotenv

2. 核心代码实现

import asyncio
import json
import time
from typing import List, Dict, Optional
from fake_useragent import UserAgent
import aiohttp
from parsel import Selector
from dotenv import load_dotenv
import os

# 加载环境变量(可选,用于配置敏感信息)
load_dotenv()

class TaobaoSearchCrawler:
    def __init__(self, concurrency: int = 10, timeout: int = 30):
        """
        初始化淘宝搜索爬虫
        :param concurrency: 最大并发数(避免触发淘宝限流)
        :param timeout: 请求超时时间
        """
        self.concurrency = concurrency
        self.timeout = timeout
        self.semaphore = asyncio.Semaphore(concurrency)  # 并发控制信号量
        self.user_agent = UserAgent()
        # 淘宝搜索接口(需注意:淘宝网页版可能有反爬,建议使用合规的开放平台接口)
        self.search_url = "https://s.taobao.com/search?q={}&imgfile=&commend=all&ssid=s5-e&search_type=item&sourceId=tb.index&spm=a21bo.jianhua.201856-taobao-item.1&ie=utf8&initiative_id=tbindexz_20170306"
        # 结果存储列表
        self.results: List[Dict] = []
        # 失败任务记录
        self.failed_tasks: List[Dict] = []

    async def fetch_html(self, session: aiohttp.ClientSession, keyword: str) -> Optional[str]:
        """
        异步获取搜索结果页面HTML
        :param session: aiohttp会话对象
        :param keyword: 搜索关键词
        :return: 页面HTML字符串,失败返回None
        """
        headers = {
            "User-Agent": self.user_agent.random,
            "Referer": "https://www.taobao.com/",
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Accept-Encoding": "gzip, deflate, br"
        }
        url = self.search_url.format(keyword)
        try:
            async with self.semaphore:  # 控制并发
                async with session.get(
                    url=url,
                    headers=headers,
                    timeout=aiohttp.ClientTimeout(total=self.timeout),
                    # 可选:添加代理
                    # proxy=os.getenv("PROXY_URL")
                ) as response:
                    if response.status == 200:
                        html = await response.text()
                        return html
                    else:
                        self.failed_tasks.append({
                            "keyword": keyword,
                            "reason": f"HTTP状态码异常: {response.status}"
                        })
                        return None
        except Exception as e:
            self.failed_tasks.append({
                "keyword": keyword,
                "reason": f"请求异常: {str(e)}"
            })
            return None

    def parse_html(self, html: str, keyword: str) -> List[Dict]:
        """
        解析搜索结果HTML,提取商品信息
        :param html: 页面HTML
        :param keyword: 搜索关键词
        :return: 商品信息列表
        """
        selector = Selector(text=html)
        items = []
        # 淘宝搜索结果商品节点(需根据实际页面结构调整)
        product_nodes = selector.css(".item J_MouserOnverReq  ")
        for node in product_nodes:
            try:
                item = {
                    "keyword": keyword,
                    "title": node.css(".J_ClickStat::attr(title)").extract_first() or "",
                    "price": node.css(".price J_price::text").extract_first() or "",
                    "sales": node.css(".deal-cnt::text").extract_first() or "0",
                    "shop_name": node.css(".shopname J_MouseververReq::text").extract_first() or "",
                    "item_url": node.css(".J_ClickStat::attr(href)").extract_first() or "",
                    "image_url": node.css(".J_ItemImg::attr(src)").extract_first() or ""
                }
                # 清洗数据
                item["title"] = item["title"].strip()
                item["price"] = item["price"].strip().replace("¥", "")
                items.append(item)
            except Exception as e:
                continue
        return items

    async def consumer(self, queue: asyncio.Queue, session: aiohttp.ClientSession):
        """
        队列消费者:从队列取关键词,执行请求和解析
        :param queue: 异步队列
        :param session: aiohttp会话
        """
        while not queue.empty():
            keyword = await queue.get()
            html = await self.fetch_html(session, keyword)
            if html:
                items = self.parse_html(html, keyword)
                self.results.extend(items)
            queue.task_done()  # 标记任务完成

    async def run(self, keywords: List[str]):
        """
        启动爬虫主流程
        :param keywords: 待搜索关键词列表
        """
        # 1. 初始化任务队列
        queue = asyncio.Queue()
        for keyword in keywords:
            await queue.put(keyword)

        # 2. 创建aiohttp会话(复用连接,提升效率)
        async with aiohttp.ClientSession() as session:
            # 3. 创建消费者任务
            tasks = [asyncio.create_task(self.consumer(queue, session)) for _ in range(self.concurrency)]
            # 4. 等待所有任务完成
            await queue.join()
            # 5. 取消未完成的任务(防止内存泄漏)
            for task in tasks:
                task.cancel()

    def save_results(self, save_path: str = "taobao_search_results.json"):
        """
        保存结果到JSON文件
        :param save_path: 保存路径
        """
        with open(save_path, "w", encoding="utf-8") as f:
            json.dump({
                "total_success": len(self.results),
                "total_failed": len(self.failed_tasks),
                "results": self.results,
                "failed_tasks": self.failed_tasks
            }, f, ensure_ascii=False, indent=4)
        print(f"结果已保存至 {save_path}")
        print(f"成功解析 {len(self.results)} 条商品数据,失败 {len(self.failed_tasks)} 个关键词")

if __name__ == "__main__":
    # 待搜索的关键词列表
    KEYWORDS = [
        "Python编程",
        "异步任务队列",
        "淘宝数据分析",
        "电商爬虫",
        "aiohttp实战"
        # 可扩展至数千个关键词
    ]

    # 初始化爬虫
    crawler = TaobaoSearchCrawler(concurrency=10, timeout=30)

    # 记录开始时间
    start_time = time.time()

    # 运行异步爬虫
    asyncio.run(crawler.run(KEYWORDS))

    # 保存结果
    crawler.save_results()

    # 输出耗时
    end_time = time.time()
    print(f"总耗时: {end_time - start_time:.2f} 秒")

三、关键技术解析

1. 异步任务队列(asyncio.Queue)

  • 解耦生产与消费:关键词作为任务被 “生产者” 放入队列,“消费者” 协程从队列取任务执行,实现任务调度与执行分离;
  • 阻塞与唤醒:队列空时消费者自动阻塞,新任务入队时唤醒,避免无效轮询;
  • 任务完成确认queue.task_done() 标记任务完成,queue.join() 等待所有任务完成,保证流程完整性。

2. 并发控制(asyncio.Semaphore)

淘宝对高频请求有限流机制,通过Semaphore限制最大并发数(本文设为 10),避免因请求过于密集触发反爬或 IP 封禁。

3. 异步 HTTP 请求(aiohttp)

  • 连接复用:通过aiohttp.ClientSession复用 TCP 连接,减少握手开销;
  • 超时控制:设置ClientTimeout避免单个请求阻塞整个流程;
  • 异常捕获:对网络超时、状态码异常等情况单独处理,保证单个请求失败不影响整体队列消费。

4. 数据解析(parsel)

parsel基于 lxml 实现,支持 XPath/CSS 选择器,解析效率远高于 BeautifulSoup,且语法更简洁。需注意:淘宝页面结构可能动态变化,需定期调整选择器规则。

四、优化与扩展建议

1. 反反爬优化

  • 代理池集成:添加动态代理池(如使用aiohttp-socks支持 SOCKS 代理),避免单 IP 请求过于频繁;
  • Cookie 持久化:登录淘宝后持久化 Cookie,提升请求成功率;
  • 请求间隔随机化:在fetch_html中添加随机延迟(如 0.5-2 秒),模拟人工操作。

2. 任务队列扩展

  • 分布式队列:若关键词数量超 10 万级,可替换为Celery + Redis分布式任务队列,支持多机器分布式消费;
  • 任务重试:对失败的任务(如failed_tasks)实现自动重试机制,提升成功率。

3. 数据落地优化

  • 批量入库:将解析后的商品数据批量插入 MySQL/Redis/MongoDB,避免单条插入的 IO 开销;
  • 增量更新:记录已爬取的关键词和商品,避免重复采集。

4. 监控与告警

  • 添加日志模块(如logging)记录请求 / 解析日志;
  • 监控失败率,当失败率超过阈值时触发邮件 / 钉钉告警。

五、注意事项

  1. 合规性:淘宝数据受《电子商务法》保护,爬虫需遵守平台robots.txt协议,仅用于合法的数据分析,禁止商用或恶意采集;
  2. 反爬应对:淘宝会动态更新反爬策略(如验证码、JS 加密),本文仅演示基础思路,生产环境需结合实际反爬机制调整;
  3. 性能测试:建议先通过少量关键词测试并发数,找到 “效率 - 稳定性” 平衡点(如 10-20 并发较合适)。

六、总结

本文通过异步任务队列 + 高并发异步请求的组合,解决了传统同步方式批量获取淘宝搜索结果的效率问题。核心优势在于:

  • 高并发:异步 IO 大幅提升请求效率,数千个关键词的采集耗时从小时级降至分钟级;
  • 高可用:异常隔离、并发控制保证流程不中断,失败任务可追溯;
  • 易扩展:队列架构支持分布式、重试、监控等扩展能力,适配不同规模的采集需求。

该思路不仅适用于淘宝搜索结果采集,也可迁移至京东、拼多多等电商平台,或其他需要批量异步请求的场景(如 API 批量调用、网页批量抓取)。

手写 new 操作符和 instanceof:深入理解 JavaScript 对象创建与原型链检测

引言

在JavaScript中,new操作符和instanceof操作符是面向对象编程的核心概念。理解它们的底层原理不仅能帮助我们更好地使用JavaScript,还能在面试中展现出更扎实的基础知识。

你是否曾好奇:

  • 当我们使用new创建对象时,背后到底发生了什么?
  • instanceof是如何检测对象类型的?
  • 原型链在对象创建和类型检测中扮演了什么角色?

通过手写实现这两个操作符, 我们将深入理解JavaScript的对象创建机制和原型链检测原理。

一、new操作符的深入理解

1.1 new操作符的作用

new操作符用于创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

// 基本用法
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHello = function () {
  console.log(`Hello, I'm ${this.name}, ${this.age} years old.`);
};

const person = new Person("Alice", 25);
console.log(person.name); // Alice
person.sayHello(); // Hello, I'm Alice, 25 years old.
1.2 new操作符的执行过程

当我们执行new Constructor(...args)时, JavaScript引擎会执行以下步骤:

  1. 创建一个新对象: 创建一个空的简单JavaScript对象(即{})
  2. 设置原型链: 将新对象的[[Prototype]](即__proto__)指向构造函数的prototype属性
  3. 绑定this值: 将构造函数中的this绑定到新创建的对象
  4. 执行构造函数: 执行构造函数中的代码(为新对象添加属性)
  5. 返回结果:
  • 如果构造函数返回一个对象, 则返回这个对象
  • 否则返回新创建的对象

二、手写new操作符

2.1 基础实现
/**
 * 手写 new 操作符
 * @param {Function} Constructor 构造函数
 * @param {...any} args 构造函数参数
 * @returns {Object} 新创建的对象
 */
function myNew(Constructor, ...args) {
  // 1. 创建一个新对象,继承构造函数的原型
  const obj = Object.create(Constructor.prototype);

  // 2. 执行构造函数,绑定 this 到新对象
  const result = Constructor.apply(obj, args);

  // 3. 如果构造函数返回一个对象, 则返回该对象; 否则返回新对象
  return result instanceof Object ? result : obj;
}

// 测试基础功能
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.introduce = function () {
  console.log(`Hi, I'm ${this.name}, ${this.age} years old.`);
};

const person1 = new Person("Alice", 25);
const person2 = myNew(Person, "Bob", 30);

console.log(person1 instanceof Person); // true
console.log(person2 instanceof Person); // true
console.log(person2.name); // Bob
console.log(person2.age); // 30
person2.introduce(); // Hi, I'm Bob, 30 years old.

2.2 完整实现(处理边界情况)
/**
 * 完整版 myNew 实现,处理各种边界情况
 * @param {Function} Constructor 构造函数
 * @param {...any} args 构造函数参数
 * @returns {Object} 新创建的对象
 */
function myNewComplete(Constructor, ...args) {
  // 参数验证
  if (typeof Constructor !== "function") {
    throw new TypeError("Constructor must be a function.");
  }

  // 1. 创建一个新对象, 继承构造函数的原型
  // 使用 Object.create 而不是 {}, 确保原型链正确
  const obj = Object.create(Constructor.prototype);

  // 2. 执行构造函数, 绑定 this 到新对象
  const result = Constructor.apply(obj, args);

  // 3. 处理返回值
  // 3.1 如果构造函数返回一个对象,则返回该对象
  // 3.2 否则返回新创建的对象
  const isObject = result !== null && typeof result === "object";
  const isFunction = typeof result === "function";

  return isObject || isFunction ? result : obj;
}
// 测试各种边界情况
console.log("=== 测试 myNewComplete ===");

// 测试1: 构造函数返回对象
function PersonWithReturnObject(name) {
  this.name = name;
  return { custom: "custom object" };
}

const test1 = myNewComplete(PersonWithReturnObject, "Alice");
console.log("测试1 - 构造函数返回对象:", test1); // { custom: 'custom object' }
console.log(
  "测试1 - 不是 Person 实例:",
  !(test1 instanceof PersonWithReturnObject)
); // true

// 测试2: 构造函数返回 null
function PersonWithReturnNull(name) {
  this.name = name;
  return null;
}

const test2 = myNewComplete(PersonWithReturnNull, "Bob");
console.log(
  "测试2 - 构造函数返回 null:",
  test2 instanceof PersonWithReturnNull
); // true
console.log("测试2 - name 属性:", test2.name); // Bob

// 测试3: 构造函数返回函数
function PersonWithReturnFunction(name) {
  this.name = name;
  return function () {
    return "I am a function";
  };
}

const test3 = myNewComplete(PersonWithReturnFunction, "Charlie");
console.log("测试3 - 构造函数返回函数:", typeof test3); // function
console.log("测试3 - 函数调用:", test3()); // I am a function

// 测试4: 构造函数返回基本类型
function PersonWithReturnPrimitive(name) {
  this.name = name;
  return 42;
}

const test4 = myNewComplete(PersonWithReturnPrimitive, "David");
console.log(
  "测试4 - 构造函数返回基本类型:",
  test4 instanceof PersonWithReturnPrimitive
); // true
console.log("测试4 - name 属性:", test4.name); // David

// 测试5: 构造函数没有返回值
function PersonNoReturn(name) {
  this.name = name;
}

const test5 = myNewComplete(PersonNoReturn, "Eve");
console.log("测试5 - 构造函数没有返回值:", test5 instanceof PersonNoReturn); // true
console.log("测试5 - name 属性:", test5.name); // Eve
2.3 处理ES6类的版本
/**
 * 完整版 myNew 实现,处理各种边界情况(包括 ES6 类)
 * @param {Function} Constructor 构造函数
 * @param {...any} args 构造函数参数
 * @returns {Object} 新创建的对象
 */
function myNewComplete(Constructor, ...args) {
  // 参数验证
  if (typeof Constructor !== "function") {
    throw new TypeError("Constructor must be a function.");
  }

  // 是哟哦那个 Reflect.construct 处理 ES6 类和普通函数
  const instance = Reflect.construct(Constructor, args, Constructor);

  // 对于普通函数, 处理返回值逻辑
  // 判断是否是 ES6类(简单判断方式)
  const isClass =
    typeof Constructor === "function" &&
    (Constructor.toString().startsWith("class") ||
      Function.prototype.toString.call(Constructor).startsWith("class"));

  if (!isClass) {
    // 检查构造函数是否显式返回了对象/函数
    const isObject = instance !== null && typeof instance === "object";
    const isFunction = typeof instance === "function";

    // 如果构造函数返回的不是继承自其原型的对象, 说明显式返回了其他值
    if (!(instance instanceof Constructor)) {
      return isObject || isFunction
        ? instance
        : Object.create(Constructor.prototype);
    }
  }

  return instance;
}

2.4 测试用例

console.log("=== 完整测试用例 ===");

// 测试类继承
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    console.log(`${this.name} (${this.breed}) barks.`);
  }
}

// 测试 ES6 类
const dog1 = new Dog("Rex", "Labrador");
const dog2 = myNewComplete(Dog, "Buddy", "Golden Retriever");

console.log("dog1 instanceof Dog:", dog1 instanceof Dog); // true
console.log("dog2 instanceof Dog:", dog2 instanceof Dog); // true
console.log("dog1 instanceof Animal:", dog1 instanceof Animal); // true
console.log("dog2 instanceof Animal:", dog2 instanceof Animal); // true

dog1.speak(); // Rex (Labrador) barks.
dog2.speak(); // Buddy (Golden Retriever) barks.

// 测试内置构造函数
const date1 = new Date("2025-12-08");
const date2 = myNewComplete(Date, "2025-12-08");

console.log("date1 instanceof Date:", date1 instanceof Date); // true
console.log("date2 instanceof Date:", date2 instanceof Date); // true
console.log("date1.toISOString():", date1.toISOString()); // 2025-12-08T00:00:00.000Z
console.log("date2.toISOString():", date2.toISOString()); // 2025-12-08T00:00:00.000Z

// 测试错误情况
try {
  myNewComplete(null);
} catch (error) {
  console.log("错误测试1 - null:", error.message); // Constructor must be a function
}

try {
  myNewComplete(42);
} catch (error) {
  console.log("错误测试2 - 数字:", error.message); // Constructor must be a function
}

try {
  myNewComplete("string");
} catch (error) {
  console.log("错误测试3 - 字符串:", error.message); // Constructor must be a function
}

三、instanceof的深入理解

3.1 instanceof操作符的作用

instanceof操作符用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。

function Animal() {}
function Dog() {}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true
console.log(dog instanceof Array); // false
3.2 instanceof的工作原理

instanceof操作符的算法可以描述为:

  1. 获取右侧操作数的prototype属性
  2. 获取左侧操作数的[[Prototype]]链(即__proto__链)
  3. 沿着原型链向上查找, 如果找到与右侧prototype相同的对象, 返回true
  4. 如果到达原型链末端(null)仍未找到, 返回false

四、手写 instanceof 操作符

4.1 基础实现
/**
 * 手写 instanceof 操作符
 * @param {any} instance 需要检测的对象
 * @param {Function} Constructor 构造函数
 * @returns {boolean} 检测结果
 */
function myInstanceof(instance, Constructor) {
  // 基本类型直接返回false
  if (
    instance === null ||
    (typeof instance !== "object" && typeof instance !== "function")
  ) {
    return false;
  }

  // 获取构造函数的 prototype
  const prototype = Constructor.prototype;

  // 获取实例的原型
  let proto = Object.getPrototypeOf(instance);

  // 沿着原型链向上查找
  while (proto !== null) {
    if (proto === prototype) {
      return true;
    }
    proto = Object.getPrototypeOf(proto);
  }

  return false;
}

// 测试基础功能
function Animal() {}
function Dog() {}
function Cat() {}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

const dog = new Dog();
const cat = new Cat();

console.log("=== 测试 myInstanceof ===");
console.log("dog instanceof Dog:", myInstanceof(dog, Dog)); // true
console.log("dog instanceof Animal:", myInstanceof(dog, Animal)); // true
console.log("dog instanceof Object:", myInstanceof(dog, Object)); // true
console.log("dog instanceof Cat:", myInstanceof(dog, Cat)); // false
console.log("cat instanceof Dog:", myInstanceof(cat, Dog)); // false
4.2 完整实现(处理边界情况)
/**
 * 完整版 myInstanceof 实现
 * @param {any} instance 要检测的对象
 * @param {Function} Constructor 构造函数
 * @returns {boolean} 检测结果
 */
function myInstanceofComplete(instance, Constructor) {
    // 参数验证
    if (typeof Constructor !== 'function') {
        throw new TypeError('Right-hand side of instanceof is not callable');
    }
    
    // 基本类型直接返回 false
    // 注意:null 和 undefined 返回 false
    if (instance === null || instance === undefined) {
        return false;
    }
    
    // 对于非对象类型,除了 function 外都返回 false
    const type = typeof instance;
    if (type !== 'object' && type !== 'function') {
        return false;
    }
    
    // 获取构造函数的 prototype
    const prototype = Constructor.prototype;
    
    // 如果构造函数的 prototype 不是对象,抛出错误
    if (prototype === null || typeof prototype !== 'object') {
        throw new TypeError('Function has non-object prototype in instanceof check');
    }
    
    // 获取实例的原型
    let proto = Object.getPrototypeOf(instance);
    
    // 沿着原型链向上查找
    while (proto !== null) {
        if (proto === prototype) {
            return true;
        }
        proto = Object.getPrototypeOf(proto);
    }
    
    return false;
}

// 测试完整功能
console.log('=== 测试 myInstanceofComplete ===');

// 测试1: 基本类型
console.log('测试1 - 数字:', myInstanceofComplete(42, Number));         // false
console.log('测试1 - 字符串:', myInstanceofComplete('hello', String));  // false
console.log('测试1 - 布尔值:', myInstanceofComplete(true, Boolean));    // false
console.log('测试1 - null:', myInstanceofComplete(null, Object));       // false
console.log('测试1 - undefined:', myInstanceofComplete(undefined, Object)); // false

// 测试2: 对象类型
function Person() {}
const person = new Person();
console.log('测试2 - 对象:', myInstanceofComplete(person, Person));     // true
console.log('测试2 - 对象 instanceof Object:', myInstanceofComplete(person, Object)); // true

// 测试3: 数组
const arr = [1, 2, 3];
console.log('测试3 - 数组:', myInstanceofComplete(arr, Array));         // true
console.log('测试3 - 数组 instanceof Object:', myInstanceofComplete(arr, Object)); // true

// 测试4: 函数
function testFunc() {}
console.log('测试4 - 函数:', myInstanceofComplete(testFunc, Function)); // true
console.log('测试4 - 函数 instanceof Object:', myInstanceofComplete(testFunc, Object)); // true

// 测试5: 内置对象
const date = new Date();
console.log('测试5 - Date:', myInstanceofComplete(date, Date));         // true
console.log('测试5 - Date instanceof Object:', myInstanceofComplete(date, Object)); // true

// 测试6: 原型链继承
function Animal() {}
function Dog() {}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

const dog = new Dog();
console.log('测试6 - 原型链:', myInstanceofComplete(dog, Dog));         // true
console.log('测试6 - 原型链:', myInstanceofComplete(dog, Animal));      // true
console.log('测试6 - 原型链:', myInstanceofComplete(dog, Object));      // true

// 测试7: 错误情况
try {
    myInstanceofComplete({}, null);
} catch (error) {
    console.log('测试7 - 非函数:', error.message); // 'Right-hand side of instanceof is not callable'
}

try {
    myInstanceofComplete({}, {prototype: null});
} catch (error) {
    console.log('测试7 - 非函数2:', error.message); // 'Right-hand side of instanceof is not callable'
}
4.3 测试用例
console.log('=== 完整测试用例 ===');

// 复杂原型链测试
function GrandParent() {}
function Parent() {}
function Child() {}

Parent.prototype = Object.create(GrandParent.prototype);
Parent.prototype.constructor = Parent;

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child();

console.log('原型链测试 - child instanceof Child:', myInstanceofComplete(child, Child)); // true
console.log('原型链测试 - child instanceof Parent:', myInstanceofComplete(child, Parent)); // true
console.log('原型链测试 - child instanceof GrandParent:', myInstanceofComplete(child, GrandParent)); // true
console.log('原型链测试 - child instanceof Object:', myInstanceofComplete(child, Object)); // true

// Symbol.hasInstance 测试
class CustomClass {
    static [Symbol.hasInstance](instance) {
        return typeof instance === 'object' && instance.isCustom === true;
    }
}

const customObj = { isCustom: true };
const notCustomObj = { isCustom: false };

console.log('Symbol.hasInstance 测试 - 自定义对象:', myInstanceofComplete(customObj, CustomClass)); // true
console.log('Symbol.hasInstance 测试 - 非自定义对象:', myInstanceofComplete(notCustomObj, CustomClass)); // false

// 边界情况:Object.create(null)
const objWithoutProto = Object.create(null);
console.log('Object.create(null) 测试:', myInstanceofComplete(objWithoutProto, Object)); // false

// 边界情况:修改原型链
const obj = {};
Object.setPrototypeOf(obj, Array.prototype);
console.log('修改原型链测试:', myInstanceofComplete(obj, Array)); // true
console.log('修改原型链测试:', myInstanceofComplete(obj, Object)); // true

// 边界情况:循环引用(应避免这种情况)
function A() {}
function B() {}

// 设置循环原型链(不推荐,仅用于测试)
const a = new A();
const b = new B();

// 注意:实际中不要这样做,这里只是为了测试
Object.setPrototypeOf(A.prototype, B.prototype);
Object.setPrototypeOf(B.prototype, A.prototype);

try {
    // 这里可能会陷入无限循环,所以我们需要有保护机制
    console.log('循环引用测试:', myInstanceofComplete(a, B));
} catch (error) {
    console.log('循环引用测试 - 捕获错误:', error.message);
}

五、new和instanceof的综合应用

5.1 实现一个继承系统
// 使用 new 和 instanceof 实现完整的继承系统
function inherit(Child, Parent) {
  // 1. 创建中间构造函数,避免直接修改 Child.prototype
  const Temp = function () {};
  Temp.prototype = Parent.prototype;

  // 2. 设置 Child 的原型为 Parent 的实例
  Child.prototype = new Temp();

  // 3. 修复 constructor 指向
  Child.prototype.constructor = Child;

  // 4. 设置 superClass 引用,便于调用父类方法
  Child.superClass = Parent.prototype;

  // 5. 如果父类的原型是 Object,需要额外处理
  if (Parent.prototype.constructor === Object) {
    Parent.prototype.constructor = Parent;
  }
}

// 测试继承系统
function Shape(color) {
  this.color = color;
}

Shape.prototype.getColor = function () {
  return this.color;
};

Shape.prototype.getArea = function () {
  throw new Error("getArea() must be implemented by subclasses");
};

function Circle(color, radius) {
  // 调用父类构造函数
  Shape.call(this, color);
  this.radius = radius;
}

// 继承 Shape
inherit(Circle, Shape);

// 添加子类方法
Circle.prototype.getArea = function () {
  return Math.PI * this.radius * this.radius;
};

Circle.prototype.getCircumference = function () {
  return 2 * Math.PI * this.radius;
};

// 测试
const circle = new Circle("red", 5);
console.log("继承测试 - color:", circle.getColor()); // red
console.log("继承测试 - area:", circle.getArea().toFixed(2)); // 78.54
console.log("继承测试 - circumference:", circle.getCircumference().toFixed(2)); // 31.42
console.log("继承测试 - instanceof Circle:", circle instanceof Circle); // true
console.log("继承测试 - instanceof Shape:", circle instanceof Shape); // true
console.log("继承测试 - instanceof Object:", circle instanceof Object); // true
5.2 实现多态检测
// 使用 instanceof 实现多态类型检测
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        console.log(`${this.name} makes a noise.`);
    }
}

class Dog extends Animal {
    speak() {
        console.log(`${this.name} barks.`);
    }
}

class Cat extends Animal {
    speak() {
        console.log(`${this.name} meows.`);
    }
}

class Bird extends Animal {
    speak() {
        console.log(`${this.name} chirps.`);
    }
}

// 多态处理器
class AnimalHandler {
    static handle(animal) {
        if (animal instanceof Dog) {
            console.log(`Handling a dog named ${animal.name}`);
            animal.speak();
            console.log('Taking the dog for a walk...');
        } else if (animal instanceof Cat) {
            console.log(`Handling a cat named ${animal.name}`);
            animal.speak();
            console.log('Giving the cat some milk...');
        } else if (animal instanceof Bird) {
            console.log(`Handling a bird named ${animal.name}`);
            animal.speak();
            console.log('Letting the bird fly...');
        } else if (animal instanceof Animal) {
            console.log(`Handling an unknown animal named ${animal.name}`);
            animal.speak();
            console.log('Providing general care...');
        } else {
            console.log('Not an animal!');
        }
    }
}

// 测试多态
const animals = [
    new Dog('Rex'),
    new Cat('Whiskers'),
    new Bird('Tweety'),
    new Animal('Generic'),
    { name: 'Not an animal' }
];

console.log('=== 多态处理测试 ===');
animals.forEach(animal => {
    AnimalHandler.handle(animal);
    console.log('---');
});

六、常见面试题

6.1 实现Object.create()
/**
 * 手写 Object.create()
 * @param {Object} proto 新创建对象的原型对象
 * @param {Object} propertiesObject 可选,包含属性描述符的对象
 * @returns {Object} 新对象
 */
function myObjectCreate(proto, propertiesObject) {
    // 参数验证
    if (proto !== null && typeof proto !== 'object' && typeof proto !== 'function') {
        throw new TypeError('Object prototype may only be an Object or null');
    }
    
    // 创建一个空构造函数
    function Temp() {}
    
    // 设置构造函数的原型
    Temp.prototype = proto;
    
    // 创建新对象
    const obj = new Temp();
    
    // 如果 proto 是 null,手动设置原型为 null
    if (proto === null) {
        Object.setPrototypeOf(obj, null);
    }
    
    // 处理第二个参数
    if (propertiesObject !== undefined) {
        Object.defineProperties(obj, propertiesObject);
    }
    
    return obj;
}

// 测试 myObjectCreate
console.log('=== 测试 myObjectCreate ===');

// 测试1: 基本功能
const proto = { x: 10, y: 20 };
const obj1 = myObjectCreate(proto);
console.log('测试1 - 原型链:', Object.getPrototypeOf(obj1) === proto); // true
console.log('测试1 - 继承属性:', obj1.x, obj1.y); // 10, 20

// 测试2: null 原型
const obj2 = myObjectCreate(null);
console.log('测试2 - null 原型:', Object.getPrototypeOf(obj2)); // null
console.log('测试2 - 没有 toString:', obj2.toString === undefined); // true

// 测试3: 属性描述符
const obj3 = myObjectCreate({}, {
    value: {
        value: 42,
        writable: false,
        enumerable: true,
        configurable: true
    }
});
console.log('测试3 - 属性描述符:', obj3.value); // 42
obj3.value = 100;
console.log('测试3 - 不可写:', obj3.value); // 42 (不可写)

// 测试4: 与原生 Object.create 比较
const nativeObj = Object.create(proto);
const myObj = myObjectCreate(proto);
console.log('测试4 - 原生 vs 手写:', 
    Object.getPrototypeOf(nativeObj) === Object.getPrototypeOf(myObj)); // true
6.2 实现Object.getPrototypeOf()
/**
 * 手写 Object.getPrototypeOf()
 * @param {Object} obj 要获取原型的对象
 * @returns {Object | null} 对象的原型
 */
function myGetPrototypeOf(obj) {
  // 参数验证
  if (obj === null || obj === undefined) {
    throw new TypeError("Cannot convert undefined or null to object");
  }

  // 使用 __proto__ 访问器(非标准,但广泛支持)
  // 注意: 实际中更推荐使用 Object.getPrototypeOf
  if ("__proto__" in obj) {
    return obj.__proto__;
  }

  // 对于不支持 __proto__ 的环境,使用 Constructor 属性
  // 注意: 这种方法不可靠,因为 Constructor 可能被修改
  if (obj.constructor && obj.constructor.prototype) {
    return obj.constructor.prototype;
  }

  // 对于通过 Object.create(null) 创建的对象
  return null;
}

// 测试 myGetPrototypeOf
console.log("=== 测试 myGetPrototypeOf ===");

// 测试1: 普通对象
const obj1 = {};
console.log("测试1 - 普通对象:", myGetPrototypeOf(obj1) === Object.prototype); // true

// 测试2: 数组
const arr = [];
console.log("测试2 - 数组:", myGetPrototypeOf(arr) === Array.prototype); // true

// 测试3: 自定义对象
function Person() {}
const person = new Person();
console.log(
  "测试3 - 自定义对象:",
  myGetPrototypeOf(person) === Person.prototype
); // true

// 测试4: null 原型
const nullProtoObj = Object.create(null);
console.log("测试4 - null 原型:", myGetPrototypeOf(nullProtoObj)); // null

// 测试5: 错误情况
try {
  myGetPrototypeOf(null);
} catch (error) {
  console.log("测试5 - null 错误:", error.message); // Cannot convert undefined or null to object
}
6.3 实现完整的原型链检测工具
/**
 * 原型链检测工具类
 */
class PrototypeChainUtils {
  /**
   * 获取对象的完整原型链
   * @param {Object} obj 要检测的对象
   * @returns {Array} 原型链数组
   */
  static getPrototypeChain(obj) {
    if (obj === null || obj === undefined) {
      throw new TypeError("Cannot convert undefined or null to object");
    }

    const chain = [];
    let proto = Object.getPrototypeOf(obj);

    while (proto !== null) {
      chain.push(proto);
      proto = Object.getPrototypeOf(proto);
    }

    return chain;
  }

  /**
   * 检查对象是否在原型链上
   * @param {Object} obj 要检查的对象
   * @param {Object} target 目标原型
   * @returns {boolean} 是否在原型链上
   */
  static isInPrototypeChain(obj, target) {
    if (obj === null || obj === undefined) {
      return false;
    }

    let proto = Object.getPrototypeOf(obj);

    while (proto !== null) {
      if (proto === target) {
        return true;
      }
      proto = Object.getPrototypeOf(proto);
    }

    return false;
  }

  /**
   * 获取对象到指定原型的距离
   * @param {Object} obj 对象
   * @param {Object} target 目标原型
   * @returns {number} 距离(-1 表示不再原型链上)
   */
  static getDistanceToPrototype(obj, target) {
    if (obj === null || obj === undefined) {
      return -1;
    }

    let distance = 0;
    let proto = Object.getPrototypeOf(obj);

    while (proto !== null) {
      if (proto === target) {
        return distance;
      }
      distance++;
      proto = Object.getPrototypeOf(proto);
    }

    return -1;
  }
}

// 测试原型链工具
console.log("=== 测试 PrototypeChainUtils ===");

function A() {}
function B() {}
function C() {}

B.prototype = Object.create(A.prototype);
B.prototype.constructor = B;

C.prototype = Object.create(B.prototype);
C.prototype.constructor = C;

const c = new C();

// 获取原型链
const chain = PrototypeChainUtils.getPrototypeChain(c);
console.log("原型链:", chain);
console.log("原型链长度:", chain.length); // 4
console.log("包含 C.prototype:", chain[0] === C.prototype); // true
console.log("包含 B.prototype:", chain[1] === B.prototype); // true
console.log("包含 A.prototype:", chain[2] === A.prototype); // true
console.log("包含 Object.prototype:", chain[3] === Object.prototype); // true

// 检查是否在原型链上
console.log(
  "是否在原型链上 - C.prototype:",
  PrototypeChainUtils.isInPrototypeChain(c, C.prototype)
); // true
console.log(
  "是否在原型链上 - B.prototype:",
  PrototypeChainUtils.isInPrototypeChain(c, B.prototype)
); // true
console.log(
  "是否在原型链上 - A.prototype:",
  PrototypeChainUtils.isInPrototypeChain(c, A.prototype)
); // true
console.log(
  "是否在原型链上 - Object.prototype:",
  PrototypeChainUtils.isInPrototypeChain(c, Object.prototype)
); // true
console.log(
  "是否在原型链上 - 不存在的原型:",
  PrototypeChainUtils.isInPrototypeChain(c, Array.prototype)
); // false

// 获取距离
console.log(
  "到 C.prototype 的距离:",
  PrototypeChainUtils.getDistanceToPrototype(c, C.prototype)
); // 0
console.log(
  "到 B.prototype 的距离:",
  PrototypeChainUtils.getDistanceToPrototype(c, B.prototype)
); // 1
console.log(
  "到 A.prototype 的距离:",
  PrototypeChainUtils.getDistanceToPrototype(c, A.prototype)
); // 2
console.log(
  "到 Object.prototype 的距离:",
  PrototypeChainUtils.getDistanceToPrototype(c, Object.prototype)
); // 3
console.log(
  "到不存在的原型的距离:",
  PrototypeChainUtils.getDistanceToPrototype(c, Array.prototype)
); // -1

七、实际应用场景

7.1 实现工厂模式
// 使用 new 和 instanceof 实现工厂模式
class VehicleFactory {
    static createVehicle(type, options) {
        switch (type) {
            case 'car':
                return new Car(options);
            case 'truck':
                return new Truck(options);
            case 'motorcycle':
                return new Motorcycle(options);
            default:
                throw new Error(`Unknown vehicle type: ${type}`);
        }
    }
    
    static validateVehicle(vehicle) {
        if (!(vehicle instanceof Vehicle)) {
            throw new TypeError('Invalid vehicle');
        }
        return vehicle;
    }
}

class Vehicle {
    constructor(make, model, year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }
    
    getInfo() {
        return `${this.year} ${this.make} ${this.model}`;
    }
}

class Car extends Vehicle {
    constructor(options) {
        super(options.make, options.model, options.year);
        this.doors = options.doors || 4;
    }
    
    getInfo() {
        return `${super.getInfo()} (${this.doors} doors)`;
    }
}

class Truck extends Vehicle {
    constructor(options) {
        super(options.make, options.model, options.year);
        this.payload = options.payload || '1 ton';
    }
    
    getInfo() {
        return `${super.getInfo()} (${this.payload} payload)`;
    }
}

class Motorcycle extends Vehicle {
    constructor(options) {
        super(options.make, options.model, options.year);
        this.type = options.type || 'street';
    }
    
    getInfo() {
        return `${super.getInfo()} (${this.type} motorcycle)`;
    }
}

// 使用工厂模式
console.log('=== 工厂模式测试 ===');

try {
    const car = VehicleFactory.createVehicle('car', {
        make: 'Toyota',
        model: 'Camry',
        year: 2022,
        doors: 4
    });
    
    const truck = VehicleFactory.createVehicle('truck', {
        make: 'Ford',
        model: 'F-150',
        year: 2021,
        payload: '2 tons'
    });
    
    const motorcycle = VehicleFactory.createVehicle('motorcycle', {
        make: 'Harley-Davidson',
        model: 'Sportster',
        year: 2020,
        type: 'cruiser'
    });
    
    console.log('Car:', car.getInfo());
    console.log('Truck:', truck.getInfo());
    console.log('Motorcycle:', motorcycle.getInfo());
    
    // 验证
    console.log('Car instanceof Vehicle:', VehicleFactory.validateVehicle(car) instanceof Vehicle);
    console.log('Car instanceof Car:', car instanceof Car);
    console.log('Truck instanceof Vehicle:', truck instanceof Vehicle);
    console.log('Motorcycle instanceof Vehicle:', motorcycle instanceof Vehicle);
    
    // 错误测试
    try {
        VehicleFactory.createVehicle('airplane', {});
    } catch (error) {
        console.log('错误处理:', error.message);
    }
    
    try {
        VehicleFactory.validateVehicle({});
    } catch (error) {
        console.log('验证错误:', error.message);
    }
} catch (error) {
    console.error('工厂模式错误:', error);
}
7.2 实现插件系统
// 使用原型链实现插件系统
class PluginSystem {
    constructor() {
        this.plugins = new Map();
        this.hooks = new Map();
    }
    
    // 注册插件
    register(plugin) {
        if (!(plugin instanceof Plugin)) {
            throw new TypeError('Plugin must be an instance of Plugin');
        }
        
        const name = plugin.name;
        
        if (this.plugins.has(name)) {
            throw new Error(`Plugin "${name}" is already registered`);
        }
        
        this.plugins.set(name, plugin);
        
        // 注册钩子
        if (plugin.hooks) {
            for (const [hookName, handler] of Object.entries(plugin.hooks)) {
                if (!this.hooks.has(hookName)) {
                    this.hooks.set(hookName, []);
                }
                this.hooks.get(hookName).push(handler);
            }
        }
        
        console.log(`Plugin "${name}" registered successfully`);
    }
    
    // 触发钩子
    trigger(hookName, ...args) {
        if (!this.hooks.has(hookName)) {
            return [];
        }
        
        const results = [];
        const handlers = this.hooks.get(hookName);
        
        for (const handler of handlers) {
            try {
                const result = handler(...args);
                results.push(result);
            } catch (error) {
                console.error(`Error in hook "${hookName}":`, error);
            }
        }
        
        return results;
    }
    
    // 获取插件
    getPlugin(name) {
        return this.plugins.get(name);
    }
    
    // 检查插件是否已注册
    hasPlugin(name) {
        return this.plugins.has(name);
    }
}

// 插件基类
class Plugin {
    constructor(name) {
        if (new.target === Plugin) {
            throw new TypeError('Cannot instantiate Plugin directly');
        }
        this.name = name;
        this.hooks = {};
    }
    
    // 初始化钩子
    init(system) {
        // 由子类实现
    }
}

// 示例插件
class LoggerPlugin extends Plugin {
    constructor() {
        super('logger');
        
        this.hooks = {
            'beforeRequest': this.beforeRequest.bind(this),
            'afterResponse': this.afterResponse.bind(this),
            'onError': this.onError.bind(this)
        };
    }
    
    beforeRequest(request) {
        console.log(`[Logger] Request started: ${request.url}`);
        return { logged: true, timestamp: Date.now() };
    }
    
    afterResponse(response) {
        console.log(`[Logger] Response received: ${response.status}`);
        return { logged: true, timestamp: Date.now() };
    }
    
    onError(error) {
        console.error(`[Logger] Error: ${error.message}`);
        return { logged: true, timestamp: Date.now() };
    }
}

class AnalyticsPlugin extends Plugin {
    constructor() {
        super('analytics');
        
        this.hooks = {
            'afterResponse': this.trackAnalytics.bind(this)
        };
        
        this.requests = [];
    }
    
    trackAnalytics(response) {
        this.requests.push({
            url: response.url,
            status: response.status,
            timestamp: Date.now()
        });
        
        console.log(`[Analytics] Tracked request to ${response.url}`);
        return { tracked: true, totalRequests: this.requests.length };
    }
    
    getStats() {
        return {
            totalRequests: this.requests.length,
            successfulRequests: this.requests.filter(r => r.status === 200).length,
            failedRequests: this.requests.filter(r => r.status !== 200).length
        };
    }
}

// 使用插件系统
console.log('=== 插件系统测试 ===');

const pluginSystem = new PluginSystem();

try {
    // 注册插件
    const logger = new LoggerPlugin();
    const analytics = new AnalyticsPlugin();
    
    pluginSystem.register(logger);
    pluginSystem.register(analytics);
    
    // 触发钩子
    console.log('触发 beforeRequest 钩子:');
    const beforeResults = pluginSystem.trigger('beforeRequest', { url: '/api/data', method: 'GET' });
    console.log('Before request results:', beforeResults);
    
    console.log('\n触发 afterResponse 钩子:');
    const afterResults = pluginSystem.trigger('afterResponse', { url: '/api/data', status: 200, data: {} });
    console.log('After response results:', afterResults);
    
    console.log('\n触发 onError 钩子:');
    const errorResults = pluginSystem.trigger('onError', new Error('Network error'));
    console.log('Error results:', errorResults);
    
    // 获取插件
    console.log('\n插件检查:');
    console.log('Has logger plugin:', pluginSystem.hasPlugin('logger'));
    console.log('Has analytics plugin:', pluginSystem.hasPlugin('analytics'));
    console.log('Has unknown plugin:', pluginSystem.hasPlugin('unknown'));
    
    // 获取统计信息
    const analyticsPlugin = pluginSystem.getPlugin('analytics');
    if (analyticsPlugin instanceof AnalyticsPlugin) {
        console.log('\nAnalytics stats:', analyticsPlugin.getStats());
    }
    
    // 错误测试
    console.log('\n错误测试:');
    try {
        pluginSystem.register({}); // 不是 Plugin 实例
    } catch (error) {
        console.log('注册非插件错误:', error.message);
    }
    
    try {
        pluginSystem.register(logger); // 重复注册
    } catch (error) {
        console.log('重复注册错误:', error.message);
    }
} catch (error) {
    console.error('插件系统错误:', error);
}

八、总结与最佳实践

81 核心要点总结
  1. new操作符的执行步骤:
  • 创建新对象
  • 设置原型链
  • 绑定 this 值
  • 执行构造函数
  • 处理返回值
  1. instanceof的工作原理:
  • 沿着原型链向上查找
  • 比较构造函数的prototype属性
  • 支持Symbol.hasInstance自定义检测
  1. 原型链的重要性:
  • 是实现继承的基础
  • 影响 instanceof 的检测结果
  • 决定了对象的属性和方法查找路径
8.2 手写实现的关键点
// new 操作符实现的关键
function myNew(Constructor, ...args) {
    // 1. 创建对象并设置原型
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数
    const result = Constructor.apply(obj, args);
    
    // 3. 处理返回值
    return result instanceof Object ? result : obj;
}

// instanceof 操作符实现的关键
function myInstanceof(instance, Constructor) {
    // 1. 基本类型直接返回 false
    if (instance === null || typeof instance !== 'object') {
        return false;
    }
    
    // 2. 获取原型链
    let proto = Object.getPrototypeOf(instance);
    const prototype = Constructor.prototype;
    
    // 3. 沿着原型链查找
    while (proto !== null) {
        if (proto === prototype) {
            return true;
        }
        proto = Object.getPrototypeOf(proto);
    }
    
    return false;
}
8.3 最佳实践
  1. 使用new操作符时:
// 好的实践
function Person(name) {
    if (!(this instanceof Person)) {
        return new Person(name); // 防止忘记使用 new
    }
    this.name = name;
}

// 或者使用 ES6 class
class Person {
    constructor(name) {
        this.name = name;
    }
}
  1. 使用instanceof时:
// 好的实践 - 检查类型
function process(value) {
    if (value instanceof Array) {
        // 处理数组
    } else if (value instanceof Date) {
        // 处理日期
    } else if (value instanceof RegExp) {
        // 处理正则表达式
    }
}

// 更好的实践 - 使用鸭子类型
function process(value) {
    if (Array.isArray(value)) {
        // 处理数组
    } else if (value instanceof Date || typeof value.getMonth === 'function') {
        // 处理日期
    }
}
  1. 避免的问题:
// 避免的问题1: 修改内置对象的原型
Array.prototype.customMethod = function() {};
const arr = [];
console.log(arr instanceof Array); // true
console.log(arr.customMethod); // function

// 避免的问题2: 跨框架的 instanceof 问题
// 在不同 iframe 中创建的数组,instanceof 可能返回 false

// 避免的问题3: constructor 被修改
function Person() {}
const person = new Person();
person.constructor = Object;
console.log(person instanceof Person); // true
console.log(person.constructor === Person); // false
8.4 性能考虑
  1. new操作符的性能:
  • 原生的new操作符是最快的
  • 手写实现会有轻微性能开销
  • 在性能关键路径上使用原生操作
  1. instanceof的性能:
  • 原型链越长,检测越慢
  • 对于频繁的类型检测,可以考虑缓存结果
  • 使用typeof检测基本类型更快
  1. 优化建议:
// 优化频繁的类型检测
const isArray = Array.isArray;

function processItems(items) {
    if (isArray(items)) {
        // 处理数组
        for (let i = 0; i < items.length; i++) {
            // 使用 for 循环而不是 forEach
        }
    }
}

结语

通过手写new操作符和instanceof操作符,我们深入理解了JavaScript的对象创建机制和类型检测原理。这些知识不仅是面试中的常见考点,更是编写高质量JavaScript代码的基础。

记住以下要点:

  • ✅ new 操作符创建对象时,会设置原型链并执行构造函数
  • ✅ instanceof 通过检查原型链来判断对象类型
  • ✅ 原型链是 JavaScript 继承的基础
  • ✅ 理解这些原理有助于避免常见的陷阱和错误

延伸阅读:

如何实现一个MCP服务器

项目结构

src/
├── index.ts             # 入口文件
├── server.ts            # 服务器启动逻辑
├── common/              # 公共模块
│   └── index.ts         # 配置和工具函数
├── tools/               # MCP工具实现
│   ├── index.ts         # 工具注册
│   └── manage/          # 业务功能
│       ├── upload-rule.ts
│       └── download.ts
└── utils/               # 工具函数
    ├── common.ts
    ├── uploadCommon.ts
    └── workspaceScanner.ts

核心依赖

{
  "@modelcontextprotocol/sdk": "^1.10.2",
  "axios": "^1.10.0",
  "form-data": "^4.0.4"
}

1. 项目初始化

package.json配置

{
  "name": "@company/mcp-server-demo",
  "type": "module",
  "main": "./dist/index.js",
  "bin": {
    "mcp-server-demo": "./dist/index.js"
  }
}

tsconfig.json配置

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext", 
    "moduleResolution": "node",
    "outDir": "./dist",
    "esModuleInterop": true
  }
}

2. 入口文件实现

src/index.ts

#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { startServer } from "./server.js";
import { registerBusinessTool } from "./tools/index.js";

async function main() {
    const server = new Server({
        name: packageInfo.name,
        version: packageInfo.version,
    }, {
        capabilities: {
            tools: {
                listChanged: true,
            },
        },
    });

    // 注册工具
    registerBusinessTool(server);
    
    // 启动服务
    await startServer(server);
}

main();

3. 服务器启动逻辑

src/server.ts

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";

async function startStdioMode(server: Server) {
    const transport = new StdioServerTransport();
    await server.connect(transport);
}

export async function startServer(server: Server) {
    try {
        await startStdioMode(server);
    } catch (error) {
        process.exit(1);
    }
}

4. 工具注册与实现

src/tools/index.ts

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

export function registerBusinessTool(server: Server) {
    // 注册工具列表
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
        tools: [
            UPLOAD_RULE_TOOL,
            DOWNLOAD_PROJECT_RULES_TOOL,
        ],
    }));

    // 注册工具调用处理器
    server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
        switch (request.params.name) {
            case "upload_rule":
                return await handleUploadRule(request);
            case "get_download_rules":
                return await handleDownloadRules(request);
            default:
                return {
                    content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
                    isError: true
                };
        }
    });
}

5. 工具定义

上传工具定义

export const UPLOAD_RULE_TOOL = {
    name: 'upload',
    description: '上传文件',
    inputSchema: {
        type: 'object',
        properties: {
            filePaths: {
                type: 'array',
                items: { type: 'string' },
                description: '要上传的文件路径列表(必须是绝对路径)'
            },
            teamSelection: {
                type: 'string',
                description: '团队选择,如:frontend、backend、testing等'
            },
            ruleTypeName: {
                type: 'string',
                enum: ['requirement_rules', 'project_rules', 'common_rules'],
                description: '规则类型'
            },
        },
        required: ['filePaths', 'teamSelection', 'ruleTypeName']
    }
};

下载工具定义

export const DOWNLOAD_PROJECT_RULES_TOOL = {
    name: "get_download_rules",
    description: '下载文件',
    inputSchema: {
        type: "object",
        properties: {
            projectNameList: {
                type: "array",
                description: "项目名集合",
                items: { type: "string" }
            },
            workspaceProjectList: {
                type: "array", 
                description: "工作区项目名集合",
                items: { type: "string" }
            }
        },
        required: ["projectNameList", "workspaceProjectList"]
    },
};

6. 业务逻辑实现

上传功能核心代码

export async function handleUploadRule(request: any, filePaths: string[], teamSelection: string, ruleTypeName: string) {
    try {
        // 1. 验证文件路径
        const invalidPaths = filePaths.filter(path => !isAbsolute(path));
        if (invalidPaths.length > 0) {
            return createErrorResponse("文件路径不是绝对路径", invalidPaths.join(', '));
        }

        // 2. 读取文件内容
        const formData = new FormData();
        for (const filePath of filePaths) {
            const content = await readFile(filePath, 'utf-8');
            const fileName = basename(filePath);
            formData.append('file', Buffer.from(content), {
                filename: fileName,
                contentType: getMimeType(extname(filePath))
            });
        }

        // 3. 添加其他参数
        formData.append('team', teamId);
        formData.append('ruleTypeId', ruleTypeId);
        formData.append('creator', getCurrentUserEmail());

        // 4. 发送请求
        const response = await axios.post(`${baseUrl}/api/rules/upload`, formData, {
            headers: { 'Content-Type': 'multipart/form-data' },
            timeout: 30000
        });

        return {
            content: [{ type: "text", text: "上传成功!" }],
            isError: false
        };
    } catch (error) {
        return createErrorResponse("上传失败", error.message);
    }
}

下载功能核心代码

export async function handleDownloadRules(request: any, projectNameList: string[], workspaceProjectList: string[]) {
    try {
        // 1. 构建请求参数
        const params = new URLSearchParams();
        projectNameList.forEach(name => params.append('projectNameList', name));

        // 2. 发起下载请求
        const response = await fetch(`${baseUrl}/api/rules/download?${params}`);
        const data = await response.json();

        // 3. 保存文件到本地
        const savedFiles: string[] = [];
        for (const fileInfo of data.files) {
            const targetPath = await determineFilePath(fileInfo.filePath, projectNameList);
            await ensureDirectoryExists(dirname(targetPath));
            await writeFile(targetPath, fileInfo.content, 'utf-8');
            savedFiles.push(targetPath);
        }

        return {
            content: [{
                type: "text",
                text: `下载完成!保存了 ${savedFiles.length} 个文件`
            }],
            isError: false
        };
    } catch (error) {
        return createErrorResponse("下载失败", error.message);
    }
}

7. 工具函数

错误处理

export function createErrorResponse(title: string, message: string) {
    return {
        content: [{
            type: "text",
            text: `❌ **${title}**\n\n${message}`
        }],
        isError: true
    };
}

文件路径处理

export async function determineFilePath(remotePath: string, projectList: string[]): Promise<string> {
    const hasCursorRules = remotePath.includes('.cursor/rules');
    
    if (hasCursorRules) {
        // 保存到 .cursor/rules 目录
        const projectName = extractProjectName(remotePath);
        const projectPath = await findProjectPath(projectName, projectList);
        return join(projectPath, '.cursor', 'rules', getRelativePath(remotePath));
    } else {
        // 保存到 ProjectRules 目录
        const parentDir = dirname(process.cwd());
        return join(parentDir, 'ProjectRules', remotePath);
    }
}

8. 构建和部署

构建脚本

{
  "scripts": {
    "build": "tsc && chmod 755 dist/index.js",
    "dev": "NODE_OPTIONS=\"--loader ts-node/esm\" node src/index.ts",
    "start": "node dist/index.js"
  }
}

postbuild处理

// scripts/postbuild.mjs
import { readFileSync, writeFileSync } from 'fs';

// 添加shebang到入口文件
const indexPath = './dist/index.js';
let content = readFileSync(indexPath, 'utf8');
if (!content.startsWith('#!/usr/bin/env node')) {
    content = '#!/usr/bin/env node\n' + content;
    writeFileSync(indexPath, content);
}

9. 关键实现要点

MCP通信协议

  • 使用stdio模式进行标准输入输出通信
  • 支持工具列表查询和工具调用
  • 返回标准化的响应格式

文件处理策略

  • 支持多文件批量操作
  • 智能识别项目结构
  • 自动创建目录结构

错误处理机制

  • 统一的错误响应格式
  • 详细的错误信息提示
  • 优雅的异常处理

工作区集成

  • 自动检测工作区项目
  • 支持单项目和多项目模式
  • 智能文件路径解析

10. 测试和调试

本地测试

# 开发模式
npm run dev

# 构建测试
npm run build
npm start

MCP客户端集成测试

{
  "mcpServers": {
    "rule-server": {
      "command": "node",
      "args": ["/path/to/dist/index.js"]
    }
  }
}

通过以上步骤,即可完成一个功能完整的MCP服务器实现。

❌