普通视图

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

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

作者 Moment
2025年12月9日 07:47

我正在开发 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懒加载与组件分包的极致优化

作者 小马写码
2025年12月9日 07:24

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

在现代前端开发中,随着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应用吧,让每一个字节的加载都有其价值,让每一次用户交互都流畅自然。

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

昨天 — 2025年12月8日掘金 前端

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

2025年12月8日 22:27

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 基础

2025年12月8日 22:25

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】我们的目标,无限宇宙!!

作者 大怪v
2025年12月8日 21:22

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

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

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几个实用命令

作者 168169
2025年12月8日 20:17

前言

最近接到公司安全团队的项目风险表格,列出了当前前端项目的一些安全风险,大概有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表单状态管理

作者 dorisrv
2025年12月8日 18:15

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

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


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

昨天以前掘金 前端

Vue Router 组件内路由钩子全解析

作者 北辰alk
2025年12月6日 11:38

一、什么是组件内路由钩子?

在 Vue Router 中,组件内路由钩子(也称为导航守卫)是在路由变化时自动调用的特殊函数,它们允许我们在特定时机执行自定义逻辑,比如:

  • • 权限验证(是否登录)
  • • 数据预加载
  • • 页面离开确认
  • • 滚动行为控制
  • • 动画过渡处理
// 一个简单的示例
export default {
  name'UserProfile',
  beforeRouteEnter(to, from, next) {
    console.log('组件还未创建,但即将进入...')
    next()
  }
}

二、三大核心钩子函数详解

Vue Router 提供了三个主要的组件内路由钩子,它们组成了一个完整的导航生命周期:

1. beforeRouteEnter - 进入前的守卫

调用时机:在组件实例被创建之前调用,此时组件还未初始化。

特点

  • • 不能访问 this(因为组件实例还未创建)
  • • 可以通过回调函数访问组件实例
export default {
  beforeRouteEnter(to, from, next) {
    // ❌ 这里不能使用 this
    console.log('from'from.path// 可以访问来源路由
    
    // ✅ 通过 next 的回调访问组件实例
    next(vm => {
      console.log('组件实例:', vm)
      vm.loadData(to.params.id)
    })
  },
  
  methods: {
    loadData(id) {
      // 加载数据逻辑
    }
  }
}

适用场景

  • • 基于路由参数的权限验证
  • • 预加载必要数据
  • • 重定向到其他页面

2. beforeRouteUpdate - 路由更新守卫

调用时机:在当前路由改变,但组件被复用时调用。

常见情况

  • • 从 /user/1 导航到 /user/2
  • • 查询参数改变:/search?q=vue → /search?q=react
export default {
  data() {
    return {
      usernull
    }
  },
  
  beforeRouteUpdate(to, from, next) {
    // ✅ 可以访问 this
    console.log('路由参数变化:'from.params.id'→', to.params.id)
    
    // 重新加载数据
    this.fetchUserData(to.params.id)
    
    // 必须调用 next()
    next()
  },
  
  methods: {
    async fetchUserData(id) {
      const response = await fetch(`/api/users/${id}`)
      this.user = await response.json()
    }
  }
}

实用技巧:使用这个钩子可以避免重复渲染,提升性能。

3. beforeRouteLeave - 离开前的守卫

调用时机:在离开当前路由时调用。

重要特性

  • • 可以阻止导航
  • • 常用于保存草稿或确认离开
export default {
  data() {
    return {
      hasUnsavedChangesfalse,
      formData: {
        title'',
        content''
      }
    }
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm(
        '您有未保存的更改,确定要离开吗?'
      )
      
      if (answer) {
        next() // 允许离开
      } else {
        next(false// 取消导航
      }
    } else {
      next() // 直接离开
    }
  },
  
  methods: {
    onInput() {
      this.hasUnsavedChanges = true
    },
    
    save() {
      // 保存逻辑
      this.hasUnsavedChanges = false
    }
  }
}

三、完整导航流程图

让我们通过一个完整的流程图来理解这些钩子的执行顺序:

是

否

是

next

next false

beforeRouteEnter 特殊处理
无法访问 this通过 next 回调访问实例开始导航组件是否复用?调用 beforeRouteUpdate调用 beforeRouteEnter组件内部处理确认导航 next创建组件实例执行 beforeRouteEnter 的回调渲染组件用户停留页面用户触发新导航?调用 beforeRouteLeave允许离开?执行新导航停留在当前页面

四、实际项目中的应用案例

案例1:用户权限验证系统

// UserProfile.vue
export default {
  beforeRouteEnter(to, from, next) {
    // 检查用户是否登录
    const isAuthenticated = checkAuth()
    
    if (!isAuthenticated) {
      // 未登录,重定向到登录页
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else if (!hasPermission(to.params.id)) {
      // 没有权限,重定向到403页面
      next('/403')
    } else {
      // 允许访问
      next()
    }
  },
  
  beforeRouteLeave(to, from, next) {
    // 如果是管理员,记录操作日志
    if (this.user.role === 'admin') {
      logAdminAccess(from.fullPath, to.fullPath)
    }
    next()
  }
}

案例2:电商商品详情页优化

// ProductDetail.vue
export default {
  data() {
    return {
      productnull,
      relatedProducts: []
    }
  },
  
  beforeRouteEnter(to, from, next) {
    // 预加载商品基础信息
    preloadProduct(to.params.id)
      .then(product => {
        next(vm => {
          vm.product = product
          // 同时开始加载相关商品
          vm.loadRelatedProducts(product.category)
        })
      })
      .catch(() => {
        next('/404'// 商品不存在
      })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 商品ID变化时,平滑过渡
    this.showLoading = true
    this.fetchProductData(to.params.id)
      .then(() => {
        this.showLoading = false
        next()
      })
      .catch(() => {
        next(false// 保持当前商品
      })
  },
  
  methods: {
    async fetchProductData(id) {
      const [product, related] = await Promise.all([
        api.getProduct(id),
        api.getRelatedProducts(id)
      ])
      this.product = product
      this.relatedProducts = related
    },
    
    loadRelatedProducts(category) {
      // 异步加载相关商品
    }
  }
}

五、高级技巧与最佳实践

1. 组合式API中的使用

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

export default {
  setup() {
    const unsavedChanges = ref(false)
    
    // 使用组合式API守卫
    onBeforeRouteLeave((to, from) => {
      if (unsavedChanges.value) {
        return confirm('确定要离开吗?')
      }
    })
    
    onBeforeRouteUpdate(async (to, from) => {
      // 处理路由参数更新
      await loadData(to.params.id)
    })
    
    return { unsavedChanges }
  }
}

2. 异步操作的优雅处理

export default {
  beforeRouteEnter(tofromnext) {
    // 使用async/await
    const enterGuard = async () => {
      try {
        const isValid = await validateToken(to.query.token)
        if (isValid) {
          next()
        } else {
          next('/invalid-token')
        }
      } catch (error) {
        next('/error')
      }
    }
    
    enterGuard()
  }
}

3. 避免常见的坑

坑1:忘记调用 next()

// ❌ 错误示例 - 会导致导航挂起
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    // 忘记调用 next()
  }
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    next()
  } else {
    next('/login')
  }
}

坑2:beforeRouteEnter 中直接修改数据

// ❌ 错误示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 避免直接修改响应式数据
    vm.someData = 'value' // 可能导致响应式问题
  })
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    vm.$nextTick(() => {
      vm.someData = 'value' // 在下一个tick中修改
    })
  })
}

六、与其他导航守卫的配合

组件内守卫还可以与全局守卫、路由独享守卫配合使用:

// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('全局守卫 → 组件守卫')
  next()
})

// 路由配置中的独享守卫
const routes = [
  {
    path'/user/:id',
    componentUserProfile,
    beforeEnter(to, from, next) => {
      console.log('路由独享守卫 → 组件守卫')
      next()
    }
  }
]

执行顺序

    1. 导航被触发
    1. 调用全局 beforeEach
    1. 调用路由配置中的 beforeEnter
    1. 调用组件内的 beforeRouteEnter
    1. 导航被确认
    1. 调用全局的 afterEach

七、性能优化建议

1. 懒加载守卫逻辑

export default {
  beforeRouteEnter(to, from, next) {
    // 按需加载验证模块
    import('@/utils/auth').then(module => {
      if (module.checkPermission(to.meta.requiredRole)) {
        next()
      } else {
        next('/forbidden')
      }
    })
  }
}

2. 缓存验证结果

let authCache = null

export default {
  beforeRouteEnter(to, from, next) {
    if (authCache === null) {
      // 首次验证
      checkAuth().then(result => {
        authCache = result
        handleNavigation(result, next)
      })
    } else {
      // 使用缓存结果
      handleNavigation(authCache, next)
    }
  }
}

总结

Vue Router 的组件内路由钩子为我们提供了强大的导航控制能力。通过合理使用这三个钩子函数,我们可以:

  1. 1. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
  2. 2. beforeRouteUpdate:优化动态参数页面的用户体验
  3. 3. beforeRouteLeave:防止用户意外丢失未保存的数据

记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。

零硬件交互:如何用纯前端把摄像头变成 4000 个粒子的魔法棒?

作者 兔子零1024
2025年12月6日 10:58

关键词:Vue 3 / Three.js / MediaPipe / AI / WebGL / 创意编程 / 前端工程化

引言:当哈利波特的魔法棒变成了一行 URL

想象这样一个场景:

你不需要购买昂贵的 Apple Vision Pro,也不需要戴上笨重的 VR 头盔,甚至不需要安装任何 App。 你只需要打开一个网页,允许摄像头权限,对着屏幕伸出手。

握拳,屏幕上 4000 个原本漂浮的粒子瞬间向中心坍缩,聚集成一个紧密的能量球; 张手,这些粒子仿佛受到冲击,瞬间向四周炸裂,如同烟花般绚烂; 挥动,粒子流随着你的指尖起舞,从地球变成爱心,从爱心变成土星。

这就是我最近做的一个开源小项目 —— Hand Controlled 3D Particles

在过去,这种级别的体感交互往往意味着:专业的深度摄像头(Kinect)、高性能的本地显卡、以及复杂的 C++/Unity 开发环境。 但现在,得益于 Web AI 和 WebGL 的进化,我们用纯前端技术就能复刻这种魔法。

一、效果:指尖上的粒子宇宙

在这个项目里,你的手就是控制一切的遥控器。

1. 实时手势识别

基于 Google MediaPipe Hands 模型,浏览器能以惊人的速度(60fps+)捕捉你手部的 21 个关键点。 这不是简单的"动量检测",而是真正的"骨骼识别":

  • 拇指与食指捏合/靠近 → 触发引力场
  • 手掌完全张开 → 触发斥力场
  • 左右手势切换 → 切换 3D 模型形态

2. 视觉形态演变

粒子不仅仅是散乱的点,它们按照数学公式排列成 5 种形态:

  • Earth(地球):经典的 Fibonacci 球面分布
  • Heart(爱心):浪漫的心形数学曲线
  • Saturn(土星):带光环的行星系统
  • Tree(圣诞树):圆锥螺旋分布
  • Fireworks(烟花):完全随机的爆炸效果

这一切,都运行在一个普通的 Chrome 浏览器里。

二、技术解构:三驾马车如何协同?

整个项目使用 Vue 3 + TypeScript 构建,核心逻辑其实非常简单,主要依赖三个技术的有机结合。

1. The Eye: MediaPipe Hands

这是 Google 开源的轻量级机器学习模型。它的特点是:快,极快。 它不需要将视频流上传到云端处理,而是直接在浏览器端(WASM)利用 GPU 加速推理。

核心逻辑只需要关注两个关键点:

  • Landmark 4 (拇指尖)
  • Landmark 8 (食指尖)
// 计算拇指和食指的欧几里得距离
const distance = Math.sqrt(
  (thumbTip.x - indexTip.x) ** 2 + 
  (thumbTip.y - indexTip.y) ** 2
)

// 设定阈值进行状态判断
if (distance < 0.08) {
  // 触发"握拳/收缩"状态
  emit('contract')
} else if (distance > 0.16) {
  // 触发"张开/爆炸"状态
  emit('explode')
}

简单的高中数学,就能把连续的模拟信号转化为离散的交互指令。

2. The Canvas: Three.js 粒子系统

4000 个粒子在 Three.js 中并不是 4000 个独立的 Mesh(那样浏览器会卡死),而是一个 THREE.Points 系统。 所有的粒子共享一个 Geometry,通过 BufferAttribute 来管理每个粒子的位置。

不同形态的切换,本质上是目标位置(Target Position)的计算。比如心形曲线:

// 心形参数方程
const x = 16 * Math.pow(Math.sin(t), 3)
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)
const z = t * 2 // 增加一点厚度

3. The Motion: 简单的物理插值

为了让动画看起来自然,粒子不是"瞬移"到新位置的,而是每一帧都向目标位置"滑行"一点点。

// 每一帧的渲染循环中
for (let i = 0; i < particleCount; i++) {
  // 当前位置 += (目标位置 - 当前位置) * 阻尼系数
  positions[i] += (targetPositions[i] - positions[i]) * 0.08
}

这个简单的公式(Lerp,线性插值)赋予了粒子重量感和惯性。

三、Web AI 的启示:算力下沉与隐私红利

做完这个项目,我有几个关于 Web 技术演进的强烈感受。

1. 算力正在向端侧回流

在云计算主导的十年里,我们习惯了把一切丢给服务器。但 MediaPipe 这类 Web AI 技术的成熟,标志着端侧算力的觉醒。 现在的手机和笔记本显卡足够强大,可以在浏览器里跑百亿参数以下的小模型。 这意味着:零延迟的交互体验。你的手一动,粒子立马跟着动,不需要等待网络请求往返。

2. 隐私是最大的护城河

用户越来越介意"上传摄像头视频"。 纯前端实现的 AI 有一个天然优势:所有计算都在本地发生。 视频流只在内存里流转,从未离开过用户的设备。这种"隐私安全感"是云端 AI API 无法比拟的。

3. 创意的门槛在指数级降低

五年前,做这个效果需要懂 OpenCV、懂 C++、懂 Shader 编程。 现在,你只需要会写 JavaScript,调几个 npm 包。 技术在变得越来越平民化(Democratization),这让开发者能把精力从"怎么实现"转移到"做什么好玩的东西"上。

四、未来:还可以怎么玩?

这个 Demo 只是一个起点,它展示了 Web 交互的一种新范式——自然用户界面(NUI)

基于这个架子,我们完全可以扩展出更多玩法:

  • WebAR 营销:在电商页面,用手势"隔空"旋转商品模型;
  • 无接触展示:在博物馆或展厅的大屏上,观众挥手就能翻页、缩放展品;
  • 音乐可视化:让粒子不仅仅跟随手势,还随着背景音乐的频谱跳动;
  • 多人远程互动:结合 WebRTC,让异地的两个人通过手势共同控制同一个 3D 场景。

五、最后

项目已开源,欢迎 Fork 玩耍,或者直接点个 Star ⭐️。

GitHub: github.com/wangmiaozer…

Flutter PopScope:iOS左滑返回失效分析与方案探讨

作者 stringwu
2025年12月6日 09:47

本文发布于公众号:移动开发那些事:Flutter PopScope:iOS左滑返回失效分析与方案探讨

1 背景

Flutter 3.10版本中,WillPopScope被弃用并替换为更现代化、功能更强大的PopScope组件。PopScope主要用于拦截用户尝试退出当前路由的各种操作(如Android的返回键、AppBar的返回箭头、以及iOS的屏幕边缘左滑手势)。

PopScope的核心属性有两个:

  • canPop:一个布尔值,设置为 false 时,会阻止路由退出。
  • onPopInvokedWithResult:当尝试退出路由时触发的回调,并返回是否成功退出(即didPop)。

在开发过程中,笔者发现了一个显著的平台差异:

  • Android/其它平台: 当我设置 canPop = false 并尝试使用返回键或返回箭头时,onPopInvokedWithResult 被调用。
  • iOS: 当我设置 canPop = false 并尝试使用屏幕边缘左滑手势 (Edge Swipe Gesture) 时,onPopInvokedWithResult 不会被调用,并且左滑手势会被系统完全忽略,导致应用看起来“卡住”了,没有提供任何用户反馈。

这无疑是一个严重的问题,它导致我们在iOS上无法通过PopScope来捕获左滑手势并执行自定义逻辑(如弹出“是否保存”对话框)。

一个典型的使用示例为:

PopScope(
    canPop: false,
    onPopInvokedWithResult: (value, _) {
    // 在iOS平台这里永远不会被回调
        if (!value) {
          // do custom action here
          }
          return;
     },
     child: Container())

2 失效原因分析

2.1 PopScope失效原因

PopScope组件是通过 Platform Navigator 与原生平台的返回事件机制进行通信的, 主要负责监听和阻止 Navigator.pop() 调用或系统触发的 返回操作(Android 返回键,或者 iOS 的左滑返回)。

iOS 左滑有特殊性: iOS 的左滑手势(Interactive Pop Gesture)是独立于 Flutter Navigator 运行的原生手势。当这个手势被启动时,如果它发现 PopScope 设置了 canPop: false,它会简单地取消手势并停止,而不会向 Flutter 的 Navigator 发送一个明确的弹出(Pop)请求。

因此,onPopInvokedWithResult 回调自然不会被触发,因为它只在 Flutter 接收到 Navigator.pop() 调用或系统返回键事件时触发。

  • Android: Android的返回键事件(Back Button Event)是一个明确的、可拦截的系统事件,它会直接传递给Flutter引擎,引擎继而触发PopScope的回调。
  • iOS (左滑手势): iOS的左滑返回手势(Interactive Pop Gesture)是由UINavigationController管理的。

在默认情况下,当一个Flutter View(FlutterViewController)被推入到原生导航栈中时:

  • 如果设置了 canPop = false,Flutter引擎会通知原生层“当前路由不可退出”。
  • 在iOS上,这个通知只阻止系统尝试自动执行 Pop 操作,但没有将“用户进行了左滑手势”这一手势输入事件转化为一个通用的“Pop 尝试”事件并传给PopScope。相反,系统只是简单地禁用了这个手势的默认行为,并未向上层报告任何事件。

简而言之,Android传递的是一个“意图”(返回键被按下),而iOS传递的是一个“手势”,且当手势被禁用时,这个“意图”就从未产生。

2.2 源码分析

通过分析Flutter源码,我们发现问题的核心在于 ModalRoutepopGestureEnabled 属性判断逻辑。当 PopScope.canPop 设置为false 时,路由的popDisposition 会被设置为 RoutePopDisposition.doNotPop ,这会导致 popGestureEnabled 变为 false ,从而禁用iOS的侧滑手势识别。

具体来说,在 CupertinoBackGestureDetector 中,有一个 enabledCallback 回调用于控制手势检测器的启用状态。当 enabledCallback 返回 false 时,手势检测器不会记录触摸起点,后续的一系列手势监听方法都不会被触发。

3 如何解决?

要解决这个问题,我们不能等待Flutter引擎去捕获一个它根本没收到的事件。我们必须在原生iOS层工作,主动拦截这个左滑手势,并在拦截成功后,手动地将其作为一个事件发送给Flutter。

我们的目标是:当用户在屏幕边缘左滑时,即使canPopfalse,也要触发onPopInvokedWithResult。这里采用了在原生层面拦截左滑的手势,并传递给Flutter层的方案。

这里在原生的核心代码为:

  /// 如果需要,设置左滑返回手势拦截(在这个plugin初始化时调用)
  ///
  /// 该方法会检查 rootViewController 的类型:
  /// - 如果是 UINavigationController,直接使用
  /// - 如果是 FlutterViewController,会创建或使用现有的 NavigationController
  private func setupInteractivePopGestureIfNeeded() {
  guard let window = UIApplication.shared.windows.first,
         let rootViewController = window.rootViewController else {
                return
            }

        if let navController = rootViewController as? UINavigationController {
                // 直接使用现有的 NavigationController
                self.navigationController = navController
         } else if let flutterVC = rootViewController as? FlutterViewController {
                // 如果 FlutterViewController 已经有 navigationController,直接使用
                if let existingNavController = flutterVC.navigationController {
                    self.navigationController = existingNavController
                } else {
                    // 否则创建新的 NavigationController 并封装 FlutterViewController
                    // 先将 window.rootViewController 设置为 nil,避免视图层次冲突
                    window.rootViewController = nil
                    let newNavController = UINavigationController(rootViewController: flutterVC)
                    self.navigationController = newNavController
                    // 再设置新的 NavigationController 为 rootViewController
                    window.rootViewController = newNavController
                }
         }
    setupInteractivePopGesture()
  }


  /// 设置左滑返回手势的拦截
  ///
  /// 保存原始的手势识别器代理,然后将自己设置为新的代理,以便拦截手势事件
  private func setupInteractivePopGesture() {
    // 保存原始的代理
    self.originalDelegate = self.navigationController?.interactivePopGestureRecognizer?.delegate
    
    // 设置自己为代理
    self.navigationController?.interactivePopGestureRecognizer?.delegate = self
  }

    /// 控制手势识别器是否应该开始识别手势
  ///
  /// 当检测到左滑返回手势时,会通知 Flutter 层处理,并返回 false 阻止系统默认行为
  public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    // 检测到系统左滑手势,发送事件给 Flutter  关键点:当发现是对应事件时,发送给Flutter层
    if gestureRecognizer == self.navigationController?.interactivePopGestureRecognizer {
      channel?.invokeMethod("onSystemBackGesture", arguments: nil)
      // 返回 false 阻止系统默认的返回行为,由 Flutter 层处理
      return false
    }
    
    // 其他手势交由原来的代理处理
    if let originalDelegate = self.originalDelegate {
      return originalDelegate.gestureRecognizerShouldBegin?(gestureRecognizer) ?? true
    }
    
    return true
  }

而在Flutter层,则监听对应的方法回调就可以:

  /// 处理来自原生端的方法调用
  Future<dynamic> _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'onSystemBackGesture':
        // 当接收到系统返回手势时
        debugPrint('PopscopeIos: onSystemBackGesture pop');
        // 1. 如果设置了自动处理导航,尝试调用 maybePop()
        if (_autoHandleNavigation) {
          final navigator = _navigatorKey?.currentState;
          if (navigator != null) {
          // 关键点2: 这里其实就是调用导航的api: Navigator.maybePop(context);
            await navigator.maybePop();
          } else {
            debugPrint('PopscopeIos: NavigatorState is null, cannot pop');
          }
        }
        
        // 2. 调用用户自定义回调(无论是否自动处理)
        _onSystemBackGesture?.call();
        break;
      default:
        throw MissingPluginException('未实现的方法: ${call.method}');
    }
  }

当调用了Navigator.maybePop(context)的方法后,不管你PopScope里的canPop的值设置为什么,对应的回调方法onPopInvokedWithResult都会被调用了;

这个库对应的代码放在github:github.com/WoodJim/pop…

4 总结

通过在原生iOS层面上介入,我们成功地将iOS的左滑手势事件手动转化为一个Flutter可识别的 Pop 事件,从而弥补了原生和框架之间的差异。

现在,无论是Android的返回键还是iOS的左滑手势,当PopScopecanPop设置为false时,onPopInvokedWithResult回调都能可靠地被触发,确保了跨平台一致的用户体验,让开发者能够真正无忧地处理路由拦截逻辑。

但这个方案有个致命的缺点:只能保证有回调,无法做到跟手的左滑效果,如果想做到很丝滑的跟手的处理,还是需要去研究CupertinoPageTransitionsBuilder 相关的代码来看如何实现,但当项目使用GetX来做路由时,好像不管怎样设置这个CupertinoPageTransitionsBuilder都无法生效。

如果大家有其他更好的方案的话,欢迎一起探讨。

CSS中常使用的函数

2025年12月6日 09:10

除了 clamp(),CSS 还有很多功能强大的函数。以下是分类介绍:

一、尺寸与计算相关函数

1. min()  - 取最小值

css

.element {
  width: min(50%, 500px); /* 取50%和500px中的较小值 */
  padding: min(2vw, 20px);
}

2. max()  - 取最大值

css

.element {
  width: max(300px, 50%); /* 至少300px */
  font-size: max(1rem, 12px);
}

3. calc()  - 数学计算

css

/* 基本计算 */
.element {
  width: calc(100% - 2rem);
  height: calc(50vh + 100px);
}

/* 复杂计算 */
.grid-item {
  width: calc((100% - 3 * 20px) / 4); /* 4列网格 */
}

/* 嵌套计算 */
.element {
  font-size: calc(var(--base-size) * 1.2);
}

4. fit-content()  - 内容适应

css

.container {
  width: fit-content(500px); /* 不超过500px的内容宽度 */
  margin: 0 auto;
}

/* 表格列自适应 */
td {
  width: fit-content;
}

二、clamp() 函数详解

语法

clamp(min, preferred, max)
  • min:最小值(下限)
  • preferred:首选值(通常使用相对单位)
  • max:最大值(上限)

工作原理

/* 实际值会在这个范围内:min ≤ preferred ≤ max */
width: clamp(200px, 50%, 800px);
/* 意思是:宽度最小200px,首选50%视口宽度,最大800px */

实际应用示例

1. 响应式字体大小

/* 字体在16px-24px之间,首选3vw(视口宽度的3%) */
.font-responsive {
  font-size: clamp(16px, 3vw, 24px);
}

/* 标题响应式 */
h1 {
  font-size: clamp(2rem, 5vw, 3.5rem);
}

2. 响应式容器宽度

.container {
  width: clamp(300px, 80%, 1200px);
  margin: 0 auto;
}

/* 图片自适应 */
img {
  width: clamp(150px, 50%, 600px);
  height: auto;
}

三、渐变与图像函数

1. linear-gradient()  - 线性渐变

css

/* 基本渐变 */
.gradient-1 {
  background: linear-gradient(45deg, red, blue);
}

/* 多色渐变 */
.gradient-2 {
  background: linear-gradient(
    to right,
    red 0%,
    orange 25%,
    yellow 50%,
    green 75%,
    blue 100%
  );
}

/* 透明渐变 */
.gradient-3 {
  background: linear-gradient(
    to bottom,
    transparent,
    rgba(0,0,0,0.5)
  );
}

2. radial-gradient()  - 径向渐变

css

/* 圆形渐变 */
.radial-1 {
  background: radial-gradient(circle, red, yellow, green);
}

/* 椭圆渐变 */
.radial-2 {
  background: radial-gradient(
    ellipse at center,
    red 0%,
    blue 100%
  );
}

/* 重复径向渐变 */
.radial-3 {
  background: repeating-radial-gradient(
    circle,
    red,
    red 10px,
    blue 10px,
    blue 20px
  );
}

3. conic-gradient()  - 锥形渐变

css

/* 色轮 */
.conic-1 {
  background: conic-gradient(
    red, yellow, lime, aqua, blue, magenta, red
  );
}

/* 饼图效果 */
.pie-chart {
  background: conic-gradient(
    red 0% 33%,
    yellow 33% 66%,
    blue 66% 100%
  );
}

4. image-set()  - 响应式图像

css

/* 根据分辨率加载不同图片 */
.responsive-bg {
  background-image: image-set(
    "image-low.jpg" 1x,
    "image-high.jpg" 2x,
    "image-ultra.jpg" 3x
  );
}

/* 格式支持 */
.optimized-bg {
  background-image: image-set(
    "image.avif" type("image/avif"),
    "image.webp" type("image/webp"),
    "image.jpg" type("image/jpeg")
  );
}

四、变换与动画函数

1. translate() / translateX() / translateY()

css

.element {
  transform: translate(50px, 100px);
  transform: translateX(100px);
  transform: translateY(50%);
}

/* 3D变换 */
.element-3d {
  transform: translate3d(100px, 50px, 20px);
}

2. scale() / scaleX() / scaleY()

css

/* 缩放 */
.element {
  transform: scale(1.5);          /* 整体放大1.5倍 */
  transform: scale(1.2, 0.8);     /* 宽放大,高缩小 */
  transform: scaleX(2);           /* 水平放大2倍 */
}

3. rotate()

css

/* 旋转 */
.element {
  transform: rotate(45deg);      /* 45度旋转 */
  transform: rotate(1turn);      /* 360度旋转 */
  transform: rotate3d(1, 1, 1, 45deg); /* 3D旋转 */
}

4. matrix() / matrix3d()

css

/* 矩阵变换(组合所有变换) */
.complex-transform {
  transform: matrix(1, 0, 0, 1, 100, 50);
  /* 等同于:translate(100px, 50px) */
}

5. cubic-bezier()  - 贝塞尔曲线

css

/* 自定义缓动函数 */
.animation {
  animation: move 2s cubic-bezier(0.68, -0.55, 0.27, 1.55);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* 预设曲线 */
.ease-in-out { transition-timing-function: ease-in-out; }
.custom-ease { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }

6. steps()  - 步进动画

css

/* 逐帧动画 */
.sprite-animation {
  animation: walk 1s steps(8) infinite;
}

/* 打字机效果 */
.typewriter {
  animation: typing 3s steps(40) forwards;
}

五、滤镜效果函数

1. blur()  - 模糊

css

.blur-effect {
  filter: blur(5px);
  backdrop-filter: blur(10px); /* 背景模糊 */
}

2. brightness()  - 亮度

css

.image {
  filter: brightness(150%); /* 变亮 */
  filter: brightness(50%);  /* 变暗 */
}

3. contrast()  - 对比度

css

.photo {
  filter: contrast(200%); /* 增加对比度 */
}

4. drop-shadow()  - 阴影

css

/* 比 box-shadow 更符合元素形状 */
.icon {
  filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.5));
}

/* 多重阴影 */
.text {
  filter: 
    drop-shadow(1px 1px 0 white)
    drop-shadow(-1px -1px 0 white);
}

5. 组合滤镜

css

.instagram-filter {
  filter: 
    brightness(1.2)
    contrast(1.1)
    saturate(1.3)
    sepia(0.1);
}

六、布局与网格函数

1. minmax()  - 网格尺寸范围

css

.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}

/* 复杂的网格布局 */
.layout {
  grid-template-columns: 
    minmax(200px, 300px)
    minmax(auto, 1fr)
    minmax(150px, 200px);
}

2. repeat()  - 重复模式

css

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 重复3次 */
  grid-template-rows: repeat(auto-fill, minmax(100px, auto));
}

/* 命名网格线 */
.complex-grid {
  grid-template-columns: 
    [sidebar-start] 250px [sidebar-end content-start] 
    repeat(2, [col] 1fr)
    [content-end];
}

3. fit-content()  - 网格尺寸

css

.grid-item {
  grid-column: 1 / fit-content(500px);
}

七、其他实用函数

1. var()  - CSS 变量

css

:root {
  --primary-color: #3498db;
  --spacing: 1rem;
  --font-family: 'Roboto', sans-serif;
}

.element {
  color: var(--primary-color);
  padding: var(--spacing);
  font-family: var(--font-family);
  
  /* 默认值 */
  margin: var(--custom-margin, 10px);
}

2. attr()  - 属性值

css

/* 显示 data-* 属性值 */
.tooltip::after {
  content: attr(data-tooltip);
}

/* 配合计数器 */
.item::before {
  content: attr(data-index);
}

/* 动态样式 */
.progress {
  width: attr(data-progress percent);
}

3. counter() / counters()  - 计数器

css

/* 自动编号 */
ol {
  counter-reset: section;
}

li::before {
  counter-increment: section;
  content: counter(section) ". ";
}

/* 嵌套计数器 */
ol ol {
  counter-reset: subsection;
}

li li::before {
  counter-increment: subsection;
  content: counter(section) "." counter(subsection) " ";
}

4. url()  - 资源路径

css

/* 图片 */
.background {
  background-image: url("image.jpg");
  background-image: url("data:image/svg+xml,..."); /* 内联SVG */
}

/* 字体 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
}

/* 光标 */
.custom-cursor {
  cursor: url('cursor.png'), auto;
}

5. env()  - 环境变量

css

/* 安全区域(适配刘海屏) */
.safe-area {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

/* 视口单位 */
.full-height {
  height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
}

八、函数组合使用示例

复杂响应式设计

css

:root {
  --min-width: 320px;
  --max-width: 1200px;
  --fluid-scale: min(max(var(--min-width), 100vw), var(--max-width));
}

.container {
  /* 组合多个函数 */
  width: clamp(
    var(--min-width),
    calc(100vw - 2 * var(--spacing)),
    var(--max-width)
  );
  
  padding: var(--spacing);
  
  /* 响应式字体 */
  font-size: clamp(
    1rem,
    calc(0.5rem + 1vw),
    1.5rem
  );
  
  /* 响应式渐变背景 */
  background: linear-gradient(
    to bottom right,
    hsl(calc(220 + var(--hue-adjust)) 70% 50% / 0.9),
    hsl(calc(280 + var(--hue-adjust)) 60% 40% / 0.8)
  );
  
  /* 动态阴影 */
  box-shadow: 
    0 calc(2px + 0.1vw) calc(4px + 0.2vw) 
    color-mix(in srgb, currentColor 20%, transparent);
}

现代按钮组件

css

.button {
  /* 尺寸响应式 */
  padding: clamp(0.5rem, 2vw, 1rem) clamp(1rem, 4vw, 2rem);
  
  /* 颜色动态 */
  background: color-mix(
    in srgb, 
    var(--primary-color) 
    calc(var(--hover, 0) * 20%), 
    black
  );
  
  /* 交互效果 */
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  
  /* 悬停状态 */
  &:hover {
    --hover: 1;
    transform: translateY(calc(-1 * var(--hover, 0) * 2px));
    filter: brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2));
  }
}

十、浏览器支持与回退方案

渐进增强策略

css

/* 1. 基础样式 */
.element {
  width: 90%;
  max-width: 800px;
  min-width: 300px;
}

/* 2. 使用 @supports 检测支持 */
@supports (width: clamp(300px, 90%, 800px)) {
  .element {
    width: clamp(300px, 90%, 800px);
  }
}

/* 3. 使用 CSS 变量提供回退 */
:root {
  --fluid-width: 90%;
}

@supports (width: clamp(300px, var(--fluid-width), 800px)) {
  .element {
    --fluid-width: min(max(300px, 90%), 800px);
    width: var(--fluid-width);
  }
}

总结对比表

函数类别 核心函数 主要用途 兼容性
尺寸计算 clamp(), min(), max(), calc() 响应式尺寸 优秀
渐变背景 linear-gradient(), radial-gradient() 背景效果 优秀
变换动画 translate(), rotate(), cubic-bezier() 动画效果 优秀
滤镜效果 blur(), drop-shadow() 视觉效果 良好
网格布局 minmax(), repeat() 布局系统 优秀
变量函数 var(), attr(), counter() 动态内容 优秀

现代 CSS 函数大大增强了样式表达能力,减少了 JavaScript 的依赖,是构建现代响应式 Web 应用的重要工具。

js防抖技术:从原理到实践,如何解决高频事件导致的性能难题

作者 Neptune1
2025年12月6日 02:49

在开发应用时,各位程序开发者们是否遇到一个性能问题,就是当用户网络卡顿时,点击一个按钮短时间内没反应后疯狂点击该按钮,频繁向后端发送请求,或者疯狂输入搜索框导致页面卡顿,频繁调整窗口大小导致重排重绘暴增等常见的性能问题。

一、 防抖的定义

此时聪明的你是否能想到一个解决方法,那就是,我们何不如设置一个定时器,当该事件被触发时,我们并不立即执行这个函数,而是等待一小段时间再触发,且当在等待的时间内没有被触发,便执行一次

想象你在坐电梯的时候,当你进入时,电梯不会立即关闭,而是会等待后面的人全部进入后再关闭(把每一次点击想象成按一次电梯按钮,每一次按按钮都会重置等待时间)

此时你就明白了解决频用户频繁发起请求导致的性能问题的解决方法:防抖

防抖 :在事件被频繁执行时,函数并不立即执行,而是在最后一次触发后等待指定的延迟时间,若延迟期间无新的触发,方才执行一次

二、防抖的实现逻辑

我们先假设在html界面中,我们添加了一个button按钮,且将其id命名为btn,此时我们想要为其添加一个点击事件,使其每点击一次便输出一次hello,我们需要写下如下js代码

1.实现具有打印功能的点击事件

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
btn.addEventListenner('click',ptn)

但是你会发现,将你带入疯狂点击该按钮的用户,你就会发现,此时前端会疯狂打印hello,若不能解决这个问题则不能解决相似的前端疯狂向后端发送请求的问题。

2.实现延迟打印(?不成功)

那你此时想到了有一个函数setTimeout(),诶,这个函数好像可以起到一种延迟的效果,或许可以添加请求的间隔?让我们看看

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
function debounce(fn,wait){
   return function(){
       setTimeout(function(){
          fn()
       
       },wait)
      }

   }   
   
btn.addEventListenner('click',debounce(ptn,1000))

此时你慢慢地点几次,你或许会发现,诶,我好像实现了这个功能,每过一秒才会打印一次hello,但是但是一旦你连续快速地的点击,你就会惊奇的发现。

啊?为什么在我停下来的时候还会继续打印hello,而且好像打印的间隔不可能有一秒

失败原因

没错,因为单纯使用setTimeout而未使用闭包来保持状态(若想了解闭包,请参考往期文章《js中作用域及闭包的概念与基础应用》导致每次点击都创建了一个打印事件,事件函数频繁触发,间隔时间无法保证,这种被称为防抖失效防抖函数未能实现

3.使用闭包解决未能更新定时器的

我们知道,闭包可以起到一个保存外部函数的变量,延迟销毁的作用

使用闭包处理后的代码
const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(function(){
      
         ptn() 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

经过检验后,你发现,你成功通过闭包实现了‘‘hello’’的延迟打印效果,同样的,也可以应用在解决用户大量提交等操作导致的大量计算或布局操作,节省了服务器的算力

扩展

一、添加防抖函数导致this本应的指向改变

上述代码虽然是完成了防抖的基本功能:‘频繁触发,只执行最后一次’,但是你或许不知道的是,这份代码仍有缺陷,试试仔细观察一下这份代码,添加防抖再运行后ptn内的this指向似乎发生了改变

没错,在btn.addEventListener('click',debounce(ptn,1000))的运行时,函数体被btn调用时,其内部的ptn函数在被调用时,是独立调用的,而我们知道,当一个函数被独立调用时,其this是指向window全局的。

但是但是,在我们添加这个防抖函数前,我们是这样实现这个点击事件的btn.addEventListenner('click',ptn),此时ptnaddEventListenner触发,出现隐式绑定,this应该是指向btn的。

而在我们添加防抖函数后,却导致ptn函数的this指向了window全局对象,那我们此时就做了一件很糟糕的事情,显然我们需要将它的this指回给btn

方法一

通过提前保存函数内this指向的btn,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
      const _this = this
   
      clearTimeout(time)
      
      time = setTimeout(function(){
         
         ptn.call(_this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

方法二

通过利用箭头函数没有this对象,其内部的this是指向btn这一特点,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

二、遗漏addEventListener提供的event事件参数

当我们使用addEventListener触发一个函数时,会默认向其内部传入一个事件函数event对象以及其它对象,用于记录事件发生的详情

而我们遗漏了向防抖函数内部接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数,此时我们需要为其接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数。

通过...arg接受及结构向其内部传递

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(...arg){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this,...arg) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

到此,js防抖技术介绍为止,点赞+关注,后续继续提供实战演示题型

从爬楼梯到算斐波那契,我终于弄懂了递归和动态规划这俩 "磨人精"

2025年12月6日 00:32

最近在代码界摸爬滚打,总被两个词按在地上摩擦 —— 递归和动态规划。这俩货就像数学题里的 "小明",天天换着花样折磨人,今天就来好好扒一扒它们的底裤。

递归:自己调自己的 "套娃大师"

递归这东西,说白了就是函数自己喊自己的名字。就像小时候问妈妈 "我从哪来的",妈妈说 "你是妈妈生的",再问 "妈妈从哪来的",妈妈说 "外婆生的"... 一直问到祖宗十八代,这就是递归的精髓 —— 找规律 + 找出口

比如算个阶乘,用递归(时间复杂度过高)写出来是这样:

function mul(n){

   if(n === 1){ // 出口:问到祖宗了

       return 1

   }

   return n * mul(n-1) 

}

这代码简洁得像诗,但算个斐波那契数列就露馅了。那个 1,1,2,3,5,8... 的数列,递归写法看着简单:

递归的 "中年危机":重复计算让 CPU 原地冒烟

function fb(n){

   if(n === 1 || n === 2){

       return 1

   }

   return fb(n-1) + fb(n-2)

}

这代码算个 n=10 还行,要是算 n=40,能让你喝杯咖啡回来还没出结果。就像你查快递单号,每次都要从快递员刚取件的时候查起,哪怕昨天刚查过。

给递归装个 "备忘录":记忆化搜索救场

比如爬楼梯问题:一次能爬 1 或 2 阶,到第 n 阶有几种走法?

后来我灵机一动,给递归加了个小本本(数组 f):

const f = []
var climbStairs = function (n) {
    if (n === 1 || n === 2) {
        return n
    }
    if (f[n] === undefined) { // 查小本本,没记过才计算
        f[n] = climbStairs(n - 1) + climbStairs(n - 2)
    }
    return f[n]
};

这招叫 "记忆化搜索"(提效),相当于把算过的结果记在通讯录里,下次直接拨号不用重新查号。

后来才发现,这代码就像给老年机装了智能手机的通讯录 —— 思路对但效率不够。全局数组 f 在多组测试用例下会残留历史数据,而且递归调用本身就有函数栈的开销,n 太大时还是扛不住。

彻底换个活法:动态规划的 "自底向上" 哲学

  • 站在已知的角度,通过已知来定位未知 最后改用纯动态规划找到动态方程)写法,直接逆袭:
var climbStairs = function (n){
    const f = []
    // 先搞定已知的1楼和2楼
    f[1] = 1
    f[2] = 2
    // 从3楼开始往上爬,每步都踩在前人的肩膀上
    for(let i = 3;i<=n;i++){
        f[i] = f[i-1] + f[i-2]
    }
    return f[n]
 }

这思路就像盖楼,从 1 层开始一层层往上盖,每一层的建材都直接用前两层的,根本不用回头看。没有递归的函数调用开销,也没有重复计算,效率直接拉满。

总结:三种写法的生存现状

写法 特点 适合场景
纯递归 代码简洁如诗 理解思路用,n≤30
记忆化搜索 加了缓存的递归 教学演示,n≤1000
动态规划 自底向上迭代 实际开发,n多大都不怕

总结:什么时候该套娃,什么时候该记笔记?

  • 递归适合简单问题或调试时用,写起来爽,但容易重复劳动

  • 动态规划适合复杂问题,虽然前期要多写几行,但跑起来飞快

  • 记住:所有动态规划问题,先建个空数组当小本本准没错

现在终于明白,递归是浪漫的诗人,只顾优雅不管效率; 动态规划是务实的会计,每一笔账都记得清清楚楚。 下次再遇到这俩货,我可不会再被它们忽悠了!

柯里化

作者 jump680
2025年12月5日 23:10

函数柯里化的含义:将多个参数的函数 转化成 一个一个传入参数的函数。

目的:函数参数复用延迟执行 它使用闭包记住之前传递的参数。

image.png

✅ 使用柯里化(参数复用

我们将函数改造一下,让它先接收“规则”,返回一个专门检查这个规则的函数。

// 柯里化:第一层接收规则,第二层接收内容
function curriedCheck(reg) {
    // 闭包记住了 reg
    return function(txt) {
        return reg.test(txt);
    }
}

// 1. 参数复用:我们先生成一个“专门检查手机号”的函数// 这里我们将 reg 参数固定(复用)在了 checkPhone 函数内部
const checkPhone = curriedCheck(/^1\d{10}$/);

// 2. 以后使用,只需要传内容,不需要再传正则了
checkPhone('13800000001'); // true
checkPhone('13800000002'); // true
checkPhone('13800000003'); // true// 甚至可以再生成一个“专门检查邮箱”的函数const checkEmail = curriedCheck(/@/);
checkEmail('abc@qq.com');

结论: 在这里,正则表达式这个参数被复用了。checkPhone 就像是一个被填入了一半参数的模具,你只需要填入剩下的一半即可。

延迟执行

onClickreact 渲染的时候就会 直接求值执行

react在渲染时,onclick会执行{}中的函数。 如果 onclick={handlerDelete(id)} 那么在渲染的时候直接就执行了这个函数,还没有点击就删除了。

所以使用匿名函数 or 柯里化

匿名函数 onclick={()=> handlerDelete(id)}

柯里化:

image.png

image.png

深入理解 JavaScript 中的 “this”:从自由变量到绑定规则

2025年12月5日 22:24

🧠 深入理解 JavaScript 中的 this:从自由变量到绑定规则

this 是 JavaScript 最容易被误解的概念之一 —— 它不是由函数定义决定的,而是由调用方式决定的。”

在日常开发中,我们经常遇到这样的困惑:

  • 为什么同一个函数,有时 this 指向对象,有时却指向 window
  • 为什么在事件回调里 this 是 DOM 元素,而赋值后调用就变成全局对象?
  • 严格模式下 this 为什么会变成 undefined

本文将结合你可能写过的代码,系统梳理 this 的设计逻辑、绑定规则与常见陷阱,并告诉你:如何真正掌控 this


🔍 一、this 不是“自由变量”——它和作用域无关!

很多初学者会混淆 变量查找this 绑定,认为它们是一回事。其实:

特性 自由变量(如 myName this
查找时机 编译阶段(词法作用域) 执行阶段(动态绑定)
查找依据 函数定义位置(Lexical Scope) 函数调用方式
是否受作用域链影响 ✅ 是 ❌ 否

来看一段典型代码:

'use strict'; // 严格模式
var myName = '极客邦'; // 挂载到 window

var bar = {
    myName: 'time.geekbang.com',
    printName: function () {
        console.log(myName);       // ✅ 自由变量 → '极客邦'(全局)
        console.log(this.myName);  // ❓ this 取决于怎么调用!
    }
};

function foo() {
    let myName = '极客时间';
    return bar.printName;
}

var _printName = foo();
_printName();      // this → undefined(严格模式)
bar.printName();   // this → bar
  • myName 是自由变量,按词法作用域查找 → 总是取全局的 '极客邦'
  • this.myName 的值完全取决于函数如何被调用

💡 关键结论:this 和作用域链毫无关系!它是运行时的“调用上下文”决定的。


🎯 二、this 的五种绑定规则(优先级从高到低)

1️⃣ 显式绑定(Explicit Binding)

使用 call / apply / bind 强制指定 this

function foo() {
    this.myName = '极客时间';
}
let bar = { name: '极客邦' };
foo.call(bar); // this → bar
console.log(bar); // { name: '极客邦', myName: '极客时间' }

最高优先级,直接覆盖其他规则。


2️⃣ 隐式绑定(Implicit Binding)

作为对象的方法调用 → this 指向该对象

var myObj = {
    name: '极客时间',
    showThis: function() {
        console.log(this); // → myObj
    }
};
myObj.showThis(); // 隐式绑定

⚠️ 陷阱:隐式丢失(Implicit Loss)

var foo = myObj.showThis; // 函数引用被赋值
foo(); // 普通函数调用 → this = window(非严格)或 undefined(严格)

这就是为什么 setTimeout(myObj.method, 1000) 会丢失 this


3️⃣ 构造函数绑定(new Binding)

使用 new 调用函数 → this 指向新创建的实例

function CreateObj() {
    this.name = '极客时间';
}
var obj = new CreateObj(); // this → obj

内部机制相当于:

var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj);
return temObj;

4️⃣ 普通函数调用(Default Binding)

既不是方法,也没用 newcall → 默认绑定

  • 非严格模式this → window(浏览器)或 global(Node)
  • 严格模式this → undefined
function foo() {
    console.log(this); // 非严格 → window;严格 → undefined
}
foo();

🚫 这是 JS 的一个“历史包袱”:作者 Brendan Eich 当年为了快速实现,让普通函数的 this 默认指向全局对象,导致大量意外污染。


5️⃣ 箭头函数(Arrow Function)

没有自己的 this!继承外层作用域的 this

class Button {
    constructor() {
        this.text = '点击';
        document.getElementById('btn').addEventListener('click', () => {
            console.log(this.text); // ✅ 正确指向 Button 实例
        });
    }
}

✅ 箭头函数是解决“回调中 this 丢失”的利器,但不能用于需要动态 this 的场景(如构造函数、对象方法)。


🌐 三、特殊场景:DOM 事件中的 this

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener('click', function() {
    console.log(this); // → <a id="link"> 元素
});
</script>

这是 addEventListener 的规范行为

回调函数中的 this 自动绑定为注册事件的 DOM 元素

但注意:

  • 如果用箭头函数 → this 不再是元素!
  • 如果把函数赋值给变量再调用 → 隐式丢失!

⚠️ 四、为什么 this 的设计被认为是“不好”的?

  1. 违反直觉:函数定义时看不出 this 指向谁。
  2. 容易出错:隐式丢失、全局污染频发。
  3. 依赖调用方式:同一函数,不同调用,this 不同。

正因如此,ES6 引入了 class 和箭头函数,弱化对 this 的依赖


✅ 五、最佳实践建议

场景 推荐做法
对象方法 用普通函数,避免赋值导致隐式丢失
回调函数(如事件、定时器) 用箭头函数,或 .bind(this)
构造函数 用 class 替代传统函数
需要强制指定上下文 用 call / apply / bind
避免全局污染 使用严格模式 + let/const

🔚 结语

this 并不神秘,它只是 JavaScript 动态绑定机制的一部分。理解它的核心在于:

“谁调用了这个函数,this 就是谁。”

掌握五种绑定规则,避开隐式丢失陷阱,你就能在任何场景下准确预测 this 的指向。

最后记住:现代 JavaScript 已经提供了更安全的替代方案(如 class、箭头函数),不必死磕 this —— 但你必须懂它。


📚 延伸阅读

  • 《你不知道的JavaScript(上卷)》—— “this & 对象原型”章节
  • MDN: this
  • ECMAScript 规范:Function Calls

欢迎在评论区分享你踩过的 this 坑! 👇
如果觉得有帮助,别忘了点赞 + 关注~ ❤️


标签:#JavaScript #this #前端 #作用域 #掘金

Python避坑指南:基础玩家的3个"开挂"技巧

2025年12月5日 22:08

刚学会Python基础,写代码还在靠 for+append 凑数?别慌!这几个进阶偏基础的知识点,既能让代码变优雅,又不搞复杂概念,新手也能秒上手~

1. 推导式:一行搞定列表/字典(告别冗余循环)

还在这样写循环添加元素?

  
# 传统写法
nums = [1,2,3,4,5]
even_squares = []
for num in nums:
    if num % 2 == 0:
        even_squares.append(num**2)
print(even_squares)  # 输出: [4, 16]

 

试试列表推导式,一行搞定,逻辑更清晰:

  
# 列表推导式
nums = [1,2,3,4,5]
even_squares = [num**2 for num in nums if num % 2 == 0]
print(even_squares)  # 输出: [4, 16]

 

字典推导式也超实用,快速构建键值对:

fruits = ["apple", "banana", "cherry"]
fruit_len = {fruit: len(fruit) for fruit in fruits}
print(fruit_len)  # 输出: {'apple':5, 'banana':6, 'cherry':6}

 

2. 解包操作:变量交换/多返回值的优雅姿势

交换变量不用临时变量,解包直接拿捏:

a, b = 10, 20
a, b = b, a  # 一行交换,无需temp
print(a, b)  # 输出: 20 10

 

函数多返回值接收更简洁,还能忽略无用值:

def get_user_info():
    return "张三", 25, "北京"

name, age, _ = get_user_info()  # _ 忽略不需要的字段
print(f"姓名:{name},年龄:{age}")  # 输出: 姓名:张三,年龄:25

 

3. F-string:格式化输出的"天花板"

告别繁琐的 % 和 format ,F-string直观又强大:

score = 92.345
name = "李四"
# 直接嵌入变量,支持格式控制
print(f"{name}的成绩:{score:.1f}分")  # 输出: 李四的成绩:92.3分
print(f"及格率:{score/100

Python基础:被低估的"偷懒"技巧,新手必学!

2025年12月5日 22:05

刚入门Python,写代码总在"重复造轮子"?这几个进阶基础知识点,不用啃复杂文档,练熟直接少走弯路,代码简洁又高效~

1. 集合(set):去重+判断的"极速工具"

还在靠列表遍历去重?集合自带去重属性,效率翻倍:

  
# 列表去重(繁琐且慢)
nums = [1, 2, 2, 3, 3, 3]
unique_nums = []
for num in nums:
    if num not in unique_nums:
        unique_nums.append(num)
print(unique_nums)  # 输出: [1,2,3]

# 集合去重(一行搞定)
unique_nums = list(set(nums))
print(unique_nums)  # 输出: [1,2,3](顺序不保证,需排序可加sorted())
 
 
判断元素是否存在,集合比列表快100倍(大数据量更明显):
 
fruit_set = {"apple", "banana", "cherry"}
print("apple" in fruit_set)  # 输出: True(O(1)时间复杂度)

 

2. enumerate:循环时"顺便"拿索引

遍历列表想同时要索引和元素?别用 range(len()) 了:

  
fruits = ["apple", "banana", "cherry"]
# 传统写法(麻烦且不优雅)
for i in range(len(fruits)):
    print(f"索引{i}{fruits[i]}")

# enumerate写法(直接获取索引+元素)
for idx, fruit in enumerate(fruits, start=1):  # start指定索引起始值
    print(f"第{idx}个水果:{fruit}")  # 输出: 第1个水果:apple...

 

3. zip:多列表"配对"的神器

想同时遍历多个列表?zip直接打包,不用手动索引对齐:

  
names = ["张三", "李四", "王五"]
scores = [85, 92, 78]
subjects = ["数学", "语文", "英语"]

# 传统写法(容易出错)
for i in range(len(names)):
    print(f"{names[i]} {subjects[i]}{scores[i]}分")

# zip打包(简洁且安全)
for name, subject, score in zip(names, subjects, scores):
    print(f"{name} {subject}{score}分")  # 输出: 张三 数学:85分...
 
 
打包后转字典更方便:
 
python
  
student_scores = dict(zip(names, scores))
print(student_scores)  # 输出: {'张三':85, '李四':92, '王五':78}

 

这三个技巧都是日常开发高频用到的,看似简单却能解决很多冗余场景~ 赶紧复制代码实测,练熟直接提升代码整洁度!

❌
❌