阅读视图

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

Bun v1.3.12 深度解析:新特性、性能优化与实战指南

Bun v1.3.12 带来了内置无头浏览器自动化、终端 Markdown 渲染、进程内定时任务等新特性,同时在性能优化和兼容性方面取得了显著进展。本文将通过示例代码和实战指南,帮助开发者快速上手这些新功能。

大家好,我是 iDao。10 年全栈开发,做过架构、运维,也在落地 AI 工程化。这里不搞虚的,只分享能直接跑、能直接用的代码、方案和经验。内容包括:全栈开发实战、系统搭建、可视化大屏、自动化部署、AI 应用、私有化部署等。关注我,一起写能落地的代码,做能上线的项目。

一、Bun.WebView:内置无头浏览器自动化

Bun v1.3.12 引入了 Bun.WebView,这是一个内置的无头浏览器自动化工具,支持 WebKit 和 Chrome 两种后端,提供类似 Playwright 的 API。

主要特性

  • 原生事件模拟:所有输入均以操作系统级别事件分发,无法被网站检测为自动化。
  • 自动等待:支持选择器操作的自动等待,确保元素可见、稳定后再执行。
  • 跨平台支持:WebKit 默认用于 macOS,Chrome 后端支持所有平台。

示例代码

以下代码展示了如何使用 Bun.WebView 进行页面导航、点击和截图:

await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate("https://bun.sh");

await view.click("a[href='/docs']"); // 等待元素可点击并执行点击
await view.scroll(0, 400); // 模拟滚轮事件

const title = await view.evaluate("document.title");
const png = await view.screenshot({ format: "jpeg", quality: 90 });
await Bun.write("page.jpg", png);

二、Markdown 渲染:终端直接预览

Bun v1.3.12 支持直接在终端渲染 Markdown 文件,提供了两种方式:

  1. 运行 bun ./file.md
  2. 使用 Bun.markdown.ansi() API。

示例代码

以下代码展示了如何使用 Bun.markdown.ansi() 渲染 Markdown:

const out = Bun.markdown.ansi("# Hello\n\n**bold** and *italic*\n");
process.stdout.write(out);

// 启用超链接
const linked = Bun.markdown.ansi("[docs](https://bun.sh)", { hyperlinks: true });
process.stdout.write(linked);

三、Bun.cron:进程内定时任务

Bun.cron 新增了回调函数支持,适合长时间运行的服务和容器。

示例代码

以下代码展示了如何使用 Bun.cron 定时执行异步任务:

Bun.cron("* * * * *", async () => {
  console.log("每分钟执行一次");
});

四、性能优化与新特性

URLPattern 性能提升

URLPattern.test()URLPattern.exec() 的性能提升了最高 2.3 倍。

const pattern = new URLPattern({ pathname: "/api/users/:id/posts/:postId" });
pattern.test("https://example.com/api/users/42/posts/123");

Bun.stripANSI 和 Bun.stringWidth 的 SIMD 优化

Bun.stripANSIBun.stringWidth 的性能显著提升,处理速度最高提升 11 倍。

bun build 构建优化

修复了线程池问题,使低核机器上的构建速度提升了 1.43 倍。

五、Bug 修复与兼容性改进

  • 修复了多个 Node.js 兼容性问题,例如 process.env 在某些情况下为空的问题。
  • 改进了 Bun.serve 的 TCP_DEFER_ACCEPT 支持,降低了 HTTP 请求延迟。

六、升级指南与验证步骤

升级到 v1.3.12

运行以下命令升级到最新版本:

bun upgrade

验证新功能

验证 Bun.WebView 是否正常工作:

await using view = new Bun.WebView();
await view.navigate("https://example.com");
console.log(await view.title);

七、总结

Bun v1.3.12 带来了众多令人兴奋的新特性和性能优化,尤其是 Bun.WebViewBun.cron 的引入,为开发者提供了更多可能性。通过本文的示例代码和实战指南,相信你已经掌握了这些新功能的使用方法。

关注 【iDao技术魔方】,获取更多全栈到AI可落地的实战干货。

用AI读源码这件事:前端视角的实战方法论,附Vue3 reactivity源码解读示范

用AI读源码这件事:前端视角的实战方法论,附Vue3 reactivity源码解读示范

读源码这件事,前端开发者应该不陌生。

学新框架要看源码、理解某个第三方库的行为要看源码、接手一个没人维护的老项目更要翻源码。但说实话,源码读到一半上下文丢了、函数调用链路追踪到后面找不到头绪、第三方库没有文档只能靠猜——这些场景应该每个人都遇到过。

这两年AI工具多了,我开始尝试用AI辅助读源码。一开始踩了不少坑,后来慢慢摸索出几个相对稳定的用法,今天把实际验证过的方法论配合真实代码讲清楚。


1. 先定位再提问:不要把整个文件丢给AI

这是最容易犯的错误——把几百行代码一股脑丢给AI问"这段是做什么的"。

AI的上下文窗口虽然长,但代码量大了之后它容易失焦,回复要么泛泛而谈、要么开始自己编一段你根本找不到出处的逻辑。更关键的是,这样浪费了上下文token,效果反而差。

正确的做法

用IDE的 Go to Definition / Find References 定位到核心函数,只把相关的片段丢给AI

实战演示:用Vue3 reactive 源码

假设我想理解 Vue3 的 reactive 函数做了什么。先找到函数定义:

// vuejs/core/packages/reactivity/src/reactive.ts
export function reactive<T extends object>(target: T): Reactive<T>
/*@__NO_SIDE_EFFECTS__*/
export function reactive(target: object) {
  // 如果目标已经是readonly代理,直接返回
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap,
  )
}

这段代码本身不到20行,包含的类型声明和接口定义加起来也就50行左右。

丢给AI的提问方式:

"请解读这个reactive函数的实现逻辑,重点关注:1)为什么第一步要判断isReadonly;2)createReactiveObject各参数的含义;3)false这个标记位的作用是什么?"

比这样问效果好很多:

"reactive函数是做什么的?"(太宽泛,AI会复述文档而非分析代码)

不要这样问:

"帮我分析整个reactive.ts文件"(上下文太散,AI容易乱编)


2. 追问链:连续追问形成探索链路

读源码不是做问答游戏,不是问一句答一句就结束了。好的使用方式是连续追问,每一轮都基于上一轮的回答延伸

追问链的标准模板

第一轮:这个函数的入参是什么类型,它做了哪些初步判断?
第二轮:如果入参已经是响应式对象会怎样?走哪个分支?
第三轮:这个分支里的Proxy是在哪里创建的?
第四轮:Proxy的get handler里,为什么要做scheduler判断?

每一轮只问一个"深入一层"的问题,不要同时问多个相关但不同层次的问题——问多了AI会选最浅的回答你。

实战演示:用追问链追踪reactive的proxy创建

我:
reactive函数里的createReactiveObject做了什么?

AI:
createReactiveObject是创建响应式代理的核心函数...
它的第三个参数mutableHandlers是一个Proxy的handler配置对象...
它会拦截对象的get/set等操作来实现依赖收集和触发更新...

我:
那mutableHandlers在哪里定义的?

AI:
在reactive.ts同目录下的baseHandlers.ts中...
它定义了getset、deleteProperty等handler...
核心逻辑在get函数里,涉及到isRef判断、嵌套reactive等内容...

我:
get handler里的isRef判断具体在哪个条件下触发?

AI:
当访问的属性值是一个ref对象时(通过isRef函数判断),
会自动解包ref并返回其内部值。这个逻辑在get handler中大约第15行附近...

这样一层层追问下去,你会获得一条清晰的追踪路径,而且每一步都有代码依据。

追问链的进阶技巧

① 给AI一个假设,让它验证或否定:

"我猜测对数组调用reactive时,会进入COLLECTION分支,请对照代码确认这个猜测,如果不对指出第一个分叉点在哪里。"

这种方式的好处是:你有猜测,AI不会泛泛而谈;如果你猜错了,修正过程本身就是深入理解。

② 告诉AI你读到这里"卡住了":

"我在追踪proxy的创建流程,但在createReactiveObject的第四个参数那里卡住了——这个WeakMap的作用是什么?它和reactiveMap有什么区别?"

把"卡住"说出来,AI会针对你的具体断点给出分析,而不是重新泛泛概述。


3. 标注"不确定":让AI帮你做验证性推理

读代码时经常会有这种感觉——"这里逻辑看起来奇怪"或者"这个条件判断可能有问题"。

这时候可以直接把你的不确定告诉AI:

"我不确定这里为什么需要判断 !isObject,如果传进来的是数组会怎样?" "这段代码里如果target是null会走哪个分支?" "这个else分支我觉得永远不会执行,请帮我确认。"

这种"不确定+具体猜测"的方式,比直接问"这段代码的逻辑是什么"效果好很多。

原因在于:你在引导AI做验证性推理——验证或否定你的猜测,而不是做描述性推理——把代码表面意思翻译成自然语言。前者能挖到深层逻辑,后者只是换了种表达方式。

实战演示

在reactive源码中有这样一段:

function getTargetType(value: Target) {
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    : targetTypeMap(toRawType(value))
}

可以这样问AI:

"我不确定这里为什么用 Object.isExtensible(value) 来判断是否是无效目标,如果一个普通对象但它的原型被锁定了(Object.preventExtensions),会不会被误判为INVALID类型?"

AI会带你去看 Object.preventExtensionsObject.isExtensible 的区别,结合这段代码的具体场景分析这是有意为之还是潜在问题。


4. 用"用户视角"理解第三方库的外部行为

这个技巧适合在理解一个库的API行为时使用,而不是追踪内部实现。

核心思路:让AI站在调用方的角度,从外向内追踪。

比如我想理解 vueuseuseLocalStorage 为什么在SSR时会失效,我会这样说:

"作为一个使用useLocalStorage的开发者,我在SSR环境下发现值不同步。请帮我追踪这个函数的实现,找到可能导致SSR场景下行为不一致的原因。"

从"用户视角"切入,AI会倾向于从暴露的API开始追踪,而不是陷入内部的工具函数。对于理解一个库的外部行为特别有用。


5. 结合IDE做交叉验证

AI说的内容不一定完全准确,特别是涉及复杂调用链时。交叉验证非常重要。

我的习惯是:

  1. AI给出的关键结论,在IDE里用 Go to Definition 快速确认
  2. 如果AI说的函数名在IDE里找不到,那说明AI在编造
  3. 对于特别重要的结论,同时问两个不同的AI工具,看结论是否一致

这本质上是一种工程思维——不迷信单一信息源,用多个工具交叉核对。


总结

用AI读源码这件事,用对了确实能提升效率,但有几个前提:

  • 你得对代码结构有基本的方向感,知道去哪找文件、找哪个函数
  • AI是辅助工具,不是替代品,核心逻辑还是得自己理解
  • 不要过度依赖,遇到关键逻辑最好在IDE里自己跑一遍
  • AI的结论要交叉验证,不轻信

这些方法不一定多新颖,但都是我实际工作里反复验证过的。如果有更好的技巧,欢迎交流。


参考资料

  1. Vue3 Core 源码 - reactivity 模块(MIT License)
  2. Vue3 官方文档 - Reactive 响应式原理
  3. MDN - Object.isExtensible
  4. MDN - Proxy
  5. Anthropic - Claude for Code(AI代码辅助工具相关研究)
  6. GitHub Blog - Developer Experience

以上内容均为技术原理分享,源码引用遵守Vue3的MIT开源协议。

OpenSpec + Superpowers 联合开发工作流

OpenSpec: 需求 → 结构化制品(proposal / spec / design / tasks),解决"做什么" Superpowers: brainstorming → worktree → plan → subagent → TDD → review → finish,解决"怎么做好" 本文档:二者如何结合,以及如何应对任务中断和上下文丢失


两套系统的关系

┌─────────────────────────────────────────────────────────┐
│                    OpenSpec (需求层)                      │
│                                                           │
│  explore → propose → continue → apply → verify → archive │
│     │          │                   │                      │
│  梳理需求   生成制品             逐任务实现               │
│             (proposal.md                                  │
│              design.md                                    │
│              tasks.md)                                    │
└─────────┬──────────────────────────┬────────────────────┘
          │                          │
          │ 制品 = 持久化的需求记忆   │ tasks.md = 进度跟踪
          │                          │
┌─────────▼──────────────────────────▼────────────────────┐
│                  Superpowers (工程层)                      │
│                                                           │
│  brainstorming → git-worktree → writing-plans             │
│       → subagent-driven-dev → TDD → code-review           │
│       → verification → finishing-branch                    │
│                                                           │
│  隔离工作区、子代理执行、测试驱动、代码审查、分支收尾      │
└─────────────────────────────────────────────────────────┘

简单理解:

  • OpenSpec 管「需求到任务」的拆解和持久化
  • Superpowers 管「任务到代码」的工程质量和执行效率
  • 二者通过 磁盘上的 Markdown 文件 连接,这也是对抗 AI 遗忘的关键

完整流程(从产品文档到上线)

Phase 1: 需求梳理

有两条路径可选:

路径 工具 适用场景
A. OpenSpec Explore /opsx:explore 需求模糊,想先讨论
B. Superpowers Brainstorming 自动触发 需求相对明确,直接设计

路径 A — OpenSpec Explore(推荐用于"只有截图+字段表"的场景):

/opsx:explore

产品给了「供应商审核」的原型截图和字段文档:
[拖入截图]
[粘贴字段表]

Explore 模式是只读思考伙伴:

  • 从截图提取页面结构、字段、操作按钮
  • 从字段文档结构化数据模型
  • 画 ASCII 图理清状态流转
  • 不写代码,只输出理解

梳理清楚后自然过渡到 Phase 2。

路径 B — Superpowers Brainstorming:

直接告诉 AI 要做什么,Superpowers 自动进入 brainstorming 流程:

  1. 探索项目现有结构
  2. 一次问一个问题澄清需求
  3. 提出 2-3 种方案 + 推荐
  4. 分段展示设计,逐段确认
  5. 写入 docs/superpowers/specs/ 并 commit

Phase 2: 生成结构化制品

使用 OpenSpec Propose 一步到位:

/opsx:propose supplier-review

开发 admin-portal 的「供应商审核」模块。
[截图 + 字段说明 + 业务规则]

自动生成:

openspec/changes/supplier-review/
├── proposal.md    ← 做什么、范围、能力列表
├── design.md      ← 文件结构、组件拆分、API 设计
└── tasks.md       ← checkbox 任务清单

或者,如果已经通过 Brainstorming 产出了设计文档,可以:

  1. docs/superpowers/specs/ 下的设计文档作为输入
  2. /opsx:propose 时附上,让 OpenSpec 制品与 Superpowers 设计保持一致

Phase 3: 工作区隔离

Superpowers 的 git-worktree 自动触发:

开始实现 supplier-review

Superpowers 会:

  1. 创建 .worktrees/supplier-review 隔离工作区
  2. 新建 feature/supplier-review 分支
  3. 运行 pnpm install 安装依赖
  4. 验证测试基线通过

为什么要隔离: 主工作区保持干净,多个功能可以并行开发互不干扰。

Phase 4: 逐任务实现

OpenSpec Apply + Superpowers Subagent 联合驱动:

/opsx:apply

执行引擎有两种模式:

模式 A — Subagent-Driven(推荐,大功能用):

主 Agent(协调者,上下文最小化)
  │
  ├─ 读 tasks.md,提取 Task 1 的完整描述
  │
  ├─ 派发 Subagent 1 实现 Task 1
  │   └─ Subagent 遵循 TDD:写测试 → 红 → 实现 → 绿 → 重构
  │
  ├─ 派发 Spec Reviewer 检查是否符合 design.md
  │   └─ 不符合 → Subagent 修复 → 再审
  │
  ├─ 派发 Code Reviewer 检查代码质量
  │   └─ 有问题 → Subagent 修复 → 再审
  │
  ├─ tasks.md 中 Task 1 打勾 [x]
  │
  ├─ 派发 Subagent 2 实现 Task 2 ...
  │
  └─ 全部完成后 → Phase 5

模式 B — 直接执行(小功能用):

/opsx:apply

AI 直接在当前会话中逐任务实现,每完成一个打勾。

Phase 5: 验证 + 收尾

/opsx:verify supplier-review

三维度检查:完整性 × 正确性 × 一致性

通过后,Superpowers 接管收尾:

实现完成,准备收尾

Superpowers finishing-a-development-branch 自动:

  1. 运行全量测试
  2. 提供四个选项:合并 / 创建 PR / 保留分支 / 丢弃
  3. 清理 worktree

最后:

/opsx:archive supplier-review

任务中断与恢复(核心问题)

问题本质

┌──────────────────────────────────────────────┐
│           AI 的上下文窗口 (有限)              │
│                                                │
│  对话开始 ───────────────────── 对话结束/中断  │
│  记住一切           逐渐遗忘        全部丢失   │
└──────────────────────────────────────────────┘

AI 有两个致命限制:

  1. 上下文窗口有限 — 长对话后期忘记前面内容
  2. 会话不持久 — 关窗口 / 新对话 = 一切归零

解决方案:三层持久化

┌─────────────────────────────────────────────┐
│ 第 1 层:项目级永久记忆                       │
│                                               │
│ openspec/config.yaml                          │
│ ├── 技术栈、代码约定                          │
│ ├── 每次新对话 AI 自动读取                    │
│ └── 相当于"置顶备忘录"                        │
│                                               │
│ CLAUDE.md / .cursor/rules/                    │
│ └── IDE 自动注入的项目规则                    │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 第 2 层:功能级需求记忆                       │
│                                               │
│ openspec/changes/<name>/                      │
│ ├── proposal.md  → "这个功能是干嘛的"         │
│ ├── design.md    → "代码怎么组织"             │
│ └── tasks.md     → "做到哪了" (checkbox)      │
│                                               │
│ docs/superpowers/specs/<design>.md            │
│ └── Brainstorming 产出的设计文档              │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 第 3 层:代码级状态记忆                       │
│                                               │
│ git worktree + branch                         │
│ ├── 分支名就是功能名                          │
│ ├── commit 历史就是实现进度                   │
│ └── worktree 路径就是工作区位置               │
└─────────────────────────────────────────────┘

中断恢复操作手册

场景 1:对话中途中断(关闭窗口 / 网络断开)

恢复方式: 新开对话,一句话搞定

/opsx:apply

AI 自动执行的恢复链:

  1. openspec list → 找到活跃变更
  2. proposal.md → 恢复"做什么"
  3. design.md → 恢复"怎么做"
  4. tasks.md → 扫描 checkbox,定位到第一个 - [ ]
  5. 从断点继续实现

场景 2:跨天开发(每天做一点)

完全相同,每天开工直接:

/opsx:apply supplier-review

场景 3:长对话上下文不够了(AI 开始"犯糊涂")

症状: AI 忘记了之前的约定,代码风格不一致,重复问已回答过的问题

解决方式: 不要在旧对话中挣扎,直接新开对话

/opsx:apply supplier-review

新对话有全新的上下文窗口,从磁盘读取制品文件,比继续在被污染的旧对话中好。

场景 4:Subagent 执行到一半中断

Subagent 每完成一个 task 就在 tasks.md 中打勾并 commit。中断后:

/opsx:apply

主 Agent 读 tasks.md,跳过已完成的 [x],从下一个 [ ] 继续派发 Subagent。

场景 5:需求变更(产品改了文档)

/opsx:explore supplier-review

产品说审核从一级改成两级,新流程截图如下:
[拖入新截图]

评估对 design.md 和 tasks.md 的影响。

确认后手动或让 AI 更新制品文件,再 /opsx:apply 继续。


上下文优化策略

策略 1:Subagent 隔离(Superpowers 核心能力)

主 Agent                    Subagent 1           Subagent 2
  │                             │                     │
  │ 只保留协调信息              │ 全新上下文           │ 全新上下文
  │ (tasks列表+当前进度)        │ (Task 1完整描述      │ (Task 2完整描述
  │                             │  + design.md片段)    │  + design.md片段)
  │                             │                     │
  │ 上下文消耗:极小            │ 上下文消耗:中等     │ 上下文消耗:中等

核心原理: 主 Agent 把任务的完整描述"打包"给 Subagent,Subagent 用全新上下文执行。执行完毕后 Subagent 的上下文被释放,不会污染主 Agent。

策略 2:制品文件 > 对话记忆

方式 上下文消耗 可靠性 可恢复
"之前我们讨论过..." 高(需回溯对话历史) 低(可能遗忘)
读 design.md 低(只读一个文件) 高(磁盘持久化)

实践建议: 当你在对话中做了重要决策,但还没到 propose 阶段时,让 AI 立刻写入文件:

把刚才讨论的结论写入 openspec/changes/supplier-review/notes.md

策略 3:config.yaml 是跨会话记忆

# openspec/config.yaml
context: |
  这里的内容,每次新对话 AI 都会读到。
  等于给 AI 的"永久记忆"。

适合写入:

  • 技术栈版本号
  • 代码约定(命名、目录结构、import 顺序)
  • 团队特有术语
  • 常见的坑("Ant Design 的 Table 组件在 xx 情况下需要 yy")

实战走查:一条完整链路(以"供应商审核"为例)

以下以组合 5(全链路) 为例,演示从零到上线的每一步。每步标注:你输入什么 → AI 内部做了什么 → 磁盘上产生/变化了哪些文件。

Step 0: 前置状态

d:\work\srm-frontend\
├── openspec/
│   └── config.yaml               ← 已存在,项目级上下文
├── apps/admin-portal/src/
│   ├── app/router.tsx             ← 已有路由定义
│   └── features/                  ← 功能域目录
└── ...

Step 1: 需求梳理(可选)

你输入:

/opsx:explore

产品给了「供应商审核」的原型截图和字段文档:
[拖入截图]
[粘贴字段表]

AI 内部动作:

  1. 读取 openspec/config.yaml 获取项目上下文
  2. 分析截图,提取页面结构、字段、按钮、状态流转
  3. 以对话形式输出理解,提问澄清

磁盘变化: 无(explore 是只读模式)

产出: 对话中形成的共识(页面结构、数据模型、业务规则)


Step 2: 生成结构化制品

你输入:

/opsx:propose supplier-review

开发 admin-portal 的「供应商审核」模块。
包含:审核列表页(分页、筛选、批量操作)+ 审核详情页(审核表单、审批流)。
[截图 + 字段说明 + 业务规则]

AI 内部动作:

  1. 读取 SKILL 文件 .codex/skills/openspec-propose/SKILL.md
  2. 读取 openspec/config.yaml 中的 rules(proposal / design / tasks 的约定)
  3. 扫描现有代码结构,了解项目约定
  4. 一次性生成三个制品文件

磁盘变化(新增 3 个文件):

+ openspec/changes/supplier-review/
+   ├── proposal.md       ← 功能概述、范围、能力列表
+   ├── design.md         ← 文件结构、组件拆分、接口设计、状态管理
+   └── tasks.md          ← 实现任务清单(checkbox 格式)

生成的 tasks.md 示例:

# Tasks: supplier-review

## Implementation Tasks

- [ ] Task 1: 注册路由和菜单项
  在 router.tsx 的 portalMenuTree 中添加「供应商审核」菜单...
- [ ] Task 2: 封装 API hooks
  创建 hooks/useSupplierReviews.ts,包含列表查询和审核操作...
- [ ] Task 3: 实现审核列表页
  创建 SupplierReviewListPage.tsx,包含 Table + 筛选条件...
- [ ] Task 4: 实现审核详情页
  创建 SupplierReviewDetailPage.tsx,包含审核表单...
- [ ] Task 5: 单元测试
  为列表页和详情页编写 React Testing Library 测试...
- [ ] Task 6: TypeScript 类型检查
  运行 pnpm exec tsc --noEmit 确保无类型错误...

Step 3: 生成精细实现计划(Superpowers writing-plans)

你输入:

请根据 openspec/changes/supplier-review/ 的制品,
用 writing-plans 生成实现计划。

AI 内部动作:

  1. 读取 SKILL 文件 skills/superpowers/writing-plans/SKILL.md
  2. 读取 proposal.md + design.md + tasks.md
  3. 将每个 task 拆成 2-5 分钟粒度的步骤,包含具体代码块

磁盘变化(新增 1 个文件):

+ docs/superpowers/plans/2026-04-16-supplier-review.md

生成的 plan 文件结构:

# Implementation Plan: supplier-review

## Task 1: 注册路由和菜单项

### Step 1.1: 添加菜单项到 portalMenuTree (2 min)
**File:** `apps/admin-portal/src/app/router.tsx`
**Action:** 在 portalMenuTree 的「供应商」children 中添加:
```tsx
{
  path: 'supplier-review',
  title: '供应商审核',
  icon: <AuditOutlined />,
  children: [
    { path: 'supplier-review/list', title: '审核列表', icon: <UnorderedListOutlined /> },
    { path: 'supplier-review/detail/:id', title: '审核详情', icon: <FileSearchOutlined /> },
  ],
}
```
**Verify:** `pnpm exec tsc --noEmit -p apps/admin-portal/tsconfig.app.json`
**Commit:** `feat(supplier-review): register routes and menu items`

### Step 1.2: 创建功能域目录 (1 min)
**Action:** 创建目录结构
```
src/features/supplier-review/
├── index.ts
├── components/
└── hooks/
```
...

## Task 2: 封装 API hooks
### Step 2.1: ...

Step 4: 创建隔离工作区(Superpowers git-worktree)

你输入:

用 subagent-driven-development 执行计划

AI 内部动作(自动触发 using-git-worktrees):

  1. git worktree add .worktrees/supplier-review -b feature/supplier-review
  2. cd .worktrees/supplier-review && pnpm install
  3. 运行测试基线验证

磁盘变化:

+ .worktrees/supplier-review/          ← 完整项目副本,独立工作区
  (git branch: feature/supplier-review)

Step 5: Subagent 逐任务执行

AI 内部动作(subagent-driven-dev 自动循环):

主 Agent(协调者)
  │
  │ 读 plan 文件,提取 Task 1 的所有 Steps
  │
  ├─── 派发 Subagent 1 ─────────────────────────────────────────┐
  │    提示词包含:                                                │
  │    - Task 1 的完整描述 + 所有 Steps                          │
  │    - design.md 中相关片段                                    │
  │    - 项目约定 (从 config.yaml)                               │
  │                                                              │
  │    Subagent 执行:                                            │
  │    1. 创建 src/features/supplier-review/ 目录                │
  │    2. 写 router.tsx 菜单项                                   │
  │    3. 运行 tsc --noEmit 验证                                 │
  │    4. git commit                                             │
  │    └─ 返回: "Task 1 完成,创建了 3 个文件,tsc 通过"         │
  │                                                              │
  ├─── 派发 Spec Reviewer ──────────────────────────────────────┐
  │    提示词: 检查 Task 1 实现是否符合 design.md                │
  │    └─ 返回: "✓ 路由结构符合设计,菜单层级正确"               │
  │                                                              │
  ├─── 派发 Code Reviewer ──────────────────────────────────────┐
  │    提示词: 检查代码质量和项目约定                             │
  │    └─ 返回: "✓ 通过,建议:icon import 可以统一到一个文件"   │
  │                                                              │
  ├─── tasks.md / plan.md 中 Task 1 打勾 [x]                    │
  │                                                              │
  ├─── 派发 Subagent 2Task 2: API hooks ...                  │
  │    ...同样的 实现 → spec review → code review 循环           │
  │                                                              │
  ├─── 派发 Subagent 3Task 3: 列表页面 ...                   │
  ├─── 派发 Subagent 4Task 4: 详情页面 ...                   │
  ├─── 派发 Subagent 5Task 5: 单元测试 ...                   │
  ├─── 派发 Subagent 6Task 6: tsc --noEmit ...               │
  │                                                              │
  └─── 全部 [x] → 报告完成                                      │

每个 Subagent 完成后的磁盘变化:

# Task 1 完成后
+ apps/admin-portal/src/features/supplier-review/index.ts
~ apps/admin-portal/src/app/router.tsx                  ← 修改(加菜单项)
~ openspec/changes/supplier-review/tasks.md             ← Task 1 打勾 [x]

# Task 2 完成后
+ apps/admin-portal/src/features/supplier-review/hooks/useSupplierReviews.ts
+ apps/admin-portal/src/features/supplier-review/hooks/useSupplierReviewDetail.ts
~ openspec/changes/supplier-review/tasks.md             ← Task 2 打勾 [x]

# Task 3 完成后
+ apps/admin-portal/src/features/supplier-review/SupplierReviewListPage.tsx
+ apps/admin-portal/src/features/supplier-review/components/ReviewFilters.tsx
+ apps/admin-portal/src/features/supplier-review/components/ReviewTable.tsx
~ openspec/changes/supplier-review/tasks.md             ← Task 3 打勾 [x]

# Task 4 完成后
+ apps/admin-portal/src/features/supplier-review/SupplierReviewDetailPage.tsx
+ apps/admin-portal/src/features/supplier-review/components/ReviewForm.tsx
+ apps/admin-portal/src/features/supplier-review/components/ApprovalFlow.tsx
~ openspec/changes/supplier-review/tasks.md             ← Task 4 打勾 [x]

# Task 5 完成后
+ apps/admin-portal/src/features/supplier-review/SupplierReviewListPage.test.tsx
+ apps/admin-portal/src/features/supplier-review/SupplierReviewDetailPage.test.tsx
~ openspec/changes/supplier-review/tasks.md             ← Task 5 打勾 [x]

# Task 6 完成后(无新文件,只跑检查)
~ openspec/changes/supplier-review/tasks.md             ← Task 6 打勾 [x]

git 历史(在 feature/supplier-review 分支上):

* feat(supplier-review): pass tsc --noEmit type check
* test(supplier-review): add unit tests for list and detail pages
* feat(supplier-review): implement review detail page with approval flow
* feat(supplier-review): implement review list page with filters
* feat(supplier-review): add API hooks with TanStack Query
* feat(supplier-review): register routes and menu items

Step 6: 验证

你输入:

/opsx:verify supplier-review

AI 内部动作:

  1. 读取 SKILL 文件 .codex/skills/openspec-verify-change/SKILL.md
  2. 对比三个维度:
    • 完整性:tasks.md 全部 [x]
    • 正确性:代码能编译、测试通过 ✓
    • 一致性:实现与 design.md 一致 ✓
  3. 输出验证报告

磁盘变化: 无(只读检查)


Step 7: 收尾

你输入:

实现完成,准备收尾

AI 内部动作(finishing-a-development-branch):

  1. 运行 pnpm test 全量测试
  2. 运行 pnpm lint 代码检查
  3. 展示四个选项让你选择:
    • [1] 合并到 main
    • [2] 创建 PR
    • [3] 保留分支
    • [4] 丢弃

假设选择 [2] 创建 PR

git push -u origin feature/supplier-review
gh pr create --title "feat: 供应商审核模块" --body "..."

磁盘变化:

- .worktrees/supplier-review/       ← worktree 清理

Step 8: 归档

你输入:

/opsx:archive supplier-review

AI 内部动作:

  1. 读取 SKILL 文件 .codex/skills/openspec-archive-change/SKILL.md
  2. openspec/changes/supplier-review/ 下的制品归档
  3. 如有 delta spec,合并到主 spec

磁盘变化:

- openspec/changes/supplier-review/     ← 整个目录归档/移除
+ openspec/specs/supplier-review/       ← 主 spec 更新(如有)

完整链路文件时间线总览

操作                    新增文件                                   修改文件
─────────────────────── ────────────────────────────────────────── ──────────────────────────
Step 1: explore         (无)                                       (无)
Step 2: propose         openspec/changes/supplier-review/          (无)
                          proposal.md, design.md, tasks.md
Step 3: writing-plans   docs/superpowers/plans/                    (无)
                          2026-04-16-supplier-review.md
Step 4: git-worktree    .worktrees/supplier-review/ (完整副本)     (无)
Step 5: subagent 执行   src/features/supplier-review/              router.tsx
                          index.ts                                 tasks.md (逐个打勾)
                          SupplierReviewListPage.tsx
                          SupplierReviewDetailPage.tsx
                          *.test.tsx
                          components/*.tsx
                          hooks/*.ts
Step 6: verify          (无)                                       (无)
Step 7: finishing        (无)                                       worktree 清理
Step 8: archive         openspec/specs/ (如有)                     openspec/changes/ 移除

命令速查

OpenSpec 命令

命令 作用 典型场景
/opsx:explore 思考伙伴,只读不写 拿到截图/文档,先梳理
/opsx:propose <name> 一键生成 proposal + design + tasks 需求明确,开始规划
/opsx:continue 生成下一个制品 propose 没一步到位时
/opsx:apply 按 tasks.md 逐任务实现 开始写代码 / 中断后恢复
/opsx:verify 检查实现 vs 制品 觉得做完了
/opsx:archive 归档已完成的变更 功能上线后

Superpowers 自动触发的 Skills

Skill 触发时机 作用
brainstorming 开始构建任何功能时 苏格拉底式需求对话
using-git-worktrees 设计确认后 隔离工作区 + 新分支
writing-plans 设计确认后 生成精细到 2-5 分钟粒度的计划
subagent-driven-dev 执行计划时 每 task 一个子代理 + 双审
test-driven-development 实现代码时 红-绿-重构循环
verification-before-completion 声称完成前 强制验证,杜绝"应该没问题"
finishing-a-development-branch 全部完成后 合并/PR/清理四选一

执行组合策略

四个执行工具的定位

需求拆解层                          执行层
                               ┌────────────────────────────┐
/opsx:apply ──────────────────►│  直接执行(内置简单模式)     │
  读 tasks.md                   │  逐 task 在当前会话实现      │
  逐 task 实现                  └────────────────────────────┘

                               ┌────────────────────────────┐
writing-plans ────────────────►│  生成精细计划文件             │
  把 task 拆成                  │  (每步 2-5 分钟粒度)         │
  2-5 分钟的步骤                └──────────┬─────────────────┘
                                           │
                                 ┌─────────┴──────────┐
                                 ▼                    ▼
                        executing-plans      subagent-driven-dev
                        (批量执行+检查点)    (每 task 派子代理+双审)

组合 1:/opsx:apply(单独使用)

适合: 小功能、占位页替换、简单 CRUD

/opsx:propose supplier-review
/opsx:apply

AI 读 tasks.md,在当前对话中逐个实现,每完成一个打勾。

优点 缺点
最快启动,零开销 长任务上下文膨胀,没有质量审查

典型任务量: 3-5 个 task,单次对话能搞定

组合 2:/opsx:apply + subagent-driven-dev

适合: 中等功能,需要质量保证

/opsx:propose material-library
/opsx:apply                        ← AI 自动用 subagent 模式执行

主 Agent 读 tasks.md,每个 task 派一个全新 Subagent 实现,完成后再派 reviewer 审查:

Agent(协调,上下文极小)
  │
  ├─ SubagentTask 1: 注册路由    → Spec Review ✓ → Code Review ✓ → [x]
  ├─ SubagentTask 2: API hooksSpec Review ✓ → Code Review ✓ → [x]
  ├─ SubagentTask 3: 列表页面    → Spec Review ✗ → 修复 → 再审 ✓ → [x]
  └─ ...
优点 缺点
每 task 全新上下文(不膨胀),双重审查保证质量 消耗更多 token(每 task = 实现 + 2 次审查)

典型任务量: 5-15 个 task,跨 1-3 天

组合 3:writing-plans + executing-plans

适合: 需要极其精细控制的功能,或要交给别人/别的 AI 执行

先生成精细计划:

我要实现供应商审核模块,设计文档在 openspec/changes/supplier-review/design.md,
请用 writing-plans 生成实现计划。

产出 docs/superpowers/plans/2026-04-16-supplier-review.md,每个步骤精确到:

  • 具体文件路径和完整代码块
  • 运行命令 + 预期输出
  • commit message

然后执行:

按 docs/superpowers/plans/2026-04-16-supplier-review.md 执行

executing-plans 批量执行,到检查点暂停让你审核。

优点 缺点
计划可人工审核、可交接、可复用 生成计划本身消耗大量 token,计划可能因代码变化过时

典型场景: 团队协作、需要人工审批的关键模块

组合 4:writing-plans + subagent-driven-dev(最高质量)

适合: 大功能,质量要求高,需要全自动执行

我要实现供应商审核模块,设计文档在 openspec/changes/supplier-review/design.md,
请用 writing-plans 生成实现计划,然后用 subagent-driven-development 执行。
writing-plans 生成精细计划
  │
  ▼
subagent-driven-dev 执行
  │
  ├─ Subagent 实现 Step 1 (TDD: 写测试→红→实现→绿)
  ├─ Spec Reviewer 审查是否符合设计
  ├─ Code Reviewer 审查代码质量
  ├─ [x] Step 1 完成
  │
  ├─ Subagent 实现 Step 2 ...
  └─ ...
优点 缺点
计划精确 + 执行隔离 + 双重审查 = 最高质量 token 消耗最大(计划生成 + 每 task 三个 agent)

典型场景: 核心业务模块、不允许返工的功能

组合 5:/opsx:propose + writing-plans + subagent-driven-dev(全链路)

适合: 从零开始的大模块,完整走一遍

Phase 1: OpenSpec 生成需求制品
/opsx:propose supplier-review + 截图/字段文档

Phase 2: Superpowers 生成精细计划
请根据 openspec/changes/supplier-review/ 的制品,
用 writing-plans 生成实现计划

Phase 3: Subagent 执行
用 subagent-driven-development 执行计划

Phase 4: 验证 + 收尾
/opsx:verify supplier-review
finishing-a-development-branch
/opsx:archive supplier-review

选择决策树

你要做多大的功能?
│
├─ 很小(改个按钮/加个字段)
│   └─ 不用 OpenSpec 也不用 Superpowers,直接写
│
├─ 小(1-3 个组件)
│   └─ /opsx:apply 单独用                          ← 组合 1
│
├─ 中(一个完整页面模块)
│   │
│   ├─ 赶时间?
│   │   └─ /opsx:apply + subagent                  ← 组合 2
│   │
│   └─ 要求高?
│       └─ writing-plans + subagent                ← 组合 4
│
├─ 大(多个页面/跨模块)
│   └─ /opsx:propose + writing-plans + subagent    ← 组合 5
│
└─ 需要交接给别人执行?
    └─ writing-plans + executing-plans             ← 组合 3

中断恢复对照

组合 进度保存位置 恢复方式
组合 1 tasks.md checkbox /opsx:apply
组合 2 tasks.md checkbox /opsx:apply
组合 3 plan 文件 checkbox 继续执行 docs/superpowers/plans/xxx.md
组合 4 plan 文件 checkbox 继续执行 docs/superpowers/plans/xxx.md
组合 5 两份 checkbox /opsx:apply继续执行 plan

所有组合的进度都通过 磁盘上的 checkbox 持久化,新对话一句话恢复。


FAQ

Q: OpenSpec 和 Superpowers 的 brainstorming/writing-plans 功能重叠了吗?

有部分重叠,但侧重不同:

  • OpenSpec propose 生成的 tasks.md 是按功能域拆分的粗粒度任务(每个 task ≈ 5-15 分钟)
  • Superpowers writing-plans 生成的 plan 是每步 2-5 分钟的精细粒度(含完整代码块和命令)

推荐:用 OpenSpec 管需求到任务的拆解,用 Superpowers 管每个任务内部的 TDD 执行。两层粒度互补而非冲突。

Q: 一定要用 Subagent 吗?

不一定。Subagent 的价值在于:

  1. 隔离上下文 — 每个 task 用全新上下文,不被前面的对话污染
  2. 双重审查 — spec reviewer + code reviewer 保证质量
  3. 对抗遗忘 — 主 Agent 上下文消耗最小

如果功能很小(3 个以下 task),直接在当前对话执行即可(组合 1)。

Q: writing-plans 生成的计划和 OpenSpec 的 tasks.md 有什么区别?

tasks.md(OpenSpec)              plan.md(Superpowers)
┌──────────────────┐            ┌─────────────────────────────┐
│ - [ ] 注册路由    │            │ Step 1: 创建路由文件          │
│ - [ ] API hooks  │──细化──▶  │   创建 src/app/router.tsx     │
│ - [ ] 列表页面    │            │   添加以下代码:              │
│                  │            │   ```tsx                     │
│                  │            │   export const routes = ...  │
│                  │            │   ```                        │
│                  │            │   运行: pnpm exec tsc        │
│                  │            │   预期: 无错误                │
└──────────────────┘            └─────────────────────────────┘
粗粒度,描述"做什么"            精细粒度,描述"怎么做每一步"

选择建议:

  • 只有 tasks.md 就够 → 组合 1 或 2
  • 需要精确控制每一步 → 先 writing-plans 再执行 → 组合 3、4、5

Q: executing-planssubagent-driven-dev 怎么选?

executing-plans subagent-driven-dev
执行方式 批量执行 + 检查点暂停 每 task 单独 subagent
上下文管理 同一上下文累积 每 task 全新上下文
审查机制 检查点由人工审核 自动 spec + code review
token 消耗
适合场景 需要人工审批、交接 全自动、高质量要求

Q: 中断后有些代码写了一半怎么办?

git 状态就是证据:

  • 有 commit → task 已完成,tasks.md 应该已打勾
  • 有未 commit 的改动 → task 做了一半,新对话中让 AI 检查 git diff 后继续

Q: tasks.md 里的任务太粗/太细怎么办?

直接编辑文件。粒度标准:每个 task 能在一次 AI 对话中完成(约 5-15 分钟)。OpenSpec 不锁定制品格式,随时可修改。

Q: 能不能混用组合?

可以。例如前 3 个简单 task 用组合 1 直接做,后面复杂的 task 切到组合 2 用 subagent。OpenSpec 的 tasks.md 是唯一进度源,无论哪种组合都通过 checkbox 同步进度。

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户点击“连接钱包”按钮,弹出 MetaMask 授权,连接成功后显示用户地址和余额。作为有几年经验的 Web3 开发者,我心想这还不是手到擒来?直接上 ethers.js 这个老伙计,几行代码搞定。于是,我新建了一个 React 组件,信心满满地开始敲代码。没想到,就是这个看似基础的功能,让我在接下来的一天里,跟各种奇怪的报错和边界情况斗智斗勇。

问题分析

我最开始的思路非常直接:在组件挂载时,检查 window.ethereum 是否存在(即用户是否安装了 MetaMask),然后调用 ethereum.request({ method: 'eth_requestAccounts' }) 请求账户授权,最后用 new ethers.providers.Web3Provider(window.ethereum) 创建 provider 来读取链上数据。

第一版代码跑起来,点击按钮,MetaMask 确实弹出来了,授权也很顺利。控制台打印出了地址,我正准备庆祝,问题就来了。

  1. 页面刷新后,登录状态丢失:用户需要重新点击连接。这体验太差了,我们的产品经理第一个不答应。
  2. 切换 MetaMask 账户时,前端页面没反应:用户在钱包里换了账号,但我们的网站显示的依然是旧地址。
  3. 切换网络时页面卡住:用户从以太坊主网切换到 Polygon,页面有时会卡死,需要手动刷新。

我意识到,我把问题想简单了。一个生产级的钱包连接,不仅仅是“弹出授权框拿到地址”,它必须是一个有状态、能响应变化、并且持久化的连接。我需要监听钱包的各种事件(账户变化、网络变化),并妥善管理这些状态,使其与 React 组件的状态同步。

核心实现

第一步:检测 Provider 与初始化状态

首先,我们不能假设用户一定装了 MetaMask。所以,检测 window.ethereum 是第一步,并且最好在组件生命周期早期进行。

这里有个坑:window.ethereum 的类型在 TypeScript 中是 anyunknown。为了更好的类型安全,我将其断言为 ethers.providers.ExternalProvider,但更严谨的做法是使用 ethers 提供的类型工具,或者直接检查必要的方法是否存在。

我决定在自定义 Hook (useWallet) 的初始化阶段完成检测和基础设置。

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      if (!window.ethereum) {
        setError('请安装 MetaMask 钱包扩展!');
        return;
      }

      try {
        // 尝试获取已授权的账户
        const accounts = await window.ethereum.request({
          method: 'eth_accounts',
        });
        if (accounts.length > 0) {
          // 如果已有授权账户,直接初始化 provider 和 signer
          await initProviderAndSigner(accounts[0]);
        }
        // 获取当前网络ID
        const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始化检查钱包连接失败:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);
}

eth_accounts 这个方法是关键,它不会弹出授权框,而是静默返回已被当前 DApp 授权的账户列表。如果列表不为空,说明用户之前已经连接过,我们可以直接恢复状态。这是解决“刷新后状态丢失”问题的核心。

第二步:实现连接与断开功能

连接功能就是主动弹出授权请求。这里要注意错误处理,特别是用户拒绝授权的情况。

const connectWallet = useCallback(async () => {
  if (!window.ethereum) {
    setError('请安装 MetaMask 钱包扩展!');
    return;
  }

  setIsConnecting(true);
  setError('');
  try {
    // 1. 请求账户授权,这会弹出 MetaMask 窗口
    const accounts = await window.ethereum.request({
      method: 'eth_requestAccounts',
    });
    // 2. 用获取到的第一个账户初始化
    await initProviderAndSigner(accounts[0]);
    // 3. 获取当前网络
    const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
    setChainId(parseInt(chainIdHex, 16));
  } catch (err: any) {
    // 用户拒绝授权是最常见的错误
    if (err.code === 4001) {
      setError('您拒绝了钱包连接请求。');
    } else {
      setError(`连接失败: ${err.message}`);
    }
    console.error('连接钱包失败:', err);
  } finally {
    setIsConnecting(false);
  }
}, []);

const disconnectWallet = useCallback(() => {
  // 注意:ethers.js 和 MetaMask 没有真正的“断开连接”API。
  // 所谓的断开,只是清除我们本地应用的状态。
  setProvider(null);
  setSigner(null);
  setAccount('');
  setChainId(0);
  setError('');
  // 在实际项目中,你可能还需要清除 localStorage/SessionStorage 中的相关状态
}, []);

这里有个大坑:很多新手(包括当时的我)会寻找 disconnectlogout 方法。但实际上,MetaMask 的权限模型是“一次授权,持续有效”,直到用户在其钱包界面手动移除站点权限。所以前端的“断开”只是前端自己清空状态,下次用 eth_accounts 检查时,如果用户没移除权限,还是会拿到地址。这是一个重要的认知点。

第三步:监听钱包事件(关键!)

这是让应用“活”起来,响应外部变化的核心。我们需要监听 accountsChangedchainChanged 事件。

// 初始化 provider 和 signer 的辅助函数
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
  if (!window.ethereum) return;
  // 创建 Provider
  const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
  setProvider(web3Provider);
  // 创建 Signer
  const web3Signer = web3Provider.getSigner();
  setSigner(web3Signer);
  setAccount(accountAddress);
}, []);

// 设置事件监听
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged 事件触发:', accounts);
    if (accounts.length === 0) {
      // 用户在所有界面断开了连接,或者切换到了一个没有权限的账户
      disconnectWallet();
      setError('请连接您的钱包账户。');
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      initProviderAndSigner(accounts[0]);
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // 注意:chainId 是十六进制字符串
    console.log('chainChanged 事件触发:', _chainId);
    // 当网络切换时,MetaMask 建议刷新页面,因为许多链上数据可能失效。
    // 但为了更好体验,我们可以只重置部分状态并重新获取链ID。
    window.location.reload();
    // 更优雅的做法:不刷新,只更新 chainId 并重新初始化 provider(可能需要新的 RPC 配置)
    // setChainId(parseInt(_chainId, 16));
    // initProviderAndSigner(account); // 重新初始化,因为网络变了
  };

  // 绑定监听器
  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清理监听器,防止内存泄漏
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    }
  };
}, [account, disconnectWallet, initProviderAndSigner]);

注意这个细节chainChanged 事件的处理。早期文档和很多教程都建议直接 window.location.reload(),因为网络切换后,旧的 provider 实例可能指向错误的 RPC。虽然刷新简单粗暴,但体验不好。更优的方案是:更新 chainId,然后基于新的 chainId 创建一个新的 provider 实例(如果你配置了多链 RPC 的话)。我这里为了代码清晰,先用了刷新方案。

第四步:获取余额与完善 UI

有了 provideraccount,获取余额就很简单了。但要注意异步操作和错误处理。

const [balance, setBalance] = useState<string>('0');

// 获取余额的函数
const fetchBalance = useCallback(async () => {
  if (!provider || !account) {
    setBalance('0');
    return;
  }
  try {
    const balanceWei = await provider.getBalance(account);
    // 格式化为 Ether 单位,保留4位小数
    const balanceEth = ethers.utils.formatEther(balanceWei);
    setBalance(parseFloat(balanceEth).toFixed(4));
  } catch (err) {
    console.error('获取余额失败:', err);
    setBalance('0');
  }
}, [provider, account]);

// 当 account 或 provider 变化时,重新获取余额
useEffect(() => {
  fetchBalance();
}, [fetchBalance]);

最后,将这些状态和方法暴露给组件,一个基础但健壮的钱包连接 Hook 就完成了。

完整代码

以下是一个整合了上述所有功能的 React 组件示例:

// WalletConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [balance, setBalance] = useState<string>('0');
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const initProviderAndSigner = useCallback(async (accountAddress: string) => {
    if (!window.ethereum) return;
    const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
    const web3Signer = web3Provider.getSigner();
    setProvider(web3Provider);
    setSigner(web3Signer);
    setAccount(accountAddress);
  }, []);

  const fetchBalance = useCallback(async () => {
    if (!provider || !account) {
      setBalance('0');
      return;
    }
    try {
      const balanceWei = await provider.getBalance(account);
      const balanceEth = ethers.utils.formatEther(balanceWei);
      setBalance(parseFloat(balanceEth).toFixed(4));
    } catch (err) {
      console.error('获取余额失败:', err);
      setBalance('0');
    }
  }, [provider, account]);

  const connectWallet = useCallback(async () => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展!');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
      await initProviderAndSigner(accounts[0]);
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      setChainId(parseInt(chainIdHex, 16));
    } catch (err: any) {
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(`连接失败: ${err.message}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, [initProviderAndSigner]);

  const disconnectWallet = useCallback(() => {
    setProvider(null);
    setSigner(null);
    setAccount('');
    setChainId(0);
    setBalance('0');
    setError('');
  }, []);

  // 初始化检查与事件监听
  useEffect(() => {
    if (!window.ethereum) {
      setError('未检测到 Web3 钱包。请安装 MetaMask。');
      return;
    }

    const checkInitialConnection = async () => {
      try {
        const accounts = await window.ethereum!.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          await initProviderAndSigner(accounts[0]);
        }
        const chainIdHex = await window.ethereum!.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始连接检查出错:', err);
      }
    };

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
        setError('账户已断开。');
      } else if (accounts[0] !== account) {
        initProviderAndSigner(accounts[0]);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      // 简单处理:刷新页面
      window.location.reload();
    };

    checkInitialConnection();

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [account, disconnectWallet, initProviderAndSigner]);

  // 余额监听
  useEffect(() => {
    fetchBalance();
  }, [fetchBalance]);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>钱包连接状态</h2>
      {error && <p style={{ color: 'red' }}>错误: {error}</p>}
      
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p><strong>连接地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>网络 ID:</strong> {chainId}</p>
          <p><strong>余额:</strong> {balance} ETH</p>
          <button onClick={disconnectWallet} style={{ marginTop: '10px' }}>
            断开连接(前端)
          </button>
          <p style={{ fontSize: '0.8em', color: '#666', marginTop: '5px' }}>
            (注:需在 MetaMask 中移除站点权限才能完全断开)
          </p>
        </div>
      )}
      
      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#333' }}>
        <p>试试以下操作,观察页面变化:</p>
        <ul>
          <li>在 MetaMask 中切换账户</li>
          <li>在 MetaMask 中切换网络(如 Goerli 测试网)</li>
          <li>刷新页面</li>
        </ul>
      </div>
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:在 TypeScript 中直接使用 window.ethereum 会报类型错误。我一开始用 (window as any).ethereum 粗暴解决,后来发现这不利于代码维护。最终通过扩展 global 接口提供了更精确的类型定义,并检查必要方法是否存在。
  2. accountsChanged 事件在断开时触发空数组:我最初只监听新账户,没处理 accounts.length === 0 的情况。导致用户在 MetaMask 里断开连接后,我的应用界面还显示着旧地址。加上这个判断后,体验才正常。
  3. 网络切换后 Provider 失效:这是我遇到最棘手的问题。用户切换网络后,旧的 provider 实例发出的请求可能仍发往旧的 RPC 节点,导致各种 UNSUPPORTED_OPERATION 或网络错误。我尝试过在 chainChanged 事件里创建新的 provider,但有时会碰到异步时序问题。最后,对于这个简单 demo,我采用了 MetaMask 官方早期文档推荐的页面刷新方案。在真实复杂项目中,需要结合项目状态管理库(如 Redux、Zustand)和自定义的多链 RPC 配置来更优雅地处理。
  4. 余额显示单位问题provider.getBalance() 返回的是 BigNumber 类型的 wei 单位。直接 toString() 会显示一长串数字。必须用 ethers.utils.formatEther() 进行单位转换。同时要注意转换后的精度显示,避免出现过多小数位。

小结

这次折腾让我彻底明白,一个稳定的钱包连接不仅仅是调用一个 API,而是一个需要持续维护状态、监听外部事件、并妥善处理各种边界情况的完整功能模块。虽然现在有 wagmiRainbowKit 这样优秀的封装库,但理解其底层原理,亲手用 ethers.js 实现一遍,对于排查复杂问题和构建定制化需求依然至关重要。下一步,我可以在此基础上集成多链支持、钱包连接缓存(localStorage)以及更优雅的网络切换处理逻辑。

OpenSpec 完全指南:让 AI 编码可预测的规范框架

一、AI 编程的"甜蜜陷阱"

随着 AI 编程助手日趋智能,开发者往往在"无需动脑、不用写文档"的诱惑下上手,但随之而来的是输出无序、需求跑偏、维护困难等隐患——这正是所谓的"甜蜜陷阱"。


二、规范驱动开发

以"明确做什么、为什么做"为前提,引入结构化规范文档,让 AI 从"规范"而非零散提示中读取需求,从而输出更可控、更易维护的代码。


三、为什么选用 OpenSpec

OpenSpec 是一款轻量、灵活的 AI 辅助开发规范工具,具有以下核心优势:

  • 轻量灵活:无刚性阶段门,随时迭代,避免繁文缛节
  • 工具自由:支持 20+ AI 编码助手(如 Claude Code、Cursor、Windsurf 等)
  • 可预测性:标准化文档模板,减少返工
  • 组织有序:每个变更独立文件夹,便于管理与追溯

项目地址gitcn.org/projects/10…


四、不用 spec 与用 OpenSpec 的对比

维度 无规范 有 OpenSpec
Token 消耗 多次返工,额外提示消耗高 标准模板+上下文注入,降低 30%–50%
编码效果 风格不一、逻辑混乱、难维护 风格统一、Bug 率降低、维护便捷
开发效率 反复沟通,效率低 输出精准,效率提升 ≥ 40%
团队协作 沟通成本高,新人难上手 模板+流程,新人快速入门
可维护性 代码意图不明,维护困难 规范文档留档,意图清晰

五、快速开始

5.1 安装要求

系统要求:Node.js 20.19.0+

# 全局安装
npm install -g @fission-ai/openspec@latest

# 或使用 pnpm / yarn / bun
pnpm add -g @fission-ai/openspec
yarn global add @fission-ai/openspec
bun install -g @fission-ai/openspec

5.2 初始化项目

# 进入项目目录
cd your-project

# 初始化 OpenSpec
openspec init

示例交互(向 AI 请求 config.yaml):

我是项目新手,请帮我写 @openspec/config.yaml 一份标准可直接使用的配置文件。
项目说明:

  • 类型:ELN/FCM 设备数据采集程序
  • 语言:C#
  • 内容:多设备通信、数据采集、协议解析、日志、配置、调试
    要求:YAML 语法正确、有中文注释、包含项目元信息、模块划分、阶段管理、文档归档、任务管理、AI 辅助……

5.3 配置工作流

默认模式(Core Profile):4 个核心命令

命令 说明
/opsx:propose 创建变更提案
/opsx:explore 探索方案
/opsx:apply 实现任务
/opsx:archive 归档变更

扩展模式(Expanded Profile):更多命令

# 切换到扩展模式
openspec config profile expanded
openspec update

六、OPSX 工作流详解

6.1 核心理念

以"行动"为中心,文档赋能但非强制,支持随时创建、实现、更新、归档。

6.2 核心工作流(Core Profile)

步骤 1:探索想法(可选)

/opsx:explore 如何为移动应用处理认证

步骤 2:创建变更

/opsx:propose add-dark-mode

自动生成文件结构:

openspec/changes/add-dark-mode/
├── proposal.md
├── specs/
├── design.md
└── tasks.md

步骤 3:实现任务

/opsx:apply

步骤 4:归档

/opsx:archive

6.3 扩展工作流(Expanded Profile)关键操作

命令 说明
/opsx:new [change-name] 创建变更脚手架
/opsx:continue 逐个生成文档
/opsx:ff 快速推进
/opsx:verify 验证实现符合规范
/opsx:sync 同步状态
/opsx:bulk-archive 批量归档
/opsx:onboard 新成员引导

七、项目配置

7.1 配置文件(openspec/config.yaml)示例

# openspec/config.yaml
context: |
  团队:后端组
  代码审查:至少 2 人批准
  部署:自动 CI/CD
  监控:Datadog 告警

rules:
  proposal:
    - 包含产品负责人批准
    - 识别依赖团队
  design:
    - 架构审查会议记录
    - 性能基准测试计划

八、文档模板参考

8.1 变更提案(proposal.md

# 变更提案:{功能名称}

## 问题陈述
描述当前存在的问题或需求背景。

## 目标
明确变更要达成的目标(可衡量)。

## 范围
- 包含:需要实现的功能点
- 不包含:明确排除的内容

## 风险与回滚计划
- 潜在风险:列举可能的风险
- 回滚计划:风险发生时的回滚方案

8.2 需求规范(specs/

  • 场景描述、验收标准
  • 推荐使用 Given/When/Then 格式

8.3 设计文档(design.md

  • 架构设计
  • 接口定义
  • 数据设计
  • 安全与性能

8.4 实现任务(tasks.md

# 实现任务:{功能名称}

## 任务列表

### 任务 1:组件开发
- [ ] 1.1 创建主题上下文提供者
  - 文件:src/context/ThemeContext.tsx
  - 测试:src/context/ThemeContext.test.tsx

## 完成标准
- [ ] 所有任务完成
- [ ] 测试通过
- [ ] 代码审查通过

九、命令参考

9.1 Core Profile

命令 说明
/opsx:propose [change-name] 创建变更提案
/opsx:explore [topic] 探索方案
/opsx:apply 实现当前任务
/opsx:archive 归档已完成变更

9.2 Expanded Profile

命令 说明
/opsx:new [change-name] 创建变更脚手架
/opsx:continue 继续生成文档
/opsx:ff 快速推进
/opsx:verify 验证实现
/opsx:sync 同步状态
/opsx:bulk-archive 批量归档
/opsx:onboard 新成员引导

十、最佳实践

10.1 文档编写

  • 注入项目上下文,保持简洁
  • AI 初稿后人工审查
  • 使用 Given/When/Then 编写场景
  • 功能完成后及时归档

10.2 工作流选择

场景 推荐模式
小型 / 个人项目 Core Profile
大型 / 团队协作 Expanded Profile

10.3 模型选择

推荐使用高推理能力模型:

  • Claude Opus 4.5
  • GPT-5.2

十一、团队协作

11.1 团队流程

产品经理 → 提案 → 技术负责人审查 → 开发实现 → 测试验证 → 归档

11.2 审查清单

提案审查

  • 问题陈述清晰
  • 目标可衡量
  • 范围明确
  • 风险与回滚方案完整

设计审查

  • 架构合理
  • 接口定义清晰
  • 安全考虑充分
  • 性能目标明确

十二、与 AI 工具集成

12.1 支持工具

支持 20+ 编码助手,包括:Claude Code、Cursor、Windsurf、Codex 等。

12.2 技能文件示例

---
name: openspec-propose
description: Create a new OpenSpec change proposal
---
# OpenSpec Propose Skill
# ... 技能内容

12.3 提示词技巧

类型 示例
"基于 openspec/config.yaml 的上下文,为用户认证模块添加 JWT 刷新令牌支持"
"添加登录功能"

十三、更新与维护

13.1 更新 OpenSpec

npm install -g @fission-ai/openspec@latest
openspec update

13.2 版本兼容

版本 要求
v1.x Node.js 20.19.0+
v2.x OPSX 工作流

13.3 退出遥测

export OPENSPEC_TELEMETRY=0
export DO_NOT_TRACK=1

十四、故障排查

14.1 常见问题

问题 解决方案
技能未被检测 运行 openspec update
命令不工作 检查版本并重新安装
配置不生效 确保文件路径为 openspec/config.yaml

14.2 性能优化(大项目)

openspec sync --incremental
openspec bulk-archive --before 2026-01-01

十五、总结

15.1 核心价值

  • 可预测性:规范驱动,减少 AI 输出偏差
  • 组织性:每个变更独立管理,清晰可追溯
  • 灵活性:无强制流程,按需使用
  • 工具自由:支持主流 AI 编码助手
  • 轻量级:学习成本低,快速上手

15.2 适用场景

适用 不适用
新功能开发 简单 Bug 修复
重构 快速原型
复杂 Bug 修复 纯探索性开发
团队协作

15.3 快速上手步骤

# 1. 安装
npm install -g @fission-ai/openspec

# 2. 初始化
openspec init

# 3. 创建第一个变更
/opsx:propose your-feature

加入社区discord.gg/YctCnvvshC


参考资料

资源 链接
OpenSpec GitHub github.com/Fission-AI/…
官方文档 github.com/Fission-AI/…
npm 包 www.npmjs.com/package/@fi…
Discord 社区 discord.gg/YctCnvvshC
X/Twitter x.com/0xTab

本文基于 OpenSpec v2.x 编写,功能与命令可能随版本更新而变化,请参考官方文档获取最新信息。

深入理解HTTP:请求/响应、缓存机制、登录态与跨域

深入理解 HTTP 协议(含 Cookie 与 JWT)

1. 为什么需要 HTTP?

在 Web 环境中,客户端(如浏览器)与服务器需要交换信息。若没有统一规则,不同厂商的软件无法互操作。HTTP(HyperText Transfer Protocol,超文本传输协议)定义了:

  • 请求的格式(方法、路径、头部、正文)
  • 响应的格式(状态码、头部、正文)
  • 资源定位方式(URL)
  • 连接管理、缓存控制、状态保持等机制

简单来说:HTTP 是浏览器与服务器之间约定的“通信语言”,确保双方能准确理解对方意图。

2. HTTP 版本演进

版本 核心特性 主要局限
HTTP/0.9 仅 GET 方法,只能返回纯文本(如早期的 HTML) 功能极简,无法传输图片或样式
HTTP/1.0 引入状态码、头部、POST/HEAD 方法 短连接:每次请求需重新建立 TCP 连接
HTTP/1.1 持久连接(keep-alive)、管道化、Host 头 队头阻塞:同一连接上的请求必须串行响应
HTTP/2 二进制分帧、多路复用、头部压缩、服务器推送 TCP 层面的队头阻塞仍存在
HTTP/3 基于 QUIC(UDP) 尚未完全普及

每个新版本都在解决前一版本的核心瓶颈。

3. HTTP 报文结构

3.1 请求报文

POST /api/user HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 18

{"name":"Alice"}

组成:

  • 请求行:方法、请求目标、HTTP版本
  • 头部字段:键值对
  • 空行:分隔头部与正文
  • 消息正文:可选

3.2 响应报文

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 27

{"id":1,"name":"Alice"}

组成:

  • 状态行:HTTP版本、状态码、原因短语
  • 头部字段、空行、正文(同上)

4. HTTP 方法(动作语义)

方法 语义 是否携带主体
GET 获取资源
HEAD 仅获取响应头部
POST 创建资源
PUT 全量替换资源
PATCH 部分更新资源
DELETE 删除资源 可有可无
OPTIONS 查询服务器支持的方法

语义核心:GET 用于读取,POST 用于创建,PUT/PATCH 用于更新,DELETE 用于删除。

5. HTTP 状态码分类

类别 范围 含义 典型示例
1xx 100-101 信息性响应 100 Continue
2xx 200-206 成功 200 OK,204 No Content
3xx 300-308 重定向 301 永久搬家,302 临时跳转,304 未修改
4xx 400-451 客户端错误 400 错误请求,401 未认证,403 禁止,404 未找到
5xx 500-511 服务器错误 500 内部错误,503 服务不可用

速记:2xx 成功,3xx 去别处,4xx 你错了,5xx 它错了。

6. HTTPS 的安全机制

HTTP 是明文传输,存在窃听、篡改、冒充三大风险。HTTPS = HTTP + TLS/SSL,在 TCP 与 HTTP 之间增加安全层。

风险 解决方案
窃听(内容被看) 混合加密(非对称交换对称密钥,对称加密数据)
篡改(内容被改) 消息认证码校验完整性
冒充(假网站) 数字证书(CA 签发),验证服务器身份

结论:HTTPS 比 HTTP 多了加密、认证、完整性保护三层机制。

7. HTTP 缓存机制

缓存可减少重复请求,提升性能。分为两类:

类型 控制字段 行为
强制缓存 Cache-Control: max-age=3600、Expires 有效期内直接使用本地副本,不发请求
协商缓存 ETag / If-None-Match、Last-Modified / If-Modified-Since 向服务器验证资源是否过期;若未变化返回 304

优先级:Cache-Control > Expires;服务器通常优先验证ETag,再验证 Last-Modified

流程图如下:

8. 一次完整的 HTTP 事务

从输入 URL 到页面展示(HTTP 相关部分):

  1. DNS 解析:域名 → IP 地址。
  2. TCP 连接:三次握手建立连接。
  3. 发送请求:浏览器构建 HTTP 请求报文,通过 TCP 发送。
  4. 服务器处理:解析请求,执行逻辑,生成响应。
  5. 返回响应:服务器发送 HTTP 响应报文。
  6. 浏览器处理: 拿到 HTML 后开始关键渲染。
  7. 连接管理:若 Connection: keep-alive,连接保持;否则关闭连接。

若为 HTTPS,则在 TCP 连接后增加 TLS 握手(证书验证、密钥协商)。

完整流程如下图:

HTTP 事务完成后,浏览器进入渲染流程(详见《深入理解浏览器渲染流程》),如下图所示:

9. 状态保持机制:Cookie 与 JWT

HTTP 本身是无状态协议——每个请求都是独立的。为了实现登录状态等功能,需要在请求之间传递身份标识。Cookie 和 JWT 是两种主流的解决方案。

9.1 Cookie

Cookie 是服务器通过 Set-Cookie 头部要求浏览器保存的文本。浏览器在后续同源请求中自动携带。

工作流程

  1. 服务器响应:Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure
  2. 浏览器保存该 Cookie。
  3. 后续请求自动携带:Cookie: sessionId=abc123

常用属性

属性 作用
Expires / Max-Age 控制有效期
Path 限制作用路径
Domain 限制作用域名
HttpOnly 禁止 JavaScript 访问(防 XSS:在你页面执行恶意 JS )
Secure 设置了 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">Secure</font> 的 Cookie 仅通过 HTTPS 传输,不会在 HTTP 请求中发送(避免明文泄露 ),Secure 不直接防 CSRF,但它是构建安全登录体系的基础,配合 <font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">SameSite</font> 才能构成完整防御
SameSite 限制 Cookie 只在本网站发送,跨站请求不自动带 Cookie,控制跨站请求是否携带(防 CSRF)

优点:浏览器自动管理。
缺点:跨域支持复杂,有 CSRF 风险。(攻击者利用你已经在目标网站登录的登录状态,在别的恶意页面里,偷偷替你发起操作请求)

9.2 JWT(JSON Web Token)

JWT 是一种紧凑的令牌格式。服务器登录后返回加密签名字符串,客户端后续请求手动携带。

JWT 结构xxxxx.yyyyy.zzzzz,三部分组成:

  • Header:我是什么类型、用什么算法
  • Payload:我带了什么数据(公开可见)
  • Signature:我没被篡改(服务器密钥保证)

工作流程

  1. 用户登录,服务器验证成功后生成 JWT 并返回。
  2. 客户端保存 JWT(通常存于 localStorage)。
  3. 客户端在后续请求头部添加:Authorization: Bearer <JWT>
  4. 服务器验证签名,读取用户信息。

优点

  • 无状态,易于水平扩展。
  • 跨域友好。
  • 自包含用户信息。

缺点

  • 无法主动注销(过期前始终有效)。
  • 体积较大。
  • 存于 localStorage 时易受 XSS 攻击。

9.3 Cookie vs JWT 对比

维度 Cookie JWT
存储位置 浏览器自动存储(可设 HttpOnly) 前端手动存储(通常 localStorage)
传输方式 自动携带(Cookie 头) 手动放入 Authorization 头
跨域支持 需配置 withCredentials 和 CORS 配置简单 (只需基础 CORS)
状态管理 服务端存储会话(有状态) 服务端无状态(令牌自包含)
安全风险 CSRF XSS
主动注销 服务端删除会话即可 需引入黑名单或缩短有效期
适用场景 传统 Web 应用(同域) 移动端、微服务、跨域 API

10. 跨域(CORS)

10.1 为什么会有跨域?

浏览器的同源策略(Same-Origin Policy)限制:协议、域名、端口三者任意一个不同,即为跨域。同源策略的目的是防止恶意脚本读取其他网站的数据(如窃取 Cookie、劫持登录状态)。

10.2 跨域解决方案

方案 原理 适用场景
CORS(跨域资源共享) 服务器设置响应头 Access-Control-Allow-Origin 允许指定源访问 标准方案,支持所有请求方法,需后端配合
JSONP 利用 <script> 标签不受同源限制,只支持 GET 老旧浏览器兼容,仅 GET 请求
代理服务器 前端请求同源代理,代理转发到目标服务器 开发环境(webpack devServer)、生产环境(nginx)
postMessage 跨窗口通信 API 不同源的 iframe 或弹出窗口通信
WebSocket 原生支持跨域 实时双向通信

10.3 CORS 核心响应头

响应头 作用
Access-Control-Allow-Origin 允许的源(* 或具体域名)
Access-Control-Allow-Methods 允许的 HTTP 方法(GET, POST, PUT, DELETE 等)
Access-Control-Allow-Headers 允许的自定义请求头
Access-Control-Allow-Credentials 是否允许携带 Cookie(设为 true 时,Allow-Origin 不能为 *
Access-Control-Max-Age 预检请求(OPTIONS)的缓存时间(秒)

10.4 预检请求(OPTIONS)

什么是预检请求?

预检请求是在跨域的前提下,浏览器在发送非简单请求前,自动发起的一个 OPTIONS 请求,用于询问服务器是否允许实际请求。代码中无需手动编写。

简单请求 vs 非简单请求
类型 条件 是否触发预检
简单请求 方法为 GET/HEAD/POST,且 Content-Type 为表单类型(application/x-www-form-urlencodedmultipart/form-datatext/plain),且无自定义头 不触发
非简单请求 不满足上述任一条件(如 PUT/DELETE 方法、application/json、携带 Authorization 头等) 触发预检

注:application/x-www-form-urlencoded 是默认的普通表单类型,multipart/form-data 用于带文件上传的表单,text/plain 为纯文本格式;application/json 用来发送 JSON 格式数据,它不属于传统表单类型,是 Ajax 常用格式,属于非简单请求。

11. 总结

HTTP 定义了请求与响应的格式、方法、状态码、缓存规则。它本身无状态,因此引入 Cookie 和 JWT 维持用户状态。跨域问题由浏览器的同源策略引起,CORS 是标准的解决方案,其中预检请求用于保护非简单跨域请求的安全性。理解这些概念,就能处理跨域、缓存、安全、登录态管理等前端核心问题。

前端登录菜单加载性能优化总结

前端登录菜单加载性能优化总结

问题现象

登录后页面出现明显卡顿,接口返回约 900 条菜单权限数据,数据量不大但前端处理耗时明显,主线程被阻塞。

瓶颈分析

整个调用链路:获取菜单接口 → 扁平数组转树形结构 → 存储菜单状态 → 解析路由和权限

排查后发现有两处性能问题:

瓶颈一:扁平数组转树形结构 — O(N²) 复杂度

问题:内层递归函数对每个节点都遍历整个剩余数组查找子节点,加上 Array.splice() 从数组中间删除元素,整体时间复杂度为 O(N²)。

// 原实现(简化)
function arrayToTree(parent, level) {
  var k = list.length - 1;
  while (k >= 0) {
    if (parent.id === list[k].pid) {
      children.push(list[k]);
      list.splice(k, 1);        // O(N) 删除
      arrayToTree(list[k], level + 1); // 递归后又从头遍历
    }
    k--;
  }
}

优化:一次遍历建立 pid → children[] 的 Map 索引,递归时通过 Map 直接查找子节点,时间复杂度降为 O(N)。

// 优化后
var childrenMap = {};
items.forEach(function (item) {
  var pid = item[option.pid].toString();
  if (!childrenMap[pid]) childrenMap[pid] = [];
  childrenMap[pid].push(item);
});

function buildTree(parent, level) {
  var children = childrenMap[parent[option.id].toString()];
  if (!children) return;
  // 直接通过 Map 拿到子节点,无需遍历整个数组
}

瓶颈二:循环内反复触发框架级 API(动态路由注册 + 状态更新)

问题:这是最主要的瓶颈。递归遍历 900 个节点时,每遇到符合条件的节点就同步调用:

  1. router.addRoute() — 每次调用都会触发路由匹配器重新编译,N 次调用相当于 O(N²) 的编译开销
  2. store.commit() — 每次提交都用扩展运算符 [...oldArr, ...newArr] 创建新数组,随着累积数组越来越大,开销递增
// 原实现(简化)
data.reduce((pre, cur) => {
  // 每个节点都触发一次框架 API
  router.addRoute('layout', { ... });     // 重建路由匹配表
  store.commit('UPDATE_MENU', { ... });   // 展开合并数组
  if (cur.children.length) traverse(cur.children, ...);
}, []);

优化:先收集,后批量操作。遍历过程中只往普通数组/对象中 push 数据,遍历完成后统一执行副作用。

// 优化后
var pendingRoutes = [];
var pendingMenus = {};

function collect(data, ...) {
  // 遍历中只收集数据,不调用框架 API
  pendingRoutes.push({ ... });
  pendingMenus[key] = (pendingMenus[key] || []).concat(items);
}

collect(data, ...);

// 遍历完成后批量添加路由
pendingRoutes.forEach(route => router.addRoute('layout', route));

// 一次性合并菜单数据到状态树
Object.keys(pendingMenus).forEach(key => { ... });

优化效果

环节 优化前 优化后
数组转树 O(N²),含 splice 删除 O(N),Map 索引查找
动态路由注册 每个节点调用一次,反复重建匹配表 收集后批量添加
状态更新 每个节点 commit 一次,反复展开数组 收集后一次性合并

经验教训

  1. 数据量不大 ≠ 不会慢:几百条数据看似不多,但 O(N²) 算法 + 循环内触发框架重计算,开销会被成倍放大
  2. 注意框架 API 的隐性成本router.addRoute 不是简单的数组 push,每次调用都有路由匹配器重编译的开销;store.commit 如果涉及数组展开合并,高频调用同样代价不小。应避免在循环中高频调用这类 API
  3. 先收集后批量是处理这类问题的通用模式:把"遍历"和"副作用"分离,遍历阶段只做纯数据收集,副作用(路由注册、状态提交、DOM 操作等)统一在最后批量执行

vite+vue2 动态路由加载方法实现

最近在改老项目,将webpack迁移到vite提高下速度 首先来看下默认静态加载路由,我们只需要在router/index.js直接配置好就可以了

dynamicRoutes_01.png

当然默认的情况 component: () => import('../views/HomeView.vue') 是这样的如果需要用@替代..需要在在vite.config.js中增加下配置

resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src')
    }
},

dynamicRoutes_02.png

在webpack中动态加载使用如下,就可以了

export const loadView = (view) => {
  if (process.env.NODE_ENV === 'development') {
    return (resolve) => require([`@/views/${view}`], resolve)
  } else {
    // 使用 import 实现生产环境的路由懒加载
    return () => import(`@/views/${view}`)
  }
}

但是在vite中语法就变了,require() 是 CommonJS 语法,Vite 不支持,需要用import.meta.glob来实现,下面是相对路径使用,相对路径使用要注意当前方法引入对应根目录的层级

//代码文件顶部增加
const modules = import.meta.glob('../views/**/*')
//然后定义loadView 方法
export const loadView = (view) => {
    return modules[`../views/${viewPath}.vue`]  // 使用相对路径
}

在这里比较推荐使用相对路径来实现

//代码文件顶部增加
const modules = import.meta.glob('/src/views/**/*')
export const loadView = (view) => {
    return modules[`/src/views/${viewPath}.vue`]  // 使用绝对路径
}

说明一下如果从后端获取的动态组件路径是带.vue文件名字的可以忽略

最后又完善了一下增加了一些模糊匹配规则

/src/views/${viewName}.vue
/src/views/${viewName}/index.vue
/src/views/${viewName}/${viewName}.vue

完整的loadView 方法

export const loadView = (view) => {
    // 统一处理:无论后端是否带 .vue,都确保有后缀
    const viewName = view.replace(/\.vue$/, '')
    const possiblePaths = [
        `/src/views/${viewName}.vue`,
        `/src/views/${viewName}/index.vue`,
        `/src/views/${viewName}/${viewName}.vue`,
    ]

    for (const path of possiblePaths) {
        // const loader = modules[path]
        if (modules[path]) {
            console.log('✅ 匹配组件:', path)
            return modules[path]
        }
    }
    console.error(`未找到页面组件: ${viewName}`)
    console.log('可用页面组件:', Object.keys(modules))
    return null
}

演示demo

dynamicRoutes.gif

原文 www.liweiliang.com/1204.html

前端害怕被蒸馏 快速入门Python 【demo_03】

Python 基础知识点技术说明文档

概述

本文档总结了 Python 编程中的核心概念,包括自定义迭代器、生成器、高阶函数、装饰器、上下文管理器、异常处理和异步编程。这些知识点是构建企业级应用的基石。

1. 自定义迭代器

定义

自定义迭代器需要实现 __iter__()__next__() 方法。__iter__() 方法返回迭代器对象本身,而 __next__() 方法返回下一个元素,如果没有更多元素可供迭代,则抛出 StopIteration 异常。

示例代码

print("使用自定义迭代器:")
class MyIterator:
    def __init__(self, start, end):
        self.current = start
        self.end = end

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration
        self.current += 1
        return self.current - 1

for num in MyIterator(0, 3):
    print(num)  # 输出 0, 1, 2

应用场景

  • 自定义数据流处理,如分页数据加载。
  • 企业级:大数据处理框架中的自定义数据源。

2. 生成器函数和表达式

生成器函数

生成器函数使用 yield 关键字来生成一个迭代器,每次调用 next() 时会从上次 yield 的位置继续执行,直到遇到 StopIteration 异常。

示例代码

print("\n使用生成器函数:")
def my_generator(start, end):
    current = start
    while current < end:
        yield current
        current += 1
for num in my_generator(0, 3):
    print(num)  # 输出 0, 1, 2

生成器表达式

生成器表达式是一种简洁的语法,用于创建生成器对象。它类似于列表推导式,但使用圆括号而不是方括号。

print("\n使用生成器表达式:")
squares = (x**2 for x in range(5))
for square in squares:
    print(square)  # 输出 0, 1, 4, 9, 16

应用场景

  • 内存高效处理大数据集。
  • 企业级:流式数据处理、日志分析。

3. 高阶函数

主要函数

  • map(func, iterable): 对每个元素应用函数。
  • reduce(func, iterable): 累积计算。
  • filter(func, iterable): 筛选元素。
  • zip(*iterables): 打包多个可迭代对象。
  • enumerate(iterable): 添加索引。
  • sorted(iterable): 排序(不修改原列表)。
  • reversed(iterable): 反转。
  • all(iterable): 所有元素为真。
  • any(iterable): 至少一个元素为真。

示例代码

print("\n1. map() 函数:")
squared = map(lambda x: x**2, range(5)) # range(5) 生成一个迭代器,包含 0, 1, 2, 3, 4
print(list(squared))  # 输出 [0, 1, 4, 9, 16]

print("\n2. reduce() 函数:")
from functools import reduce
product = reduce(lambda x, y: x * y, range(1, 5)) # range(1, 5) 生成一个迭代器,包含 1, 2, 3, 4
print(product)  # 输出 24 (1*2*3*4)

print("\n3. filter() 函数:")
even_numbers = filter(lambda x: x % 2 == 0, range(10))
print(list(even_numbers))  # 输出 [0, 2, 4, 6, 8]

print("\n4. zip() 函数:")
list1 = [1, 2, 3]
list2 = ['a', 'b', 'c']
zipped = zip(list1, list2)
print(list(zipped))  # 输出 [(1, 'a'), (2, 'b'), (3, 'c')]
list3 = ['apple', 'banana']
list4 = ['Apple', 'Banana']
zipped_case = zip(list3, list4) # 输出 [('apple', 'Apple'), ('banana', 'Banana')]

# 如何输出[{apple:Apple}, {banana:Banana}]
zipped_case_dict = {k: v for k, v in zipped_case}
print(zipped_case_dict)  # 输出 {'apple': 'Apple', 'banana': 'Banana'}

print("\n5. enumerate() 函数:")
items = ['apple', 'banana', 'cherry']
for index, item in enumerate(items):
    print(index, item)  # 输出 0 apple, 1 banana, 2 cherry
    
print("\n6. sorted() 函数:")
numbers = [3, 1, 4, 1, 5]
sorted_numbers = sorted(numbers)
print(sorted_numbers)  # 输出 [1, 1, 3, 4, 5]
print(numbers)  # 输出 [3, 1, 4, 1, 5] (原列表未被修改)

print("\n7. reversed() 函数:")
numbers = [1, 2, 3, 4, 5]
reversed_numbers = reversed(numbers)
print(list(reversed_numbers))  # 输出 [5, 4, 3, 2, 1]

print("\n8. all() 函数:")
values = [True, True, False]
print(all(values))  # 输出 False
values = [True, True, True]
print(all(values))  # 输出 True

print("\n9. any() 函数:")
values = [False, False, False]
print(any(values))  # 输出 False
values = [False, True, False]
print(any(values))  # 输出 True

应用场景

  • 函数式编程风格。
  • 企业级:数据转换、聚合计算。

4. 装饰器

定义

装饰器是一种函数,它接受一个函数作为参数,并返回一个新的函数。装饰器可以用来修改或增强原函数的行为,而不需要修改原函数的代码。

示例代码

print("\n使用装饰器:")
def decorator(func):
    def wrapper(*args, **kwargs):
        print("这是装饰器的前置操作")
        result = func(*args, **kwargs)
        print("这是装饰器的后置操作")
        return result
    return wrapper
@decorator
def say_hello(name):
    print(f"Hello, {name}!")
say_hello("Alice")  # 输出装饰器的前置操作, Hello, Alice!, 装饰器的后置操作

企业级应用场景

  • 日志记录:自动记录函数调用。
  • 权限验证:检查用户权限。
  • 性能监控:测量执行时间。
  • 缓存:缓存结果。
  • 重试机制:失败时重试。

5. 上下文管理器

定义

上下文管理器是一种对象,它定义了 enter() 和 exit() 方法,可以在 with 语句中使用。上下文管理器可以用来管理资源的获取和释放,例如文件操作、数据库连接等。

示例代码

print("\n使用上下文管理器:")
class MyContextManager:
    def __enter__(self):
        print("进入上下文管理器")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        print("退出上下文管理器")
with MyContextManager() as manager:
    print("在上下文管理器中执行操作")

企业级应用场景

  • 文件操作:自动关闭文件。
  • 数据库连接:自动提交/回滚。
  • 锁管理:自动释放锁。
  • 网络连接:管理连接池。
  • 临时资源:自动清理。

6. 异常处理

语法

异常处理是指在程序运行过程中捕获和处理错误,以避免程序崩溃。Python 使用 try-except 语句来进行异常处理。

示例代码

print("\n使用异常处理:")
try:
    result = 10 / 0  # 这会引发 ZeroDivisionError 异常
except ZeroDivisionError as e:
    print("捕获到异常:", e)

应用场景

  • 错误恢复和日志记录。
  • 企业级:健壮的错误处理策略。

7. 异步和协程

定义

异步编程是一种编程范式,它允许程序在等待某些操作完成时继续执行其他任务。Python 使用 async 和 await 关键字来实现异步编程. 处理高并发 I/O 任务的黄金标准库是 asyncio,它提供了一个事件循环和一套用于编写异步代码的工具。

示例代码

import asyncio
print("\n使用异步和协程:")
async def fetch_data(id):
    print(f"获取数据 {id} 开始")
    await asyncio.sleep(1)  # 模拟非阻塞的异步 I/O 操作
    print(f"获取数据 {id} 结束")
    return {"id": id, "data": "dummy"}

async def main():
    # 并发执行多个任务
    results = await asyncio.gather(
        fetch_data(1),
        fetch_data(2),
        fetch_data(3)
    )
    print(results)

asyncio.run(main())

应用场景

  • 高并发 I/O 任务,如网络请求、文件 I/O。
  • 企业级:Web 服务器、API 客户端、实时数据处理。

总结

直接硬吃干货

Vue<前端页面装修组件>

一个基于 Vue 2 和 Ant Design Vue 1.x 的可视化组件装修工具,支持拖拽排序、属性编辑和实时预览。

gif_1.gif

功能特性

  • 📱 移动端预览:支持自定义尺寸的移动端预览界面
  • 🎨 组件编辑:实时编辑组件属性,所见即所得
  • 📦 组件库:可扩展的组件库系统
  • 🎯 拖拽排序:支持组件的拖拽排序功能
  • 🔧 尺寸调整:可自定义预览区域尺寸,保持等比例缩放

技术栈

  • Vue 2
  • Ant Design Vue 1.x
  • vuedraggable

目录结构

src/
├── components/
│   └── DecorationBuilder/          # 装修工具主目录
│       ├── bases/                  # 基础组件
│       │   ├── Editor/             # 属性编辑器
│       │   ├── Preview/            # 移动端预览组件
│       │   │   ├── components/     # 预览组件的子组件
│       │   │   │   ├── BrowserToolbar/ # 浏览器工具栏
│       │   │   │   └── SizeEditor/     # 尺寸编辑器
│       │   └── Selector/           # 组件选择器
│       ├── config/                 # 配置文件
│       │   ├── componentTypes.js   # 组件类型定义
│       │   └── settings.js         # 全局设置
│       ├── widgets/                # 自定义组件
│       │   ├── Banner/             # 轮播图组件
│       │   ├── News/               # 新闻列表组件
│       │   └── index.js            # 组件注册表
│       └── index.vue               # 装修工具主入口
└── utils/
    ├── componentUtils.js           # 组件相关工具函数
    └── index.js                    # 通用工具函数
graph TD
    A[DecorationBuilder] --> B[bases]
    A --> C[config]
    A --> D[widgets]
    A --> E[index.vue]
    
    B --> F[Editor]
    B --> G[Preview]
    B --> H[Selector]
    
    G --> I[components]
    I --> J[BrowserToolbar]
    I --> K[SizeEditor]
    
    C --> L[componentTypes.js]
    C --> M[settings.js]
    
    D --> N[Banner]
    D --> O[News]
    D --> P[index.js]

核心组件说明

1. DecorationBuilder (主入口)

  • 文件:src/components/DecorationBuilder/index.vue
  • 功能:整合预览、编辑器和选择器组件,管理组件数据和交互逻辑

2. Preview (预览组件)

image.png

  • 文件:src/components/DecorationBuilder/bases/Preview/index.vue
  • 功能:展示移动端预览界面,支持组件拖拽排序
  • 子组件:
    • BrowserToolbar:浏览器工具栏,包含预览、添加组件、发布等功能
    • SizeEditor:尺寸编辑器,用于调整预览区域大小

3. Editor (属性编辑器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Editor/index.vue
  • 功能:动态加载组件编辑器,允许编辑组件属性

4. Selector (组件选择器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Selector/index.vue
  • 功能:展示所有可用组件,支持选择组件添加到预览区

配置文件说明

componentTypes.js

  • 定义组件类型枚举和元数据
  • 包含组件的显示名称、描述、图标等信息
export const COMPONENT_TYPES = {
  BANNER: 'banner',          // 轮播图
  NEWS_LIST: 'news-list'     // 新闻列表
}

export const COMPONENT_METADATA = {
  [COMPONENT_TYPES.BANNER]: {
    name: '轮播图',
    description: '支持多张图片轮播展示',
    icon: 'picture',
    category: '基础组件'
  }
  // ...
}

settings.js

  • 全局配置文件,包含预览设置等
export const PREVIEW_SETTINGS = {
  MOBILE_WIDTH: 375,         // 默认移动端宽度
  MOBILE_HEIGHT: 812         // 默认移动端高度
}

组件工具函数

文件:src/utils/componentUtils.js

主要功能:

  • getComponentMetadata():获取组件元数据
  • getAllComponentTypes():获取所有组件类型
  • getWidgetConfig():获取组件配置
  • getWidgetDefaultProps():获取组件默认属性
  • getWidgetPreview():获取预览组件
  • getWidgetEditor():获取编辑组件

组件映射关系

组件类型 组件名称 预览组件 编辑组件
banner 轮播图 BannerPreview BannerEditor
news-list 新闻列表 NewsPreview NewsEditor

数据格式说明

标准组件数据格式

[
  {
    "id": "1234567890",
    "type": "banner",
    "props": {
      "images": [
        { "url": "https://example.com/image1.jpg", "link": "" },
        { "url": "https://example.com/image2.jpg", "link": "" }
      ],
      "autoPlay": true,
      "interval": 3000,
      "dots": true,
      "arrows": false
    }
  },
  {
    "id": "0987654321",
    "type": "news-list",
    "props": {
      "title": "最新资讯",
      "news": [
        { "id": 1, "title": "新闻标题1", "date": "2026-04-16", "link": "" },
        { "id": 2, "title": "新闻标题2", "date": "2026-04-17", "link": "" }
      ],
      "showDate": true,
      "showArrow": true,
      "maxItems": 5
    }
  }
]

字段说明

  • id:组件唯一标识符,由系统自动生成
  • type:组件类型,对应 COMPONENT_TYPES 中的值
  • props:组件属性,包含所有可配置的参数

数据来源

  1. 默认数据:组件的初始默认属性来自各组件的 index.js 文件中的 defaultProps
  2. 用户配置:用户在编辑器中修改的属性会覆盖默认属性
  3. 保存/发布:最终的组件数据会以标准JSON格式保存或发布

使用方式

  • 前端渲染:通过组件类型动态加载对应的预览组件,并传入props进行渲染
  • 后端存储:可以将JSON数据存储到后端数据库中
  • 页面加载:从后端获取JSON数据后,可以直接传递给DecorationBuilder组件进行渲染

添加新组件指南

以添加一个"轮播的通知公告"组件为例:

1. 创建组件目录和文件

src/components/DecorationBuilder/widgets/ 下创建新组件目录:

NotificationBanner/
├── index.js           # 组件配置文件
├── preview.vue        # 预览组件
└── editor.vue         # 编辑组件

2. 编写组件配置文件 (index.js)

import NotificationBannerPreview from './preview.vue'
import NotificationBannerEditor from './editor.vue'
import { COMPONENT_TYPES } from '../../config/componentTypes'

export default {
  type: COMPONENT_TYPES.NOTIFICATION_BANNER,  // 需要在componentTypes.js中定义
  Preview: NotificationBannerPreview,
  Editor: NotificationBannerEditor,
  defaultProps: {
    // 组件默认属性
    notifications: [
      { id: 1, content: '通知内容1' },
      { id: 2, content: '通知内容2' }
    ],
    autoPlay: true,
    interval: 2000
  }
}

3. 编写预览组件 (preview.vue)

<template>
  <div class="notification-banner">
    <!-- 轮播的通知内容 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerPreview',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

<style scoped>
/* 组件样式 */
</style>

4. 编写编辑组件 (editor.vue)

<template>
  <div class="notification-banner-editor">
    <!-- 属性编辑表单 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerEditor',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

5. 注册组件类型

src/components/DecorationBuilder/config/componentTypes.js 中添加组件类型:

export const COMPONENT_TYPES = {
  // ... 现有类型
  NOTIFICATION_BANNER: 'notification-banner'  // 新增通知公告类型
}

export const COMPONENT_METADATA = {
  // ... 现有元数据
  [COMPONENT_TYPES.NOTIFICATION_BANNER]: {
    name: '通知公告',
    description: '轮播展示通知内容',
    icon: 'bell',
    category: '基础组件'
  }
}

6. 注册组件

src/components/DecorationBuilder/widgets/index.js 中导入并注册新组件:

import BannerComponent from './Banner'
import NewsComponent from './News'
import NotificationBannerComponent from './NotificationBanner'  // 导入新组件

export const widgets = [
  BannerComponent,
  NewsComponent,
  NotificationBannerComponent  // 注册新组件
]

注意事项

  1. 所有组件必须遵循相同的命名和目录结构
  2. 新组件必须在 componentTypes.js 中定义类型和元数据
  3. 预览组件和编辑组件必须正确导出
  4. 默认属性应该在组件的 index.js 中定义

NativeWind v4 与 React Native UI Kit或三方库样式隔离指南

NativeWind v4 与 React Native UI Kit 样式隔离指南

1. 问题背景

在 Expo 或 React Native 项目中同时使用 NativeWind v4 和第三方 UI 库(如 react-native-chat-uikit)时,NativeWind 默认的全局注入机制会导致:

  • 样式冲突:Tailwind 的基础样式(Preflight)污染 UI 库组件。
  • 黑屏/显示异常:NativeWind 运行时尝试接管第三方组件的渲染,或因暗色模式逻辑注入错误的背景变量。

2. 深度隔离方案

第一步:编译层隔离 (Babel)

babel.config.js 中,确保 NativeWind 仅处理项目源码,完全跳过 node_modules

修改前 (Standard NativeWind v4):

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};

修改后 (Isolated Config):

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ["babel-preset-expo"],
    overrides: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/, // 核心:绝对禁止处理任何第三方库
        presets: ["nativewind/babel"],
      },
    ],
  };
};

第二步:物理层隔离 (Tailwind Config)

tailwind.config.js 中设置物理屏障,防止命名碰撞和自动主题注入。

修改前:

module.exports = {
  content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
  presets: [require("nativewind/preset")],
  theme: {
    extend: {},
  },
  plugins: [],
};

修改后:

module.exports = {
  prefix: 'tw-',           // 核心:强制所有自定义类名带前缀
  darkMode: 'class',       // 核心:锁定暗色模式为类触发,防止系统自动注入背景
  content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
  presets: [require("nativewind/preset")],
  corePlugins: {
    preflight: false,      // 禁用默认的全局重置样式
  },
  theme: {
    extend: {},
  },
  plugins: [],
};

第三步:样式层隔离 (Global CSS)

修改前:

@tailwind base;
@tailwind components;
@tailwind utilities;

修改后:

/* 仅保留工具类,彻底移除基础样式和组件样式的注入 */
@tailwind utilities;

3. 开发规范与验证

  1. 类名编写: 在源码中编写类名时必须带上前缀:

    <View className="tw-flex-1 tw-bg-blue-500" />
    
  2. 清理缓存(关键): 修改配置后,必须强制清理缓存重启,否则旧的编译结果会导致隔离失效:

    npx expo start -c
    

    若仍有异常,请手动删除 node_modules/.cache 目录。

4. 方案优势

  • 零侵入:无需修改第三方库源码。
  • 高性能:Babel 跳过 node_modules 扫描,提升编译速度。
  • 可预测:物理前缀和手动暗色模式控制确保了样式的绝对安全。

5. 核心原理倒推 (为什么这样做有效?)

5.1 解决“黑屏”:darkMode: 'class'

  • 原理:NativeWind 默认通过系统媒体查询自动切换主题。当系统处于深色模式时,它会主动向所有 View 注入暗色背景变量。
  • 真相:长按弹出的 ActionSheet/Modal 触发了这种自动注入。将其改为 'class' 后,NativeWind 失去了主动注入的权限,从而彻底杜绝了无故黑屏。

5.2 解决“逻辑干扰”:exclude: /node_modules/ (Babel)

  • 原理:NativeWind 插件默认会重写 node_modules 里的代码以注入跨平台样式逻辑。
  • 真相:这种重写往往会“劫持” UI Kit 内部组件的 Props,导致其内部状态(如 Modal 的显示隐藏)与样式转换逻辑冲突。物理隔绝 Babel 扫描是保证 UI Kit 原生逻辑运行的基石。

5.3 解决“样式冲突”:prefix: 'tw-'

  • 原理:Tailwind 类名过于通用(如 flex, absolute),极易与 UI Kit 内部的样式或 Props 同名。
  • 真相:这种重名会导致 NativeWind 误以为 UI Kit 的内部元素也是 Tailwind 组件。使用前缀建立了明确的边界,让 NativeWind “只管前缀样式”,互不干扰。

深入理解 AbortController:从底层原理到跨语言设计哲学

引言

在目前的现代异步编程中,取消操作是一个看似简单却极其复杂的问题。JavaScript 的 AbortController API 作为 Web 标准和 Node.js 环境中的统一解决方案,不只是解决了异步操作的可取消性难题,更体现了一种深刻的设计哲学:协作式取消(Cooperative Cancellation)。

今天我们从底层原理出发,深入剖析 AbortController 的工作机制,对比浏览器与 Node.js 的实现差异,并横向对比其他编程语言的中断机制设计,最终揭示这一 API 背后的语言特性与设计思想。那我们开始吧!


第一部分:AbortController 的底层原理

1.1 核心架构:信号-控制器分离模式

AbortController 的设计遵循信号-控制器分离模式(Signal-Controller Separation Pattern)。这种设计将"控制"与"监听"两个职责进行分离:

// 核心架构示意
class AbortController {
  constructor() {
    // 控制器持有信号对象的引用
    this.signal = new AbortSignal();
  }

  abort(reason) {
    // 控制器触发信号的中止状态
    this.signal._abort(reason);
  }
}

class AbortSignal extends EventTarget {
  constructor() {
    super();
    this.aborted = false;
    this.reason = undefined;
  }

  _abort(reason) {
    if (this.aborted) return; // 幂等性保证

    this.aborted = true;
    this.reason = reason ?? new DOMException("Aborted", "AbortError");

    // 触发中止事件,通知所有监听器
    this.dispatchEvent(new Event("abort"));
  }
}

为什么这样设计?

  1. 单一职责原则:控制器负责"触发",信号负责"传播"。这种分离使得一个控制器可以控制多个信号,或者多个消费者可以共享同一个信号。

  2. 不可变性保证:signal 对象一旦创建,其引用关系就固定下来。消费者只能监听信号,无法重新赋值或篡改控制器的状态。

  3. 传播语义清晰:信号作为 EventTarget 的子类,天然支持事件订阅机制,符合 JavaScript 的异步编程范式。

1.2 事件驱动机制:从信号到执行中断

AbortSignal 继承自 EventTarget,这意味着它使用事件驱动模型来传播取消信号。当调用 controller.abort() 时,内部执行以下步骤:

Image from Nlark

关键设计点:

  • 幂等性:多次调用 abort() 不会产生副作用,确保信号状态的一致性。
  • 同步触发:abort() 的调用是同步的,事件处理也是同步执行的,这保证了取消信号的即时性。
  • 不可撤销:一旦信号被中止,就无法"恢复",这符合"取消"的语义——取消是一个不可逆的操作。

1.3 底层资源释放:从信号到系统调用

AbortController 的真正威力在于它能够触发底层资源的释放。以 fetch 请求为例:

const controller = new AbortController();
fetch("/api/data", { signal: controller.signal });

// 触发取消
controller.abort();

abort() 被调用时,浏览器会执行以下操作:

  1. TCP 连接中断:浏览器向服务器发送 RST(Reset)包,强制关闭 TCP 连接。这不是"忽略响应",而是真正意义上的连接终止。

  2. 资源回收:释放与该请求相关的内存缓冲区、文件描述符、事件监听器等资源。

  3. Promise 拒绝:fetch 返回的 Promise 被 reject,抛出 AbortError

这种分层取消机制确保了从应用层到系统层的完整资源释放,避免了内存泄漏和资源耗尽问题。

1.4 AbortSignal.any():信号组合的设计智慧

AbortSignal.any() 是 AbortController API 的一个重要扩展,它允许将多个信号组合成一个 "或" 关系的新信号:

const timeoutSignal = AbortSignal.timeout(5000);
const userCancelSignal = new AbortController().signal;

// 任一信号触发,组合信号就触发
const combinedSignal = AbortSignal.any([timeoutSignal, userCancelSignal]);

fetch("/api/data", { signal: combinedSignal });

实现原理:

// 简化版实现示意
class AbortSignal {
  static any(signals) {
    const controller = new AbortController();

    for (const signal of signals) {
      if (signal.aborted) {
        // 如果任一信号已中止,立即触发
        controller.abort(signal.reason);
        return controller.signal;
      }

      // 监听每个信号的 abort 事件
      signal.addEventListener(
        "abort",
        () => {
          controller.abort(signal.reason);
        },
        { once: true },
      );
    }

    return controller.signal;
  }
}

设计要点:

  1. 竞态处理:如果传入的信号中已经有一个是 aborted 状态,立即触发新信号的中止。
  2. 原因传递:触发时传递原始信号的 reason,保持错误信息的完整性。
  3. 内存管理:使用 { once: true } 确保事件监听器在触发后自动清理,避免内存泄漏。
  4. WeakRef 优化:实际实现中使用 WeakRefFinalizationRegistry 来管理信号之间的依赖关系,防止循环引用。

第二部分:Node.js 与 Web 实现的异同

2.1 实现层面的差异

虽然 Node.js 的 AbortController 遵循与浏览器相同的 WHATWG DOM 标准,但在底层实现上存在显著差异:

特性 浏览器(Blink/V8) Node.js (libuv/V8)
事件循环 基于渲染事件循环 基于 libuv 事件循环
网络层 Chromium Network Stack libuv + 系统调用
信号传播 通过 Blink 的绑定层 通过 Node.js 的 C++ 绑定
文件系统 受限的 File System Access API 完整的 fs 模块支持
子进程 不支持 支持 child_process 模块
Worker 线程 Web Workers Worker Threads

2.2 Node.js 特有的扩展

Node.js 对 AbortController 进行了多项扩展,使其更适用于服务端场景:

2.2.1 定时器支持

import { setTimeout } from "node:timers/promises";

const controller = new AbortController();

setTimeout(1000, "value", { signal: controller.signal })
  .then((value) => console.log(value))
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("Timer aborted");
    }
  });

// 5秒后取消
setTimeout(() => controller.abort(), 500);

底层实现:Node.js 的定时器模块内部维护了一个 AbortSignal 到定时器句柄的映射。当信号触发时,调用 clearTimeout() 清除定时器。

2.2.2 文件系统操作

import { readFile } from "node:fs";

const controller = new AbortController();

readFile("/path/to/file", { signal: controller.signal }, (err, data) => {
  if (err?.name === "AbortError") {
    console.log("Read aborted");
  }
});

// 取消读取
controller.abort();

重要限制:根据 Node.js 文档,文件系统的取消不会中止底层的操作系统请求,而只是中止 Node.js 内部的缓冲操作。这意味着:

这与浏览器中 fetch 的取消(可以终止 TCP 连接)有本质区别,反映了服务端 I/O 与客户端网络请求的不同特性。

2.2.3 子进程控制

import { spawn } from "node:child_process";

const controller = new AbortController();

const child = spawn("node", ["script.js"], {
  signal: controller.signal,
});

child.on("error", (err) => {
  if (err.name === "AbortError") {
    console.log("Child process aborted");
  }
});

// 终止子进程
controller.abort();

实现机制:Node.js 在子进程模块中监听 AbortSignalabort 事件,触发时向子进程发送 SIGTERM 信号。如果子进程未在超时内退出,则发送 SIGKILL 强制终止。

2.3 行为一致性与边界情况

2.3.1 事件触发时序

浏览器和 Node.js 在事件触发时序上保持一致:

const controller = new AbortController();
const signal = controller.signal;

// 注册多个监听器
signal.addEventListener("abort", () => console.log("Listener 1"));
signal.addEventListener("abort", () => console.log("Listener 2"));

controller.abort();
console.log("After abort");

// 输出顺序:
// Listener 1
// Listener 2
// After abort

事件监听器是同步执行的,这保证了取消操作的即时性。

2.3.2 已完成的操作

如果操作已经完成,取消信号会被忽略:

const controller = new AbortController();

fetch("/api/data", { signal: controller.signal }).then((response) => {
  console.log("Request completed");
});

// 延迟触发取消(假设请求已经完成)
setTimeout(() => {
  controller.abort(); // 不会产生任何效果
}, 1000);

这种行为是协作式取消的核心体现:消费者决定如何响应取消信号,包括选择忽略它。


第三部分:跨语言对比——中断机制的设计哲学

3.1 协作式取消 vs 抢占式取消

不同编程语言对"取消操作"的设计哲学可以分为两大类:

3.2 Go:Context 模式

Go 语言的 context 包提供了与 JavaScript AbortController 类似的协作式取消机制:

// Go 的 Context 模式
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 启动 goroutine
go func(ctx context.Context) {
    select {
    case <-ctx.Done():
        // 收到取消信号
        fmt.Println("Cancelled:", ctx.Err())
        return
    case <-time.After(5 * time.Second):
        fmt.Println("Work completed")
    }
}(ctx)

// 触发取消
cancel()

与 JavaScript 的对比

特性 Go Context JavaScript AbortController
信号类型 Channel(<-ctx.Done() Event(addEventListener
传播方式 显式传递 ctx 参数 通过 signal 属性传递
超时支持 context.WithTimeout() AbortSignal.timeout()
值传递 支持 ctx.Value() 不支持(专用设计)
组合能力 可以嵌套传递 AbortSignal.any() 组合

设计差异分析

Go 的 context 不仅是取消信号,还承担了请求作用域数据传递的职责(通过 ctx.Value())。这种设计在微服务架构中非常有用,可以传递请求 ID、用户信息等。JavaScript 的 AbortController 则专注于单一职责:取消信号传递。

3.3 C#:CancellationToken 模式

.NET 的 CancellationToken 是一个成熟的协作式取消机制:

// C# 的 CancellationToken 模式
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

try {
    await Task.Run(async () => {
        while (!token.IsCancellationRequested) {
            // 执行任务
            await Task.Delay(100);
        }
    }, token);
} catch (OperationCanceledException) {
    Console.WriteLine("Operation cancelled");
}

// 触发取消
cts.Cancel();

关键特性:

  1. 轮询与回调双模式:既可以通过 IsCancellationRequested 属性轮询,也可以通过 Register() 方法注册回调。

  2. 链接令牌:CreateLinkedTokenSource() 可以将多个令牌链接成一个,任一令牌取消都会触发整体取消。

  3. 异常类型:取消时抛出 OperationCanceledException,与 JavaScript 的 AbortError 对应。

与 JavaScript 的对比:


⚖️ 核心差异对照表

对比维度 C# CancellationToken JS AbortSignal
类型系统 struct(值类型) class(引用类型)
传递语义 按值复制(快照式) 按引用共享(同一实例)
取消检测 轮询 .IsCancellationRequested 监听 'abort' 事件
异常类型 OperationCanceledException DOMException("AbortError")
资源释放 需手动 .Dispose() CTS GC 自动回收
超时内置 cts.CancelAfter() AbortSignal.timeout() (ES2024)
多信号合并 CreateLinkedTokenSource() AbortSignal.any() (ES2024)
与 fetch 集成 ❌ 不适用 ✅ 原生支持
与 async/await ✅ 原生支持 ✅ 原生支持

3.4 Java:Future.cancel() 与线程中断

Java 提供了两种取消机制:

3.4.1 Future.cancel()(协作式)

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 执行任务
    }
});

// 尝试取消
future.cancel(true); // true = 允许中断运行中的线程

3.4.2 线程中断(抢占式)

Thread workerThread = new Thread(() -> {
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        // 收到中断信号
        Thread.currentThread().interrupt(); // 重新设置中断标志
    }
});

workerThread.start();
workerThread.interrupt(); // 发送中断信号

关键区别

Java 的 Thread.interrupt() 并不会强制停止线程,而是设置一个中断标志。线程需要主动检查这个标志(通过 isInterrupted())或在可中断的阻塞操作(如 sleep(), wait())中捕获 InterruptedException

这与 JavaScript 的 AbortController 非常相似,都是协作式的。但 Java 还保留了 Thread.stop()(已废弃)这样的抢占式方法,反映了早期 Java 设计中对抢占式取消的探索。

3.5 Kotlin:协程的取消机制

Kotlin 协程的取消是结构化并发(Structured Concurrency)的核心特性:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("Job: I'm working $i ...")
                delay(500L)
            }
        } finally {
            // 清理资源
            println("Job: I'm running finally")
        }
    }

    delay(1300L)
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消并等待完成
    println("main: Now I can quit.")
}

关键特性:

  1. 挂起点的取消检查:Kotlin 协程只在挂起点(suspension points)检查取消状态。如果协程处于 CPU 密集型计算中,不会立即响应取消。

  2. 异常传播:取消时抛出 CancellationException,这是一种特殊的异常,不会被视为错误。

  3. 父子关系:子协程的取消会传播给所有子协程,形成树状的取消传播。

与 JavaScript 的对比:

3.6 Python:asyncio.Task 的取消

Python 的 asyncio 提供了任务取消机制:

import asyncio

async def worker():
    try:
        while True:
            print("Working...")
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        print("Cancelled!")
        raise  # 必须重新抛出

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(2)
    task.cancel()

    try:
        await task
    except asyncio.CancelledError:
        print("Task cancelled")

asyncio.run(main())

设计特点

  1. 异常驱动:取消通过抛出 CancelledError 实现,任务需要捕获并重新抛出。

  2. 异步清理finally 块中可以执行异步清理操作(使用 async 语法)。

  3. 取消传播:父任务取消时,子任务会自动收到取消信号。

与 JavaScript 的对比

Python 的 asyncio.CancelledError 与 JavaScript 的 AbortError 类似,都是异常驱动的取消机制。但 Python 的取消更依赖异常传播,而 JavaScript 更依赖事件监听。

3.7 Rust:异步取消与 Drop 语义

Rust 的异步取消机制与众不同,它利用了所有权和 Drop trait:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(5)).await;
        println("Task completed");
    });

    // 取消任务
    handle.abort();

    match handle.await {
        Ok(_) => println!("Task finished normally"),
        Err(e) if e.is_cancelled() => println!("Task was cancelled"),
        Err(e) => println!("Task panicked: {:?}", e),
    }
}

核心概念

  1. Future 的 Drop:在 Rust 中,当一个 Future(异步任务)被 drop(丢弃)时,任务就被取消了。这是通过所有权系统实现的。

  2. 取消安全性(Cancel Safety):Rust 强调"取消安全性",即任务在被取消时不会留下不一致的状态。这通常要求使用特定的模式(如 select! 宏)。

  3. Async Drop:Rust 正在讨论引入 AsyncDrop trait,允许在 drop 时执行异步清理操作。

与 JavaScript 的对比


第四部分:设计哲学与最佳实践

4.1 为什么协作式取消是主流?

从上述跨语言对比可以看出,协作式取消已成为现代异步编程的主流设计。原因如下:

  1. 资源安全:协作式取消允许任务在退出前执行清理操作(关闭文件、释放锁、回滚事务等),避免资源泄漏。

  2. 状态一致性:任务可以在安全点(挂起点或检查点)响应取消,确保数据结构处于一致状态。

  3. 可预测性:取消的时机和行为是确定的,不会出现抢占式取消的"任意点中断"问题。

  4. 组合性:多个取消信号可以组合(如 AbortSignal.any()),形成复杂的取消策略。

4.2 AbortController 的设计原则总结

根据 WHATWG DOM 规范和各实现的设计文档,AbortController 遵循以下核心原则:

  1. 分离原则(Separation)

    • 控制器(Controller)负责触发
    • 信号(Signal)负责传播
    • 消费者(Consumer)决定如何响应
  2. 幂等性原则(Idempotency)

    • 多次调用 abort() 无副作用
    • 信号一旦中止,状态不可变
  3. 即时性原则(Immediacy)

    • abort() 调用是同步的
    • 事件处理是同步的
    • 保证取消信号的即时传播
  4. 不可撤销原则(Irreversibility)

    • 取消是不可逆的操作
    • 信号不能"恢复"或"重置"
  5. 组合性原则(Composability)

    • 支持多个信号的组合(any, race)
    • 支持信号链的传播(dependent signals)
  6. 资源安全原则(Resource Safety)

    • 提供清理算法的注册机制
    • 支持自动解订阅(unsubscription)

4.3 实际应用中的最佳实践

4.3.1 始终传递 Signal

// ✅ 好的实践:函数接受 signal 参数
async function fetchData(url, options = {}) {
  const { signal } = options;

  // 立即检查
  signal?.throwIfAborted();

  const response = await fetch(url, { signal });

  // 中间检查
  signal?.throwIfAborted();

  return response.json();
}

// ❌ 不好的实践:忽略 signal
async function fetchDataBad(url) {
  return fetch(url).then((r) => r.json()); // 无法取消
}

4.3.2 正确清理事件监听器

async function someOperation(signal) {
  const cleanup = new AbortController();

  // 使用嵌套 signal 确保清理
  signal?.addEventListener(
    "abort",
    () => {
      cleanup.abort();
    },
    { once: true },
  );

  try {
    await doWork({ signal: cleanup.signal });
  } finally {
    // 确保清理
    cleanup.abort();
  }
}

4.3.3 区分取消错误与其他错误

async function robustFetch(url, signal) {
  try {
    return await fetch(url, { signal });
  } catch (error) {
    if (error.name === "AbortError") {
      // 取消是预期的行为,不需要上报
      console.log("Request cancelled");
      return null;
    }
    // 其他错误需要处理
    throw error;
  }
}

4.3.4 使用 AbortSignal.timeout() 设置超时

// ✅ 推荐:使用内置的超时信号
const signal = AbortSignal.timeout(5000);

// ❌ 不推荐:手动实现
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

4.3.5 组合多个取消条件

// 组合用户取消和超时
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);

const combinedSignal = AbortSignal.any([userController.signal, timeoutSignal]);

fetch("/api/data", { signal: combinedSignal }).catch((err) => {
  if (err.name === "AbortError") {
    // 判断是哪种取消
    if (timeoutSignal.aborted) {
      console.log("Timeout");
    } else {
      console.log("User cancelled");
    }
  }
});

第五部分:深入思考——语言特性对设计的影响

5.1 JavaScript 的事件驱动本质

AbortController 的设计深深植根于 JavaScript 的事件驱动(Event-Driven)本质。JavaScript 作为单线程语言,无法使用抢占式中断(如线程信号),必须通过事件循环机制来传播信号。

这种设计使得 AbortController 与 JavaScript 的异步模型(Promise、async/await、EventTarget)无缝集成。

5.2 单线程模型的限制与优势

JavaScript 的单线程模型限制了取消机制的设计空间:

  • 无法强制中断:无法像操作系统信号那样强制中断执行中的代码。
  • 必须协作:任务必须主动检查信号并响应。

但这种限制也带来了优势:

  • 避免竞态条件:没有抢占式中断的"任意点中断"问题,状态一致性更容易保证。
  • 简化并发模型:单线程 + 事件循环使得取消信号的传播路径清晰可预测。

5.3 对比其他语言的设计选择

不同语言的中断机制设计反映了它们的运行时特性:

语言 运行时模型 取消机制 设计选择
JavaScript 单线程 + 事件循环 AbortController 事件驱动,协作式
Go M:N 协程调度 context.Context Channel 驱动,协作式
C# 线程池 + Task CancellationToken 轮询 + 回调,协作式
Java OS 线程 Future.cancel() + 中断 混合式(协作为主)
Kotlin 协程(挂起/恢复) Job.cancel() 挂起点检查,协作式
Rust 异步 Future + 轮询 Drop 语义 所有权驱动,协作式
Python 事件循环 + 协程 Task.cancel() 异常驱动,协作式

核心点

所有现代语言都选择了协作式取消,这不是偶然,而是对资源安全和状态一致性的共同追求。不同语言的实现方式反映了它们的核心抽象模型

  • JavaScript 的 EventTarget → 事件驱动
  • Go 的 Channel → 通信顺序进程(CSP)
  • Rust 的 Ownership → 编译时安全
  • Kotlin 的 Structured Concurrency → 父子作用域

结论

AbortController 不仅是一个 API,更是 JavaScript 异步编程哲学的集中体现。它的设计遵循了以下核心思想:

  1. 协作优于强制:通过信号机制让任务自主决定如何响应取消,保证资源安全和状态一致性。

  2. 分离优于耦合:控制器与信号的分离使得取消逻辑可以灵活组合和传播。

  3. 事件驱动优于轮询:利用 JavaScript 的事件循环机制,实现即时、可靠的信号传播。

  4. 组合优于继承AbortSignal.any() 等组合操作使得复杂的取消策略可以用简单的原语构建。

跨语言对比揭示了一个行业共识:协作式取消是现代异步编程的最佳实践。无论是 Go 的 Context、C# 的 CancellationToken、Kotlin 的协程取消,还是 Rust 的 Drop 语义,都在用各自语言的核心抽象表达同一个理念——让取消成为一等公民,但绝不以牺牲安全为代价

理解 AbortController 的底层原理,不仅能帮助我们写出更健壮的异步代码,更能让我们洞察语言设计背后的深层思考:好的设计不是增加复杂性,而是在约束条件下找到最优雅的解决方案

Get 这波之后,我把 Flutter 状态管理重新看了一遍:新项目到底该选谁?

前言

昨天 get 的删库跑路之后发,社区和公司部门内部基本可以说是原地炸了。(早上作者说是他的github账户被风控,但是get本身的问题已经很多了...)

问得最多的,不是“这个包现在还能不能装”,而是另一类更扎心的问题:

“以后 Flutter 项目状态管理到底该怎么选?”

“Get 还能不能继续用?”

“Provider、Riverpod、Bloc 这些,到底谁更靠谱?”

“你如果现在开一个新项目,你会选谁?”

我仔细思考了,发现很多讨论都有两个毛病:

  1. 只聊 API,不聊项目演进后的维护成本。
  2. 只聊自己喜欢什么,不聊团队、业务、复杂度、学习成本这些现实问题。

所以这篇文章,我想和大家聊聊我的看法。

我想做的事情很简单:

借着这次 get 的风波,把 Flutter 生态里主流的状态管理方案,重新摆到桌面上,按真实项目的标准,认认真真聊一遍。

不是聊“哪个最优雅”,而是聊:

  • 它到底解决什么问题
  • 它的边界在哪
  • 它为什么有人爱,也为什么有人骂
  • 如果现在重新开一个项目,我会怎么选

为了避免这篇文章变成“空对空”,我还顺手把同一个业务场景做成了一个开源 Demo,把 Provider / Riverpod / Cubit / Bloc Event 全都落地了一遍。

开源地址在这里: state_manages

后面我文中提到的一些对比,不只是嘴上说说,基本都能在这个仓库里对上代码。

screenshot-20260415-151857.png

先说结论

我先把结论放前面,免得大家看半天最后发现和自己预想差不多。

如果你现在问我:

“2026 年这个时间点,Flutter 新项目状态管理怎么选?”

我的答案是:

  • 小项目、单人项目、快速起步:Provider
  • 中大型新项目、我个人最愿意推荐的平衡方案:Riverpod
  • 多人协作、复杂业务、强调规范和状态流可追踪:Cubit / Bloc
  • 老项目已经深度绑定 GetX:先稳住,不要一激动就全量重构
  • 新项目再从 0 开始选 GetX:我个人会明显更谨慎

注意,我这里不是说 GetX 技术上突然一夜归零了。

而是说,这次事情把一个以前很多人不愿意正视的问题,硬生生摊开了:

状态管理从来不是“代码写起来爽不爽”这么简单,它还关乎维护、协作、生态稳定性、升级路线、团队兜底能力。

说得再直白一点:

以前大家觉得“能跑就行”, 这次很多人才开始意识到:

“哦,原来依赖生态稳定,也是技术选型的一部分。”


篇章一:先把问题掰直,状态管理到底在管什么

很多人聊状态管理,一上来就对比 API:

  • setState
  • notifyListeners
  • ref.watch
  • emit
  • Obx

但这其实是表象。

状态管理真正要解决的,不是“你用哪个函数刷新页面”,而是下面这几件事:

  1. 状态放在哪里
  2. 状态变化后,谁来通知 UI
  3. 异步请求、空状态、错误状态怎么建模
  4. 页面越来越复杂之后,代码会不会开始失控
  5. 团队里第二个人、第三个人接手时,还看不看得懂

你会发现,一个状态管理方案,真正的价值,不在于它能不能写出页面。

因为大家都能写。

真正拉开差距的,是当业务变成这样时:

  • 页面有列表
  • 列表要刷新
  • 请求会失败
  • 有搜索
  • 有筛选
  • 有排序
  • 还有弹 Toast、弹 Dialog、跳详情页这种一次性副作用

这时候你再看,方案之间的差异就出来了。

也就是说:

状态管理的核心,不是“能不能更新 UI”,而是“当 UI 和业务越来越复杂时,这套结构还能不能顶住”。


篇章二:Provider,Flutter 状态管理里的“老实人”

如果让我给 Provider 起个外号,我会叫它:

“老实人方案。”

它最大的特点就是:

  • 不花
  • 不绕
  • 不装神秘
  • 你基本一眼就能知道状态在哪、怎么改、谁在监听

这也是为什么,很多 Flutter 新人第一个真正上手的状态管理,都是它。

Provider 到底在干嘛

最常见的写法其实很直白:

class UserViewModel extends ChangeNotifier {
  bool loading = false;
  List<String> users = [];

  Future<void> loadUsers() async {
    loading = true;
    notifyListeners();

    await Future.delayed(const Duration(seconds: 1));
    users = ['Ava', 'Noah', 'Mia'];

    loading = false;
    notifyListeners();
  }
}

// 页面里用:

ChangeNotifierProvider(
  create: (_) => UserViewModel()..loadUsers(),
  child: Consumer<UserViewModel>(
    builder: (_, vm, __) {
      if (vm.loading) {
        return const CircularProgressIndicator();
      }
      return ListView(
        children: vm.users.map(Text.new).toList(),
      );
    },
  ),
)

这套东西的优点几乎不用解释:

  • 好懂
  • 上手快
  • 学习门槛低
  • 代码量不大
  • 对小页面非常够用

Provider 为什么好用

因为它特别符合人脑最朴素的思路:

  • 我有一个对象
  • 对象里放状态
  • 改完状态
  • 通知页面刷新

这套逻辑没有什么抽象负担。

对于很多简单页面来说,这种方案不仅够用,而且其实是最划算的。

你非要拿一个很轻的用户列表页,上来就写一堆事件、状态类、派生结构,很多时候反而是技术过剩。

Provider 的问题到底在哪

问题不在它不能用,而在它太容易一路“长歪”。

最开始你只放两个字段:

  • loading
  • users

后面慢慢加:

  • errorMessage
  • query
  • selectedFilter
  • sortType
  • showVipOnly
  • currentTab
  • hasMore
  • isRefreshing

再往后还会加一堆方法:

  • loadUsers
  • refreshUsers
  • retry
  • updateQuery
  • toggleVip
  • changeSort
  • openDetail
  • showErrorToast

写着写着,一个 ChangeNotifier 就变成了一个“巨型大管家”。

它不是不能维护,但它特别考验开发者的自觉。

Provider 最大的问题,不是功能弱,而是结构约束弱。

你写得好,它很好用。 你写得随便,它也很容易烂。

所以 Provider 适合谁

我会这样建议:

适合:

  • Flutter 初学者
  • 小型项目
  • 页面级逻辑不复杂的业务
  • 想先把状态管理基本感觉建立起来的人

不太适合:

  • 中大型复杂项目当唯一主状态管理方案
  • 多人长期协作、对结构一致性要求很高的团队
  • 派生状态很多、异步链路复杂的模块

———

篇章三:Riverpod,我最愿意推荐给新项目的方案

如果说 Provider 是“老实人”,那 Riverpod 在我眼里更像:

“脑子清楚、结构现代、能打硬仗的中生代主力。”

我为什么这么说?

因为 Riverpod 真正厉害的地方,不是“语法多高级”,而是:

它很擅长把依赖关系和状态关系拆清楚。

这点在项目越做越大时,价值会越来越明显。

Riverpod 和 Provider 最本质的区别

很多人会把 Riverpod 理解成“升级版 Provider”。

这么说不算全错,但也太粗暴了。

Provider 更像是:

  • 往 Widget Tree 里塞对象
  • 下层从树里读对象
  • 对象变了,通知相关 Widget 刷新

Riverpod 更像是:

  • 先把状态和依赖拆成一个个 provider 节点
  • provider 和 provider 之间可以互相组合
  • 页面只是去消费这些节点

你把它想象成一张依赖图,会更容易理解。

Riverpod 为什么在异步场景特别舒服

我觉得 Riverpod 最讨喜的一点,是它对异步状态的表达非常自然。

比如一个最常见的异步列表:

  final usersProvider =
      AsyncNotifierProvider<UsersNotifier, List<String>>(UsersNotifier.new);

  class UsersNotifier extends AsyncNotifier<List<String>> {
    @override
    Future<List<String>> build() async {
      await Future.delayed(const Duration(seconds: 1));
      return ['Ava', 'Noah', 'Mia'];
    }

    Future<void> refreshUsers() async {
      state = const AsyncLoading();
      state = await AsyncValue.guard(() async {
        await Future.delayed(const Duration(seconds: 1));
        return ['Ava', 'Noah', 'Mia'];
      });
    }
  }

//   页面里:

  final asyncUsers = ref.watch(usersProvider);

  return asyncUsers.when(
    loading: () => const CircularProgressIndicator(),
    error: (e, _) => Text('出错了:$e'),
    data: (users) => ListView(
      children: users.map(Text.new).toList(),
    ),
  );

你会发现这里有个很明显的优势:

异步状态本身就是框架的一等公民。

不是你自己去维护:

  • isLoading
  • errorMessage
  • hasData

而是用 AsyncValue 直接把这些状态表达出来。

这个对实际开发体验影响很大。

Riverpod 真正强的,不是异步,而是“组合能力”

如果你只拿一个简单异步列表示例去看 Riverpod,其实还没看到它最强的地方。

它真正强的是这种场景:

  • 原始用户列表一个 provider
  • 搜索关键词一个 provider
  • VIP 开关一个 provider
  • 排序方式一个 provider
  • 最终可见列表再是一个派生 provider
  • 用户详情页再用 family

也就是说:

Riverpod 不是鼓励你写一个“大而全的状态类”,而是鼓励你把不同职责拆成多个 provider,再组合起来。

这会带来两个很现实的好处:

  1. 结构更清楚
  2. 重建范围更好控制

Riverpod 有什么代价

它当然也不是白给的。

代价主要有三个:

1. 学习曲线比 Provider 高

你第一次看 Riverpod,脑子里经常会冒出几个问题:

  • 为什么一个页面拆这么多 provider
  • 为什么这里 watch,那边 read
  • 为什么这里要 listen
  • 为什么 provider 还要依赖 provider

这很正常,因为 Riverpod 不是在教你“存一个对象”,而是在教你“组织一组状态节点”。

2. 写不好会显得很碎

Riverpod 的优点是可拆分,但坏处也正是可拆分。

如果一个团队没有统一规范,很容易出现:

  • provider 命名混乱
  • 分层过细
  • 逻辑散落到各处

最后导致不是“结构清晰”,而是“文件一大堆,人都找不到”。

3. 对抽象能力有要求

Riverpod 更适合那种愿意先想清楚状态边界,再写代码的人。

如果一个人习惯先堆功能,再慢慢补结构,那 Riverpod 反而不一定让他更轻松。

所以 Riverpod 适合谁

我的建议是:

非常适合:

  • 中大型新项目
  • 需要长期维护的项目
  • 依赖关系复杂、派生状态较多的模块
  • 想把局部注入、测试隔离做得更清晰的团队

如果你问我现在新项目更倾向推荐谁,

我个人会优先推荐 Riverpod。

不是因为它最火,也不是因为它“最优雅”,而是因为它在:

  • 开发体验
  • 异步表达
  • 可组合性
  • 可维护性
  • 模块化能力

这几个维度上,整体太均衡了。

篇章四:Bloc / Cubit,这套东西不是“重”,而是“规矩大”

很多人一提到 Bloc,第一反应就是:

“太重了。”

这句话不能说错,但我觉得它只说了一半。

更准确一点的说法应该是:

Bloc 不是单纯地重,它是规矩大。

它会逼着你把一些以前可以“糊着写”的东西,全部摊开来写清楚。

比如:

  • 页面到底触发了什么动作
  • 动作进来后,状态怎么变
  • 哪些地方是副作用
  • 哪些地方只是纯渲染

这套思路,在简单页面里确实显得重。

但一旦业务复杂起来,它的价值就会越来越大。

先说 Cubit,它比你想象中实用

我其实很想先替 Cubit 正个名。

因为很多人把 Bloc 体系一股脑都理解成:

  • 一堆 event
  • 一堆 state
  • 一堆 boilerplate

但 Cubit 不是这样的。

Cubit 更像是:

“有明确状态对象的、工程化一点的 ViewModel。”

比如:

  class UsersCubit extends Cubit<UsersState> {
    UsersCubit() : super(const UsersState());

    Future<void> loadUsers() async {
      emit(state.copyWith(loading: true));

      await Future.delayed(const Duration(seconds: 1));

      emit(
        state.copyWith(
          loading: false,
          users: ['Ava', 'Noah', 'Mia'],
        ),
      );
    }
  }

你看,它其实很好懂:

  • 有状态类
  • 有方法
  • 改状态时 emit
  • 页面用 BlocBuilder 监听

对很多简单到中等复杂度页面来说,Cubit 是非常实用的。

它比 Provider 更有“状态层”的味道, 又比完整 Bloc Event 版轻很多。

那为什么还需要 Bloc Event 版

因为业务一复杂,方法驱动就开始不够清楚了。

比如一个页面同时有:

  • 首次加载
  • 刷新
  • 重试
  • 搜索变更
  • 排序切换
  • 筛选切换
  • Toast 提示
  • 并发请求控制

这时候你再全靠方法名去表达,就会慢慢开始乱。

而 Event 版会逼你把事情说清楚:

  • UsersRequested
  • UsersRefreshed
  • UsersRetried
  • SearchChanged
  • FilterChanged

这不是为了多写几个类,而是为了让状态变化路径可追踪。

尤其在多人协作里,这点特别重要。

Bloc 这套方案最值钱的地方

我觉得有三点。

1. 事件语义明确

一眼就能看出:

“页面现在到底发生了什么业务动作。”

2. 副作用边界清楚

用 BlocBuilder 渲染 UI, 用 BlocListener 处理 Toast、Dialog、路由跳转。

这比很多项目里“状态逻辑和副作用搅成一锅粥”的写法,要清爽太多。

3. 复杂交互下更稳

比如并发控制。

Bloc 生态里你可以明确去处理:

  • 重复点击刷新怎么办
  • 搜索输入连发怎么办
  • 模式切换时要保留最后一次还是顺序执行

这些东西,在复杂项目里不是“锦上添花”,而是迟早会遇到的坑。

Bloc 的代价是什么

说实话,代价也很明显:

  • 样板代码更多
  • 初学者上手成本更高
  • 简单页面里容易显得重炮打蚊子

所以我不会无脑推荐所有人都上 Bloc Event 版。

但如果你在的团队是这种风格:

  • 人多
  • 业务重
  • 状态复杂
  • 强调规范
  • 维护周期长

那 Bloc 的价值,真的会越来越明显。

所以 Bloc / Cubit 适合谁

我的建议是:

  • 简单页面:Cubit 很香
  • 复杂业务模块:Bloc Event 版很稳
  • 团队已经全套 flutter_bloc:不要轻易引第二套主状态管理
  • 如果只是个人小项目,别一上来就给自己加戏

篇章五:GetX,到底该怎么重新看

这部分我不想写成“清算大会”。

因为说实话,GetX 当年能火,不是没原因的。

它确实帮很多 Flutter 开发者解决过实际问题。

尤其是早几年,Flutter 生态还没现在这么成熟时,GetX 的那种“开箱快、上手爽、什么都给你带一点”的感觉,对很多人真的很有吸引力。

GetX 当年为什么能打

原因其实很现实:

  • 学起来快
  • 写起来省事
  • 状态管理、路由、依赖注入几乎一把梭
  • 对很多从前端框架过来的人很有亲和力

你写页面时会感觉:

“卧槽,这也太快了吧。”

这就是 GetX 当年最强的传播力来源。

但它的问题也一直存在

只不过以前很多人选择忽略。

我自己总结,主要有这几类:

1. 职责容易混在一起

GetX 很容易让人一路写成这种结构:

  • 状态也在 controller
  • 路由也在 controller
  • 依赖注入也在 controller
  • 页面副作用也在 controller
  • 工具方法也在 controller

最后 controller 既像 ViewModel,又像 Service,又像 Router。

短期开发很爽,长期看边界其实很容易糊。

2. “魔法感”很强

很多写法前期很丝滑,但越往后越容易出现一种感觉:

项目能跑,但你说不清它到底靠什么机制在跑。

这对个人项目问题不大, 但对团队维护来说,是个隐患。

3. 风险被低估了

以前大家更多讨论的是:

  • 性能
  • 写法
  • 学习曲线

这次事情之后,大家终于被迫意识到另一层风险:

生态稳定性和治理能力,也是技术选型的一部分。

所以我现在对 GetX 的态度是:

  • 我不会否认它历史上的价值
  • 我也不会说所有 GetX 项目都得马上重构
  • 但如果你现在让我从 0 开一个新项目,我会明显更谨慎

篇章六:如果今天重新开一个项目,我会怎么选

这一段我尽量说人话,不打太极。

场景一:单人项目、验证想法、快速上线

我会优先考虑:

  • Provider
  • Cubit

原因很简单:

  • 成本低
  • 起步快
  • 心智负担小

别把事情搞太复杂。

场景二:中小团队新项目,业务会持续增长

我会优先考虑:

  • Riverpod

原因是它太平衡了。

它既没有 Bloc Event 那么重, 又比 Provider 更容易把结构撑住。

如果团队里成员整体水平还不错,Riverpod 是一个非常舒服的主状态管理方案。

场景三:复杂业务、多人协作、强调规范

我会优先考虑:

  • Cubit + Bloc Event

简单模块用 Cubit, 复杂模块上 Bloc Event。

这种搭配很实用。

场景四:老项目已经深度用了 GetX

我不会建议你们一夜重构。

我的建议是:

  1. 先锁版本
  2. 先备份依赖来源
  3. 先把最关键模块稳住
  4. 新模块逐步减少对 GetX 的继续扩散
  5. 再考虑渐进迁移

因为状态管理迁移这种事,一旦上头,很容易把“风险治理”做成“二次事故”。

———

最后

这次 get 的事情,对我来说最大的提醒,不是“某个包危险”,而是另一件更本质的事:

技术选型从来不是一锤子买卖。

你今天选一个方案,不只是选它今天写起来爽不爽, 你其实是在选:

  • 三个月后它还好不好改
  • 半年后新人能不能接
  • 一年后团队还能不能稳稳维护
  • 真出事时,你们有没有兜底能力

所以如果你现在问我:

“Get 这波之后,Flutter 状态管理该怎么重新看?”

我的答案是:

  • Provider,适合入门和轻量场景
  • Riverpod,是我目前最愿意推荐给新项目的平衡方案
  • Cubit / Bloc,适合复杂业务和多人协作
  • GetX,不是不能用,但以后别再只看“写起来爽不爽”了

说到底,真正好的状态管理方案,不是“最酷”的那个。

而是当项目做大之后,它还能让你回答清楚下面这句话:

“状态从哪来,为什么变,谁在监听,副作用在哪发生。”

如果这个问题它还能帮你解释清楚,那它就值钱。 如果它让这些东西越来越糊,那它迟早会反噬你。

这也是我这两天重新看 Flutter 状态管理生态之后,最真实的感受。

如果你们团队现在也在重新评估状态管理路线,希望这篇能帮你少踩几个坑。

———

如果你想让我继续写

这篇如果大家爱看,后面我可以继续写三篇:

  1. Provider、Riverpod、Bloc,我做了一个同业务 Demo,带你看真实代码差异
  2. GetX 老项目怎么渐进式迁移,不推倒重来
  3. Flutter 状态管理怎么选,别只看 API,得看团队结构

如果你觉得有用,评论区告诉我,我就继续更。

往期文章回顾

Get 删库风波

Web 前端转 Flutter

Flutter 图片编辑器

Flutter 全链路监控 SDK

语音合成与视觉模型api接入实现

语音合成与视觉模型api接入实现

读完这篇,你应能按步骤复现本仓库里的两条能力:火山豆包语音 TTS(文本 → 音频)与 Moonshot 视觉理解(图 + 文 → 描述)。代码已在仓库中落地,本文侧重为什么要这样拆、先写哪一层、再拼哪一层,方便你搬到自己的项目里。

多模态业务里,「语音」和「看图说话」常来自不同厂商、不同鉴权方式;浏览器又不能随便塞 Secret。做法是:单页只负责表单与展示本机 server.js 当 BFF.env.local、带齐 Header、转发到官方域名。下面按推荐实现顺序写。


你将得到什么

能力 页面 代理路径 上游
语音合成 index-volc-tts.html POST /tts/api/v1/tts openspeech.bytedance.com/api/v1/tts
视觉理解 index-moonshot-vision.html POST /moonshot/v1/chat/completions api.moonshot.cn/v1/chat/completions

效果图:

volc-tts.gif

moon.gif


第 0 步:先画清楚「三层」

无论做哪一条链路,都可以抽象成同一副骨架:

  1. 浏览器:只认识 http://127.0.0.1:3000(或你的代理地址),用 fetch 发 JSON,不出现厂商密钥
  2. server.js:读 .env.local,补鉴权(Header 或 body 里的 app),fetch 到真实上游,把状态码和 body 原样或略加工返回给浏览器。
  3. 厂商 API:校验通过后返回 JSON(TTS 里是 base64 音频;Chat 里是 choices[0].message.content)。
flowchart LR
  subgraph browser["浏览器单页"]
    A[表单 / 选图]
    B[fetch 本地路径]
  end
  subgraph bff["server.js"]
    C[读环境变量]
    D[拼上游 URL + Header]
  end
  subgraph upstream["厂商 HTTPS"]
    E[(openspeech / Moonshot)]
  end
  A --> B --> C --> D --> E
  E --> D --> B

实现顺序建议:先能用 curl 或最小脚本从本机打到上游(验证密钥与路径),再写 BFF 路由,最后写 Vue 单页把体验补齐。本仓库把后两步都写好了,你可以对照 server.js 里的 proxyVolcengineTtsproxyMoonshotRequest 逆序读回去。


第 1 步:准备账号与密钥(两条线各自一次)

火山(语音)

火山引擎语音活动/实名控制台创建应用,拿到 AppIDAccess Token(测试 Token 常有有效期,过期要重新复制)。HTTP 一次性合成文档见 豆包语音 · HTTP 非流式。请求体里的 app.cluster 在常见在线 TTS 场景下为 volcano_tts(若你开通的是其它产品线,以控制台绑定的文档为准)。

Moonshot(视觉)

Moonshot 开放平台 注册,在 API Keys 创建 sk-...。视觉走 OpenAI 兼容的 /v1/chat/completionsmessagescontent 可为数组:type: image_urlurl 可用 Data URL)+ type: textmodel 须选支持视觉的型号(如 moonshot-v1-8k-vision-preview),以 官方 Chat 文档 为准。


第 2 步:在 server.js 接通路(先后端,再前端)

2.1 为什么要单独路由,而不是让浏览器直连?

  • 密钥:火山 Token、Moonshot API Key 不能进前端仓库与打包产物。
  • Header 细节:火山 TTS 要求 Authorization: Bearer;token(分号);Moonshot 是常见的 Bearer <空格>token。写进 BFF 可避免前端写错格式。
  • 路径前缀:本仓库用 /tts/.../moonshot/v1/... 作为「入口命名空间」,与仓库里可灵 /kling 并列,便于在 createServer 里分支维护。

2.2 火山:proxyVolcengineTts 在做什么(实现要点)

  1. 只处理 POST /tts/api/v1/tts(与前端约定死,避免误打到别的服务)。
  2. JSON.parse 请求体后,强制写入 parsed.app = { appid, token, cluster }(来自环境变量),这样即使浏览器带了假 app 也会被覆盖。
  3. 若缺 request.reqid,服务端用 crypto.randomUUID() 补上,满足「每次合成唯一」。
  4. 向上游 POST https://openspeech.bytedance.com/api/v1/tts(可用 VOLCENGINE_TTS_ORIGIN 覆盖域名),带上 Bearer; 头,把上游响应 原样写回(状态码 + Content-Type + body)。

环境变量示例(.env.local勿提交):

VOLCENGINE_TTS_APP_ID=你的AppId
VOLCENGINE_TTS_ACCESS_TOKEN=你的AccessToken
VOLCENGINE_TTS_CLUSTER_ID=volcano_tts

也支持 VITE_APP_ID / VITE_ACCESS_TOKEN / VITE_CLUSTER_ID。启动后看控制台 [Volc TTS] 已配置…

2.3 Moonshot:proxyMoonshotRequest 在做什么

  1. 匹配 /moonshot 前缀,剥掉后剩余路径必须以 /v1/ 开头且无 ..,防止开放代理被滥用。
  2. MOONSHOT_API_ORIGIN + restPath,默认 https://api.moonshot.cn
  3. 请求头 Authorization: Bearer ${MOONSHOT_API_KEY},body 透传(密钥不在 body 里)。
MOONSHOT_API_KEY=sk-你的密钥

也可用 VITE_API_KEY / API_KEY。日志里 [Moonshot] 已配置… 表示就绪。


第 3 步:写 index-volc-tts.html(从文本到能播的音频)

目标:用户输入文案 → 点按钮 → 听到合成声。

  1. 技术栈:一个 HTML 里 <script type="module">,从 unpkg 引入 Vue 3 ESM;需要 npx serve . 这类静态服务,避免 file:// 下模块加载失败。
  2. 代理根 proxyBase:所有请求都是 base + '/tts/api/v1/tts',与第 2 步里服务端路由一致;成功请求前写入 localStorage,下次打开少输一次地址。
  3. 请求体:只组 user / audio / request不要在浏览器写 app(交给服务端合并)。
  4. reqid:在浏览器生成 UUID,减少与服务端补全的竞态,也符合文档习惯。
  5. 解析返回:JSON 里 data 是纯 base64;用 atobUint8ArrayBlob,MIME 用 mimeForEncoding(encoding)mp3audio/mpegogg_opusaudio/ogg),否则容易「有数据但播不出」。
  6. 播放与内存URL.createObjectURL 赋给 <audio>;每次合成前 revokeObjectURL 旧地址,组件卸载时再清一次,避免泄漏。

跑通检查清单:node server.jsnpx serve . → 打开 index-volc-tts.html → 代理填 http://127.0.0.1:3000 → 点 Generate & Play → 听到声音。更多字段以火山文档为准;仓库交叉索引:README.md


第 4 步:写 index-moonshot-vision.html(从本地图片到模型回复)

目标:选一张图 + 一句问法 → 看到模型对图的描述。

  1. 读文件<input type="file"> + FileReader.readAsDataURL,得到 data:image/...;base64,...,同时用于 <img :src> 预览请求体(一份数据两处用,避免双份状态)。
  2. isValid:用 computed 判断是否已选图,禁用「提交」,减少空请求。
  3. 请求 URLbase + '/moonshot/v1/chat/completions',对应第 2.3 步的代理前缀。
  4. 请求体stream: falsemessages[0].content 为数组,先图后文(与多模态习惯一致);model 可做成输入框便于换型号线。
  5. 解析:取 choices[0].message.content;HTTP 错误或结构不对时,把截断的 JSON 塞进 Error 文案,便于对照上游 error 字段排错。
  6. 大图:Data URL 会线性撑大 POST body,易触发超时或网关限制——产品上要引导压图或走对象存储 URL,本示例先文档化约束。

跑通检查清单:.env.local 配好 Moonshot Key → node server.js → 打开 index-moonshot-vision.html → 选图 → 提交 → 看到文字回复。


第 5 步:单页内部的共通套路(和 server.js 怎么分工)

环节 单页负责 server.js 负责
密钥 不出现 .env.local,向上游带正确 Header / 火山 app
URL proxyBase + 固定相对路径 拼官方 origin + /api/v1/tts/v1/...
业务 JSON 文案、音色、encoding、图片 Data URL、model 火山:覆盖 app;Moonshot:透传 body
错误展示 try/catch、HTTP 状态、关键字段校验 上游非 2xx 时透传 status + body

一句话:单页只做 「表单 → JSON → fetch → 解析 → UI」;BFF 只做 「鉴权 + 合法路径 + 转发」

index-volc-tts.html 再抠两点

  • status / error:把「进行中 / 成功 / 失败」从控制台搬到页面上,demo 才像产品。
  • await audio.play():若浏览器拦截自动播放,用户仍可通过 controls 手动点播放。

index-moonshot-vision.html 再抠两点

  • OpenAI 兼容content数组是多模态与纯文本混排的关键形状,顺序影响模型「先看到什么」。
  • 与火山的 Header 差异:火山是 Bearer;,Moonshot 是 Bearer ——写博客或接其它厂商时务必按文档逐字核对,不要想当然混用。

排错与扩展

  • 火山 401 / 鉴权失败:核对 Token 是否过期、Authorization 是否为 Bearer;、cluster 是否为 volcano_tts(或文档要求值)。
  • Moonshot 4xx:Key 是否有效、模型是否支持视觉、图片是否过大。
  • .env.local:务必重启 node server.js
  • 扩展:TTS 若改流式要换协议;视觉若改流式要解析 SSE / chunk,单页复杂度会明显高于本文的非流式示例。

相关文件(按阅读顺序)

  1. server.js — 搜 proxyVolcengineTtsproxyMoonshotRequest
  2. index-volc-tts.htmlindex-moonshot-vision.html
  3. README.md — 环境变量与接口总表

若你还想对照「文生图 + 异步轮询」的另一条 BFF 链路,可继续读 image-model.md 里的 index-keling.html 思路(与本篇的「一次 POST 即返回」形态不同)。

OpenClaw 插件开发避坑指南

Hi,大家好 👋 今天来聊聊 OpenClaw 插件开发中那些让人头秃的坑 😅

作为过来人,我把踩过的坑整理成这份避坑指南,建议先收藏再看,防止以后用到时找不着 📌


1. outbound.sendText vs deliver 回调:别再傻傻分不清 🔀

😵 问题现象

这两个货太容易搞混了!用 sendText 回复用户消息、用 deliver 推送系统通知……结果消息跑错路,业务逻辑全乱套,调试调到头秃。

🔍 问题根源

它俩是两条完全不同的消息发送通道,井水不犯河水:

特性 deliver 回调 outbound.sendText 方法
触发时机 用户主动发消息 → 我来响应 系统主动推消息 → 不用等用户操作
典型场景 回复用户提问、反馈操作结果 系统通知、告警提示、定时推送
messageId 直接用用户原消息 ID,别自己造 手动生成,建议 Date.now().toString(36)
能力 支持工具调用、流式输出、多轮对话 只能发一次性文本,不支持流式

✅ 解决方案

一句话记住:用户主动找你的 → deliver;系统自己要推的 → sendText。

// 🚀 sendText:主动推送(系统通知、告警等)
outbound: {
    sendText: async ({ to, text, accountId }) => {
         // 手动生成唯一ID
        const messageId = Date.now().toString(36);

        // 写到文件日志,方便排查
        const fs = require('fs');
        const logPath = path.join(__dirname, 'outbound.log');
        fs.appendFileSync(logPath,
            `[${new Date().toLocaleString()}] messageId: ${messageId} | 发给: ${to}\n`);

        await wsClient.send({ type: 'push', payload: { text, messageId, to } });
        return { channel: 'yeizi', ok: true, messageId };
    }
}
// 🚀 deliver:被动响应(回复用户消息)
if (message.type === 'message' && message.text) {
    // 复用用户的ID,别自己造!
    const originalMessageId = message.messageId; 

    await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
        ctx: finalized,
        cfg,
        dispatcherOptions: {
            deliver: async (payload, info) => {
                await wsClient.send({
                    type: 'response',
                    payload: { content: payload.content, messageId: originalMessageId }
                });
            }
        }
    });
}

2. sendText 日志「消失」怎么办 📝

😵 问题现象

在 sendText 里写了 console.log,控制台毛都没有……函数到底有没有跑?参数对不对?完全不知道,排查问题全靠猜。

🔍 问题根源

sendText 是出站子系统异步调用的,跟主进程控制台不连通,console.log 相当于「对牛弹琴」。

✅ 解决方案

放弃 console.log,写文件日志才是正道:

const fs = require('fs');
const logPath = path.join(__dirname, 'sendText-debug.log');

const log = (msg) => {
    fs.appendFileSync(logPath,
        `[${new Date().toISOString()}] ${msg}\n`, { encoding: 'utf8' });
};

log(`发送成功 | messageId: ${messageId} | 内容: ${text}`);

想看日志?type sendText-debug.log 或者直接打开文件,一目了然 👀


3. deliver 回调里抓取 AI 干活的全过程 🤖

😵 问题现象

想知道 AI 到底调用了哪个工具、返回了什么结果,可 deliver 回调触发好几次(工具调用、流式文本、最终回复全混在一起),根本分不清谁是谁。

🔍 问题根源

  1. 没开详细日志,OpenClaw 默认不输出工具调用信息
  2. deliver 回调返回的消息分三种类型,得靠 info.kind 来区分

✅ 解决方案

第一步:开启完整日志(在 openclaw.json 里)

{
    "agents": {
        // 开启后能看到 AI 调工具的全过程
        "verboseDefault": "full" 
    }
}

第二步:靠 info.kind 区分消息类型

info.kind 意思是 能拿到的信息
tool AI 调用工具了 payload.content(工具返回结果)或 payload.error(报错信息)
block AI 正在「打字」中 payload.content(这波输出的文本片段)
final AI 终于说完了 payload.content(完整回复)

代码示例:

deliver: async (payload, info) => {
    if (!info) return;

    switch (info.kind) {
        case 'tool':
            console.log('🛠️ 工具调用/返回:', payload.content || payload.error);
            break;
        case 'block':
            console.log('💬 AI 正在打字:', payload.content);
            break;
        case 'final':
            console.log('✅ AI 最终回复:', payload.content);
            break;
    }
}

💡 小技巧:日志会同时写入 /var/log/openclaw/runtime.log,两个地方都可以看。


4. 多账户配置:鉴权鉴到怀疑人生怎么破 🔐

😵 问题现象

配置了 N 个账户,结果每个账户都独立鉴权、WebSocket 连接也各自建一个……服务器压力山大,连接还可能不稳定,鉴权失败的错误弹到麻木。

🔍 问题根源

没搞复用!每个账户都「自立门户」,鉴权、连接全重做了一遍。

✅ 解决方案

方案一:单账户走天下(推荐)

如果业务不需要严格区分用户身份,一个账户就能服务所有人,省心又省力。

方案二:共享连接池(多账户必用)

// 全局就一个 WebSocket 连接,大家共用
let sharedWsConnection = null;

const getSharedConnection = async () => {
    if (!sharedWsConnection) {
        // 只有第一次需要鉴权创建连接
        sharedWsConnection = await createAndAuthenticateWs();
    }
    return sharedWsConnection;
};

gateway.startAccount = async (accountId) => {
    // 复用!不再重复鉴权
    const ws = await getSharedConnection();
    // 业务逻辑继续...
};

5. 插件依赖:别再重复安装内置包啦 📦

😵 问题现象

插件启动失败、版本冲突、包体积膨胀……一查,哦豁,dependencies 里写了 openclawtypescript,好家伙,这两个 OpenClaw 运行环境里已经有了!

🔍 问题根源

OpenClaw 内置的包(openclaw、typescript 等),别再 npm install 了,否则版本冲突没商量。

✅ 解决方案

❌ 错误示范(别学):

{
    "dependencies": {
        // 千万别装!
        "openclaw": ">=2026.3.12",   
         // 千万别装!
        "typescript": "^5.0.0"       
    }
}

✅ 正确姿势:

{
    "name": "@myorg/openclaw-yeizi",
    "version": "1.0.0",
    "type": "module",
    "peerDependencies": {
        // 声明版本即可,不实际安装
        "openclaw": ">=2026.3.13"    
    },
    "dependencies": {
        // 只装业务真正需要的
        "ws": "^8.16.0"              
    },
    "devDependencies": {
        // 开发用类型定义,可选
        "@types/ws": "^8.5.10"       
    }
}

验证方法:

  • node_modules 里没有 openclaw、typescript 文件夹 ✓
  • npm run start 能正常启动插件 ✓

🎉 祝大家插件开发顺利,少掉头发,多掉 bug(然后快速修好)💪

如果觉得有帮助,点个赞 👍 支持一下,也欢迎在评论区分享你的踩坑经历!

小程序包体积分析利器 -- vite-plugin-component-insight

背景

大家好,我是 uni-app 的核心开发 笨笨狗吞噬者,欢迎关注我的微信公众号 前端笨笨狗,或者加我的微信 wxid_olsjlzuh4ivf22 沟通交流!

微信小程序支持分包异步化 跨分包自定义组件引用,但是,很多业务项目往往都比较复杂,组件使用情况也不容易看清,开发中很容易遇到这些问题:

  • 无法快速获悉某个组件到底被哪些页面使用
  • 不清楚一个组件在项目里出现了多少次
  • 做分包优化时,不知道组件放在主包还是分包更合适

于是,我写了一个插件 vite-plugin-component-insight 来简化这一过程。

特性

  • 开箱即用,配置简单
  • 统计组件的使用次数和调用情况
  • 结合主包和分包关系输出组件划分建议
  • 支持生成 markdown 报告,方便查看更加详细的信息
  • 支持 hx 项目和 cli 项目
  • 支持 uni-app 和 uni-app-x (vue3)

使用指南

安装

npm install @uni_toolkit/vite-plugin-component-insight -D
# 或
pnpm add @uni_toolkit/vite-plugin-component-insight -D
# 或
yarn add @uni_toolkit/vite-plugin-component-insight -D

配置插件

vite.config.js 中使用:

import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';
import componentInsight from '@uni_toolkit/vite-plugin-component-insight';

export default defineConfig({
  plugins: [
    uni(),
    componentInsight(), // 在 uni 之后调用
  ],
});

Tips

插件默认不会生成文件,而是在控制台直接输出分析结果。

sub.jpg

如果需要生成 markdown 报告,可以这样配置:

componentInsight({
  reportMarkdownPath: 'logs/component-insight-report.md',
})

如果只想生成 markdown,不输出控制台日志,可以这样配置:

componentInsight({
  logToConsole: false,
  reportMarkdownPath: 'logs/component-insight-report.md',
})

完整配置项

interface VitePluginComponentInsightOptions {
  reportMarkdownPath?: string;
  logToConsole?: boolean;
  exclude?: ReadonlyArray<string | RegExp> | string | RegExp | null;
  include?: ReadonlyArray<string | RegExp> | string | RegExp | null;
}
选项 说明
reportMarkdownPath 自定义 Markdown 报告输出路径,不传则不生成 Markdown
logToConsole 是否输出控制台日志,默认开启
exclude 指定过滤的文件,默认过滤 node_modules 和 uni_modules
include 指定包含的文件,默认为空

Vite 开发服务器启动时,如何将 client 注入 HTML?

当执行 vite 命令启动开发服务器,并在浏览器中打开 http://localhost:5173 时,页面会神奇地具备热模块替换(HMR)能力。这一切的起点,就是 Vite 在返回的 HTML 中悄悄注入了一个特殊的脚本:/@vite/client。这个脚本负责建立 WebSocket 连接、监听文件变化并触发模块热更新。

整体流程概览

Vite 将 client 注入 HTML 的过程可以概括为以下几个步骤:

  1. 服务器启动:创建 Vite 开发服务器,初始化插件容器。
  2. 注册插件:内置插件被激活。
  3. 请求拦截:浏览器请求 index.html,Vite 的 HTML 中间件接管。
  4. HTML 转换:调用所有插件的 transformIndexHtml 钩子。
  5. 注入标签clientInjectionsPlugin 在 transformIndexHtml 中返回需要注入的 script 标签。
  6. 模块解析:浏览器解析 HTML 后请求 /@vite/client,经过 resolveId 和 load 钩子返回实际代码。
  7. 代码转换:client 源码中的占位符被替换为当前服务器的实际配置(如 HMR 端口、base 路径等)。
  8. 客户端执行:浏览器执行 client 代码,建立 WebSocket 连接,HMR 就绪。

启动服务器到 client 在浏览器中运行的全过程

image.png

clientInjectionsPlugin

负责在客户端代码中注入配置值和环境变量,确保客户端代码能够正确访问 Vite 配置和环境信息,特别是热模块替换 (HMR) 相关的配置。

客户端核心入口:处理 /@vite/client 和 /@vite/env 文件,先注入配置值,再替换 define 变量。

image.png

buildStart钩子

image.png

image.png

vite/packages/vite/src/node/plugins/clientInjections.ts

function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
  // 存储配置值替换函数,在 buildStart 钩子中初始化
  let injectConfigValues: (code: string) => string

  // 返回一个函数,每个构建环境(如 client 和 ssr)分别创建 define 替换函数
  const getDefineReplacer = perEnvironmentState((environment) => {

    const userDefine: Record<string, any> = {}

    for (const key in environment.config.define) {
      // import.meta.env.* is handled in `importAnalysis` plugin
      // 过滤掉 import.meta.env.* 前缀的变量(这些由 importAnalysis 插件处理
      if (!key.startsWith('import.meta.env.')) {
        userDefine[key] = environment.config.define[key]
      }
    }
    const serializedDefines = serializeDefine(userDefine)
    const definesReplacement = () => serializedDefines
    return (code: string) => code.replace(`__DEFINES__`, definesReplacement)
  })

  return {
    name: 'vite:client-inject',
    // 初始化插件,在 buildStart 钩子中创建配置值替换函数
    async buildStart() {

      // 生成一个函数
      // 用于接收客户端源码字符串,将其中的占位符(如 __BASE__、__HMR_PORT__、__MODE__ 等)替换为实际的值
      injectConfigValues = await createClientConfigValueReplacer(config)
    },
    // 转换客户端代码,注入配置值和环境变量
    async transform(code, id) {
      const ssr = this.environment.config.consumer === 'server'
      const cleanId = cleanUrl(id)

      // 客户端核心入口:/@vite/client 和 /@vite/env
      if (cleanId === normalizedClientEntry || cleanId === normalizedEnvEntry) {
        const defineReplacer = getDefineReplacer(this)
        return defineReplacer(injectConfigValues(code))

        // 其他文件中的 process.env.NODE_ENV 替换
      } else if (!ssr && code.includes('process.env.NODE_ENV')) {
        // replace process.env.NODE_ENV instead of defining a global
        // for it to avoid shimming a `process` object during dev,
        // avoiding inconsistencies between dev and build
        const nodeEnv =
        // 优先使用用户定义的值
          this.environment.config.define?.['process.env.NODE_ENV'] ||
          // 回退到系统环境变量
          // 最终回退到 Vite 模式
          JSON.stringify(process.env.NODE_ENV || config.mode)

        return await replaceDefine(this.environment, code, id, {
          'process.env.NODE_ENV': nodeEnv,
          'global.process.env.NODE_ENV': nodeEnv,
          'globalThis.process.env.NODE_ENV': nodeEnv,
        })
      }
    },
  }
}
function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {

  const stateMap = new WeakMap<Environment, State>()

  return function (context: PluginContext) {
    const { environment } = context
    // 尝试从 stateMap 中获取当前环境的状
    let state = stateMap.get(environment)
    if (!state) {
      // 调用 initial 函数初始化状态,并将其存储到 stateMap 中
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

indexHtmlMiddleware 中间件

当浏览器请求 index.html 时,Vite 开发服务器会通过中间件(packages/vite/src/node/server/middlewares/html.ts)处理。

  1. 请求拦截与过滤
  2. 完整打包模式(Full Bundle Dev Environment) 或 普通文件系统模式
  3. 通过 send 发送返回 HTML。

image.png

image.png

image.png

image.png

image.png

全量环境

  1. 文档类请求的 SPA 回退:若请求头的 sec-fetch-dest 为 documentiframe 等类型,并且满足以下任一条件:
    (1)当前 bundle 已过时,会重新生成 bundle);
    (2)或者文件原本不存在(file === undefined);则调用 generateFallbackHtml(server) 生成一个默认的 index.html 作为文件内容。

  2. 最终将文件内容(字符串或 Buffer)通过 send 返回,并携带 etag 用于缓存。

  if (fullBundleEnv) {
    const pathname = decodeURIComponent(url)
    // 打包根目录的文件路径 index.html
    const filePath = pathname.slice(1) // remove first /

    let file = fullBundleEnv.memoryFiles.get(filePath)
    if (!file && fullBundleEnv.memoryFiles.size !== 0) {
      return next()
    }
    const secFetchDest = req.headers['sec-fetch-dest']
    // 处理文档类请求(SPA 回退)
    if (
      [
        'document',
        'iframe',
        'frame',
        'fencedframe',
        '',
        undefined,
      ].includes(secFetchDest) &&
      // 检查当前 bundle 是否过期
      ((await fullBundleEnv.triggerBundleRegenerationIfStale()) ||
        file === undefined)
    ) {
      // 生成一个 fallback HTML 作为文件内容
      // 生成一个默认的 HTML 入口
      file = { source: await generateFallbackHtml(server as ViteDevServer) }
    }
    if (!file) {
      return next()
    }

    const html =
      typeof file.source === 'string'
        ? file.source
        : Buffer.from(file.source)
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers
    return send(req, res, html, 'html', { headers, etag: file.etag })
  }

image.png

image.png

发送

image.png

async function getHmrImplementation(
  config: ResolvedConfig,
): Promise<string> {

  // 读取client脚本文件
  const content = fs.readFileSync(normalizedClientEntry, 'utf-8')
  const replacer = await createClientConfigValueReplacer(config)
  return (
    replacer(content)
      // the rolldown runtime cannot import a module
      .replace(/import\s*['"]@vite\/env['"]/, '')
  )
}

image.png

image.png

 async function importUpdatedModule({
    url, // 补丁文件的 URL,例如 "/hmr_patch_0.js"
    acceptedPath, // 需要热更新的模块路径
    isWithinCircularImport,
  }) {
    const importPromise = import(base + url!).then(() =>
      // 从 Rolldown 运行时中提取模块的导出
      // @ts-expect-error globalThis.__rolldown_runtime__
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    if (isWithinCircularImport) {
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
}

【示例】修改文件,client 的 websocket 收到更新

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_0.js",
            "path": "src/pages/home/index.vue",
            "acceptedPath": "src/pages/home/index.vue",
            "timestamp": 1775992132341
        }
    ]
}

image.png

image.png

【示例】修改样式变量文件

浏览器客户端收到websocket消息,会加载补丁文件。

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "acceptedPath": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "acceptedPath": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "acceptedPath": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "timestamp": 1775994912031
        }
    ]
}

浏览器端成功加载一个模块(包括动态 import())后,客户端会主动发送 vite:module-loaded 事件。

{
    "type": "custom",
    "event": "vite:module-loaded",
    "data": {
        "modules": [
            "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less"
        ]
    }
}

服务器接收到该事件,提取出本次加载的模块列表(payload.modules),并将其注册到开发引擎(devEngine)中,关联到当前客户端 ID。

this.hot.on('vite:client:disconnect', (_payload, client) => {
  const clientId = this.clients.delete(client)
  if (clientId) {
    this.devEngine.removeClient(clientId)
  }
})

普通文件模式

  1. 解析请求的文件路径
  2. 开发模式下的访问权限检查
  3. 读取 HTML 文件并进行转换,最终通过 send 返回 HTML。
  // 根据请求 URL 确定 HTML 文件的实际文件系统路径
  let filePath: string

  // 如果是开发服务器且 URL 以 FS_PREFIX 开头(表示直接访问文件系统路径)
  if (isDev && url.startsWith(FS_PREFIX)) {
    filePath = decodeURIComponent(fsPathFromId(url))
  } else {
    // 将 URL 与服务器根目录连接,解析为绝对路径
    filePath = normalizePath(
      path.resolve(path.join(root, decodeURIComponent(url))),
    )
  }

  if (isDev) {
    const servingAccessResult = checkLoadingAccess(server.config, filePath)
    // 如果路径被拒绝访问,返回 403 错误
    if (servingAccessResult === 'denied') {
      return respondWithAccessDenied(filePath, server, res)
    }
    // 
    if (servingAccessResult === 'fallback') {
      return next()
    }
    // 确保路径被允许访问
    servingAccessResult satisfies 'allowed'
  } else {
    // `server.fs` options does not apply to the preview server.
    // But we should disallow serving files outside the output directory.
    if (!isParentDirectory(root, filePath)) {
      return next()
    }
  }

  if (fs.existsSync(filePath)) {
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers

    try {
      // 读取 HTML 文件内容
      let html = await fsp.readFile(filePath, 'utf-8')
      if (isDev) {
        // 开发环境下,对 HTML 进行转换
        html = await server.transformIndexHtml(url, html, req.originalUrl)
      }
      // 发送 HTML 内容
      // 这里使用 send() 方法,而不是 res.end(),因为它会自动处理响应头和编码
      return send(req, res, html, 'html', { headers })
    } catch (e) {
      return next(e)
    }
  }

image.png

执行中间件

image.png

image.png

读取html文件内容

image.png

server.transformIndexHtml 其实就是执行 applyHtmlTransforms,之前中间件已经处理 createDevHtmlTransformFn

createDevHtmlTransformFn

  1. plugin.transformIndexHtml 获取具有 transformIndexHtml 钩子的插件,排序。
  2. 构建转换钩子管道,在 applyHtmlTransforms 中按顺序执行。
  3. applyHtmlTransforms 根据插件钩子,生成相关 tag 注入 html 中。

vite/packages/vite/src/node/server/middlewares/indexHtml.ts

function createDevHtmlTransformFn(
  config: ResolvedConfig,
): (
  server: ViteDevServer,
  url: string,
  html: string,
  originalUrl?: string,
) => Promise<string> {

  // 从配置的插件中解析出 HTML 转换钩子
  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
    config.plugins,
  )

  // 构建转换钩子管道
  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

  // 创建插件上下文
  const pluginContext = new BasicMinimalPluginContext(
    { ...basePluginContextMeta, watchMode: true },
    config.logger,
  )
  return (
    server: ViteDevServer,
    url: string,
    html: string,
    originalUrl?: string,
  ): Promise<string> => {

    // 将所有转换钩子应用到 HTML 内容上
    return applyHtmlTransforms(html, transformHooks, pluginContext, {
      path: url,
      filename: getHtmlFilename(url, server),
      server,
      originalUrl,
    })
  }
}

traverseHtml

server.transformIndexHtml 对html进行转换 ——> createDevHtmlTransformFn confige 解析阶段收集了插件transformIndexHtml钩子 ——〉applyHtmlTransforms 执行上述收集的hook.handler——> injectToHead 将标签插入头部

收集所有插件的 transformIndexHtml 钩子返回的修改(可能是 HTML 字符串的替换,或者要插入的标签数组),然后将其应用到原始 HTML 上。

  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

【示例】执行devHtmlTransformFn

image.png

【示例】执行 htmlEnvHook

image.png

【示例】 devHtmlHook

image.png

image.png

image.png

处理 html 节点

image.png

处理head节点

image.png

处理 meta 节点

image.png

处理 link 节点

image.png

applyHtmlTransforms

image.png

injectToHead

image.png

vite/packages/vite/src/node/plugins/html.ts

async function applyHtmlTransforms(
  html: string,
  hooks: IndexHtmlTransformHook[],
  pluginContext: MinimalPluginContextWithoutEnvironment,
  ctx: IndexHtmlTransformContext,
): Promise<string> {
  for (const hook of hooks) {
    const res = await hook.call(pluginContext, html, ctx)
    if (!res) {
      continue
    }
    if (typeof res === 'string') {
      html = res
    } else {
      let tags: HtmlTagDescriptor[]
      if (Array.isArray(res)) {
        tags = res
      } else {
        html = res.html || html
        tags = res.tags
      }

      let headTags: HtmlTagDescriptor[] | undefined
      let headPrependTags: HtmlTagDescriptor[] | undefined
      let bodyTags: HtmlTagDescriptor[] | undefined
      let bodyPrependTags: HtmlTagDescriptor[] | undefined

      for (const tag of tags) {
        switch (tag.injectTo) {
          case 'body':
            ;(bodyTags ??= []).push(tag)
            break
          case 'body-prepend':
            ;(bodyPrependTags ??= []).push(tag)
            break
          case 'head':
            ;(headTags ??= []).push(tag)
            break
          default:
            ;(headPrependTags ??= []).push(tag)
        }
      }
      headTagInsertCheck([...(headTags || []), ...(headPrependTags || [])], ctx)
      if (headPrependTags) html = injectToHead(html, headPrependTags, true)
      if (headTags) html = injectToHead(html, headTags)
      if (bodyPrependTags) html = injectToBody(html, bodyPrependTags, true)
      if (bodyTags) html = injectToBody(html, bodyTags)
    }
  }

  return html
}

vite 插件 @vitejs/plugin-vue

@vitejs/plugin-vue 是 Vite 官方提供的 Vue 3 单文件组件(SFC)支持插件,它负责将 .vue 文件转换为浏览器可执行的 JavaScript 模块。

Vue3项目使用 @vitejs/plugin-vue插件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      
    }),
    // vueJsx(),
    // vueDevTools(),
  ],
})

参数选项有哪些?

interface Options {
  // 指定哪些文件需要被插件转换
  include?: string | RegExp | (string | RegExp)[]
  // 指定哪些文件不需要被插件转换
  exclude?: string | RegExp | (string | RegExp)[]

  /**
   * In Vite, this option follows Vite's config.
   */
  isProduction?: boolean

  // options to pass on to vue/compiler-sfc
  // 传递给 @vue/compiler-sfc 中 compileScript 的选项
  script?: Partial<
    Omit<
      SFCScriptCompileOptions,
      | 'id'
      | 'isProd'
      | 'inlineTemplate'
      | 'templateOptions'
      | 'sourceMap'
      | 'genDefaultAs'
      | 'customElement'
      | 'defineModel'
      | 'propsDestructure'
    >
  > & {
    /**
     * @deprecated defineModel is now a stable feature and always enabled if
     * using Vue 3.4 or above.
     */
    defineModel?: boolean
    /**
     * @deprecated moved to `features.propsDestructure`.
     */
    propsDestructure?: boolean
  }

  // 传递给 @vue/compiler-sfc 中 compileTemplate 的选项
  template?: Partial<
    Omit<
      SFCTemplateCompileOptions,
      | 'id'
      | 'source'
      | 'ast'
      | 'filename'
      | 'scoped'
      | 'slotted'
      | 'isProd'
      | 'inMap'
      | 'ssr'
      | 'ssrCssVars'
      | 'preprocessLang'
    >
  >
  // 传递给 @vue/compiler-sfc 中 compileStyle 的选项
  style?: Partial<
    Omit<
      SFCStyleCompileOptions,
      | 'filename'
      | 'id'
      | 'isProd'
      | 'source'
      | 'scoped'
      | 'cssDevSourcemap'
      | 'postcssOptions'
      | 'map'
      | 'postcssPlugins'
      | 'preprocessCustomRequire'
      | 'preprocessLang'
      | 'preprocessOptions'
    >
  >

  /**
   * Use custom compiler-sfc instance. Can be used to force a specific version.
   */
  compiler?: typeof _compiler

  /**
   * Requires @vitejs/plugin-vue@^5.1.0
   */
  features?: {
    /**
     * Enable reactive destructure for `defineProps`.
     * - Available in Vue 3.4 and later.
     * - **default:** `false` in Vue 3.4 (**experimental**), `true` in Vue 3.5+
     * 启动后,`defineProps` 中的响应式解构将支持 Vue 3.4 中的语法。
     */
    propsDestructure?: boolean
    /**
     * Transform Vue SFCs into custom elements.
     * - `true`: all `*.vue` imports are converted into custom elements
     * - `string | RegExp`: matched files are converted into custom elements
     * - **default:** /\.ce\.vue$/
     * 启动后,所有匹配的文件都将被转换为自定义元素。
     */
    customElement?: boolean | string | RegExp | (string | RegExp)[]
    /**
     * Set to `false` to disable Options API support and allow related code in
     * Vue core to be dropped via dead-code elimination in production builds,
     * resulting in smaller bundles.
     * - **default:** `true`
     * 启动后,Vue 核心代码中的 Options API 将被移除,从而减小 bundle 大小。
     */
    optionsAPI?: boolean
    /**
     * Set to `true` to enable devtools support in production builds.
     * Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 devtools 将被启用,从而增加 bundle 大小。
     */
    prodDevtools?: boolean
    /**
     * Set to `true` to enable detailed information for hydration mismatch
     * errors in production builds. Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 hydration mismatch 错误将包含详细的调试信息,从而增加 bundle 大小。
     */
    prodHydrationMismatchDetails?: boolean
    /**
     * Customize the component ID generation strategy.
     * - `'filepath'`: hash the file path (relative to the project root)
     * - `'filepath-source'`: hash the file path and the source code
     * - `function`: custom function that takes the file path, source code,
     *   whether in production mode, and the default hash function as arguments
     * - **default:** `'filepath'` in development, `'filepath-source'` in production
     * 启动后,组件 ID 将根据文件路径和源代码进行哈希处理,从而增加 bundle 大小。
     */
    componentIdGenerator?:
      | 'filepath'
      | 'filepath-source'
      | ((
          filepath: string,
          source: string,
          isProduction: boolean | undefined,
          getHash: (text: string) => string,
        ) => string)
  }

  /**
   * @deprecated moved to `features.customElement`.
   * 已废弃,移至 feature中
   */
  customElement?: boolean | string | RegExp | (string | RegExp)[]
}

SFC 编译流程

当 Vite 遇到一个 .vue 文件时,插件会执行以下编译流程:

  1. 解析 SFC:使用 @vue/compiler-sfc 将 .vue 文件解析为 descriptor 对象,其中包含 templatescriptstyle 等部分的解析结果
  2. 脚本编译:处理 <script> 块,包括 <script setup> 语法糖和 TypeScript 支持
  3. 模板编译:将 <template> 块编译为 render 函数
  4. 样式处理:处理 <style> 块,包括 CSS 预处理器的支持

生命周期

@vitejs/plugin-vue 中钩子执行顺序遵循 Vite 插件生命周期,分为服务器启动/构建准备阶段模块请求处理阶段

配置阶段(一次)

  • config:最早执行,用于修改 Vite 配置。
  • configResolved:配置解析完成后调用,可获取最终配置。
  • options:Rollup 选项钩子,在构建开始前修改输入选项(较少用)。

服务器启动 / 构建开始

  • 开发模式configureServer 在开发服务器创建时调用,用于添加中间件。
  • 生产构建buildStart 在构建开始时调用。

模块请求处理(每次请求/每个文件)

  • resolveId:解析模块 ID(将路径转换为绝对路径或虚拟 ID)。
  • load:加载模块内容(读取文件或生成源码)。
  • shouldTransformCachedModule(Rollup 钩子):决定是否使用缓存转换结果(在 load 后、transform 前调用,仅构建时)。
  • transform:转换模块内容(核心编译逻辑,例如将 .vue 文件转为 JS)。

image.png

transform 钩子

transform 钩子是整个插件的核心编译入口,它的职责是拦截 .vue 文件或相关子模块的请求,根据请求参数的不同,调用相应的编译函数,将 SFC 转换为浏览器可执行的 JavaScript 代码。

handler 接收的参数

  • code vue 文件源码
  • id 文件在系统的绝对路径
  • opt 配置项

image.png

image.png

 主请求(!query.vue

这是浏览器或构建工具直接请求 .vue 文件(例如 import App from './App.vue')。

transformMain 是 @vitejs/plugin-vue 中处理 .vue 文件主请求的核心编译函数。它负责将整个单文件组件(SFC)转换为可在浏览器或服务端运行的 JavaScript 模块。

  1. 解析与校验:创建 SFC 描述符,检查编译错误。
  2. 分块编译:分别生成脚本、模板、样式、自定义块的代码。
  3. 组装导出:合并各部分代码,生成最终组件对象。
  4. HMR 与 SSR 增强:注入热更新逻辑或服务端模块注册。
  5. Source Map 合并:如果存在模板,将模板的 source map 偏移后合并到脚本 map。
  6. TypeScript 转译:对最终代码进行 TS 转译(优先使用 Oxc,降级为 esbuild)。
创建描述符信息 compiler.parse

createDescriptor

image.png

getDescriptor 获取描述符。有缓存则从缓存取,否则创建。

image.png

image.png

描述符id生成策略

描述符id生成策略(依据 features?.componentIdGenerator 配置)

  • filepath 文件路径
  • filepath-source 文件路径 +源码
  • function 自定义实现
  • 默认策略(生产环境:文件路径+源码;非生产环境:文件路径)

image.png

import crypto from 'node:crypto'

function getHash(text: string): string {
  // 计算哈希值,采用 sha256 哈希算法对输入文本进行计算
  // 将哈希结果转换为十六进制 (hex) 格式
  // 取前8位,用于组件ID的唯一性
  return crypto.hash('sha256', text, 'hex').substring(0, 8)
}
生成脚本代码

genScriptCode 通过 resolveScript(@vue/compiler-dom ) 获取脚本,然后根据 <script> 块的存在性、内容来源(内联或外部)以及 Vue 编译器版本,产出最终用于构建组件对象的脚本部分。

参数信息

image.png

脚本代码 resolve

image.png

image.png

resolved.content

image.png

image.png

resolevd.map

image.png

生成 template 代码

针对 内联模板(无预处理器且无外部 src)

genTemplateCode 调用 transformTemplateInMain 函数,该函数会:

  1. 使用 @vue/compiler-dom 将模板内容编译为 render 函数。
  2. 处理 scoped 样式、指令转换等。
  3. 返回包含 render 函数声明的代码(例如 const _sfc_render = () => {...})。
  4. 直接内联到主模块中,避免额外的网络请求,提升开发环境性能。

image.png

transformTemplateInMain(template.content, descriptor, options, pluginContext, ssr, customElement)

image.png

result.code

image.png

result.ast

image.png

// 重命名模板编译后的渲染函数
// $1 引用第一个捕获组,即 function 或 const
// $2 引用第二个捕获组,即 render 或 ssrRender
result.code.replace(
      /\nexport (function|const) (render|ssrRender)/,
      '\n$1 _sfc_$2',
    )

image.png

image.png

生成style 代码

genStyleCode 是 @vitejs/plugin-vue 中专门处理 Vue 单文件组件(SFC)样式块的核心函数。它的主要职责是:为每个 <style> 块生成相应的导入语句,并处理 CSS Modules 和自定义元素模式下的样式收集

生成 自定义块 代码

genCustomBlockCode 用于生成 Vue 单文件组件 (SFC) 中自定义块的处理代码,它会为每个自定义块生成导入语句和执行代码,确保自定义块能够被正确处理和集成到组件中。

添加热更新相关代码

image.png

image.png

import.meta.hot.on("file-changed", ({ file }) => {
__VUE_HMR_RUNTIME__.CHANGED_FILE = file;
});

Vue HMR(热模块替换)运行时的一部分,用于监听 Vite 开发服务器的 file-changed 事件,并记录被修改的文件路径。

// Vite 提供的 HMR API,用于接受模块自身的更新。当该模块(即 .vue 文件)被修改时,回调函数会收到新的模块内容 mod。
import.meta.hot.accept((mod) => {
if (!mod) return;
const { default: updated, _rerender_only } = mod;
if (_rerender_only) {
  __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
} else {
  __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
}
});
  • updated:更新后的组件默认导出(即组件对象)。
  • _rerender_only:Vue 编译器生成的一个标志,用于指示本次变更是否仅影响模板(而不影响 <script> 逻辑)。如果是 true,则只需重新渲染视图;否则需要完全重载组件实例。
  • __VUE_HMR_RUNTIME__ :Vue 在开发环境注入的全局 HMR 运行时对象。
    • rerender:仅更新组件的渲染函数,保留组件实例状态(如 datacomputed 等)。通常用于仅修改 <template> 的场景。
    • reload:完全销毁并重新创建组件实例,会丢失内部状态。用于 <script> 逻辑发生变化时。

core/packages/runtime-core/src/hmr.ts

if (__DEV__) {
  getGlobalThis().__VUE_HMR_RUNTIME__ = {
    createRecord: tryWrap(createRecord),
    rerender: tryWrap(rerender),
    reload: tryWrap(reload),
  } as HMRRuntime
}
收集附加属性 (attachedProps)

添加 attachedProps 的导出代码并转为字符串resolvedCode

image.png

转译 Typescript

根据条件 判断利用 transformWithOxctransformWithEsbuild 来转译 Tyscript 代码。

优先尝试使用 Oxc(一个高性能的 JavaScript/TypeScript 编译器)进行转译,如果不可用,则回退到使用 esbuild

image.png

子块请求(query.vue 为 true

首先获取缓存的 SFC 描述符(descriptor)。

根据 query.type 进一步分流:

  1. type === 'template' :调用 transformTemplateAsModule,将 <template> 块编译为独立的 render 函数模块。
  2. type === 'style' :调用 transformStyle,将 CSS 内容交给 Vite 的 CSS 处理管道(例如注入到页面或提取为独立文件)。

处理 template

async function transformTemplateAsModule(
  code: string, 
  filename: string,
  descriptor: SFCDescriptor,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  ssr: boolean,
  customElement: boolean,
): Promise<{
  code: string
  map: any
}> {
  // 调用 compile 函数编译模板代码
  // 返回包含编译后代码和 source map 的结果
  const result = compile(
    code,
    filename,
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement,
  )

  let returnCode = result.code

  // 处理热更新
  if (
    options.devServer && //开发服务器
    options.devServer.config.server.hmr !== false && // 开启热更新
    !ssr && // 不是服务器端渲染
    !options.isProduction // 不是生产环境
  ) {
      // 重新渲染组件
      // 传递组件 ID 和新的渲染函数
    returnCode += `\nimport.meta.hot.accept(({ render }) => {
      __VUE_HMR_RUNTIME__.rerender(${JSON.stringify(descriptor.id)}, render)
    })`
  }

  return {
    code: returnCode,
    map: result.map,
  }
}

处理style

async function transformStyle(
  code: string,
  descriptor: ExtendedSFCDescriptor,
  index: number,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  filename: string,
) {
  const block = descriptor.styles[index]
  // vite already handles pre-processors and CSS module so this is only
  // applying SFC-specific transforms like scoped mode and CSS vars rewrite (v-bind(var))
  const result = await options.compiler.compileStyleAsync({
    ...options.style,
    filename: descriptor.filename, // 样式文件路径
    id: `data-v-${descriptor.id}`,// 组件 ID(用于 scoped 样式)
    isProd: options.isProduction,
    source: code, // 原始样式代码
    scoped: block.scoped, // 是否为 scoped 样式
    ...(options.cssDevSourcemap
      ? {
          postcssOptions: {
            map: {
              from: filename, // 设置源文件路径
              inline: false, // 不内联 Source Map,Source Map 会作为单独的文件生成
              annotation: false, // 不在 CSS 文件中添加 Source Map 注释
            },
          },
        }
      : {}),
  })

  if (result.errors.length) {
    result.errors.forEach((error: any) => {
      if (error.line && error.column) {
        error.loc = {
          file: descriptor.filename,
          line: error.line + block.loc.start.line,
          column: error.column,
        }
      }
      pluginContext.error(error)
    })
    return null
  }

  const map = result.map
    ? await formatPostcssSourceMap(
        // version property of result.map is declared as string
        // but actually it is a number
        result.map as Omit<
          RawSourceMap,
          'version'
        > as Rollup.ExistingRawSourceMap,
        filename,
      )
    : ({ mappings: '' } as any)

  return {
    code: result.code,
    map: map,
    meta:
    // 当样式为 scoped 且描述符不是临时的时,添加 cssScopeTo 元数据
    // 用于 Vite 处理 CSS 作用域
      block.scoped && !descriptor.isTemp
        ? {
            vite: {
              cssScopeTo: [descriptor.filename, 'default'] as const,
            },
          }
        : undefined,
  }
}

handleHotUpdate 热更新

handleHotUpdate:当文件变化触发 HMR 时调用,自定义热更新行为。

执行过程?

  1. 获取旧描述符:从缓存中读取文件修改前的 SFC 描述符(prevDescriptor)。
  2. 读取最新内容并生成新描述符:通过 read() 获取文件当前内容,再调用 createDescriptor 生成新的 SFC 描述符。
  3. 比对差异:依次比较 scripttemplatestylecustomBlocks 等块是否发生变化。
  4. 收集受影响的模块:根据变化类型,将对应的 Vite 模块(ModuleNode)加入到 affectedModules 集合中。
  5. 返回模块列表:将 affectedModules 返回给 Vite,Vite 会重新转换这些模块,并通过 WebSocket 通知浏览器进行热更新。

vue 文件热更新变化

  1. 脚本变化导致组件完全重载(添加主模块);
  2. 模板变化且脚本未变时仅重新渲染(保留组件状态,添加模板模块);
  3. 样式变化时仅更新对应样式模块(若无独立模块则回退重载主模块);
  4. CSS 变量或 scoped 状态变化以及自定义块变化均会强制重载主模块

vite-plugin-vue-6.0.4/packages/plugin-vue/src/handleHotUpdate.ts

async function handleHotUpdate(
  { file, modules, read }: HmrContext,
  options: ResolvedOptions,
  customElement: boolean,
  typeDepModules?: ModuleNode[],
): Promise<ModuleNode[] | void> {
  
  const prevDescriptor = getDescriptor(file, options, false, true)
  if (!prevDescriptor) {
    // file hasn't been requested yet (e.g. async component)
    return
  }

  // 取文件的最新内容
  const content = await read()
  // 基于最新内容创建新的组件描述符
  const { descriptor } = createDescriptor(file, content, options, true)

  let needRerender = false
  // 将模块分为非 JS 模块和 JS 模块
  const nonJsModules = modules.filter((m) => m.type !== 'js')
  const jsModules = modules.filter((m) => m.type === 'js')

  // 受影响的模块集合
  const affectedModules = new Set<ModuleNode | undefined>(
    nonJsModules, // this plugin does not handle non-js modules
  )
  // 找到组件的主模块
  const mainModule = getMainModule(jsModules)
  // 找到模板相关的模块
  const templateModule = jsModules.find((m) => /type=template/.test(m.url))

  /** 1、检测脚本块的变化并确定受影响的模块 */
  // trigger resolveScript for descriptor so that we'll have the AST ready
  // resolveScript 会触发对 <script> 或 <script setup> 的解析(生成 AST 等),确保新描述符中的脚本信息可用
  resolveScript(descriptor, options, false, customElement)
  const scriptChanged = hasScriptChanged(prevDescriptor, descriptor)
  if (scriptChanged) {
    affectedModules.add(getScriptModule(jsModules) || mainModule)
  }

  /** 2、检测模板块的变化并确定受影响的模块 */
  // 模板变化
  if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
    // when a <script setup> component's template changes, it will need correct
    // binding metadata. However, when reloading the template alone the binding
    // metadata will not be available since the script part isn't loaded.
    // in this case, reuse the compiled script from previous descriptor.
    // 如果脚本没有改变,直接使用之前的编译后的脚本
    if (!scriptChanged) {
      setResolvedScript(
        descriptor,
        getResolvedScript(prevDescriptor, false)!,
        false,
      )
    }
    affectedModules.add(templateModule)
    needRerender = true // 标记需要重渲染
  }

  /** 3、检查 CSS 变量注入的变化并确定受影响的模块 */
  let didUpdateStyle = false
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // force reload if CSS vars injection changed
  // 如果 CSS 变量注入发生变化,强制重新加载
  if (prevDescriptor.cssVars.join('') !== descriptor.cssVars.join('')) {
    affectedModules.add(mainModule)
  }

  /** 4、检查 scoped 状态的变化并确定受影响的模块 */
  // force reload if scoped status has changed
  // 如果 scoped 状态变化,强制重新加载
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    // template needs to be invalidated as well
    affectedModules.add(templateModule)
    affectedModules.add(mainModule)
  }

  /** 5、检测样式块的变化并确定受影响的模块 */
  // only need to update styles if not reloading, since reload forces
  // style updates as well.
  for (let i = 0; i < nextStyles.length; i++) {
    const prev = prevStyles[i]
    const next = nextStyles[i]

    // 如果旧样式块不存在(新添加的样式块)
    // 或者旧样式块与新样式块不相等(样式内容发生变化
    if (!prev || !isEqualBlock(prev, next)) {
      didUpdateStyle = true // 标记样式发生变化
      const mod = jsModules.find(
        (m) =>
          m.url.includes(`type=style&index=${i}`) &&
          m.url.endsWith(`.${next.lang || 'css'}`),
      )
      if (mod) {
        affectedModules.add(mod)

        // 如果样式内联,添加主模块到受影响模块集合
        if (mod.url.includes('&inline')) {
          affectedModules.add(mainModule)
        }
      } else {
        // 如果没有找到对应的模块(新添加的样式块)
        // new style block - force reload
        affectedModules.add(mainModule)
      }
    }
  }
  if (prevStyles.length > nextStyles.length) {
    // 如果旧样式块数量大于新样式块数量(说明有样式块被移)
    // 强制重新加载
    // style block removed - force reload
    affectedModules.add(mainModule)
  }

  /**  6、检测自定义块的变化并确定受影响的模块 */
  const prevCustoms = prevDescriptor.customBlocks || []
  const nextCustoms = descriptor.customBlocks || []

  // custom blocks update causes a reload
  // because the custom block contents is changed and it may be used in JS.
  // 如果数量变化,强制重新加载
  if (prevCustoms.length !== nextCustoms.length) {
    // block removed/added, force reload
    affectedModules.add(mainModule)
  } else {
    for (let i = 0; i < nextCustoms.length; i++) {
      const prev = prevCustoms[i]
      const next = nextCustoms[i]

      // 
      if (!prev || !isEqualBlock(prev, next)) {
        const mod = jsModules.find((m) =>
          m.url.includes(`type=${prev.type}&index=${i}`),
        )
        if (mod) {
          affectedModules.add(mod)
        } else {
          affectedModules.add(mainModule)
        }
      }
    }
  }

  const updateType = []
  // 需要重渲染
  if (needRerender) {
    // 记录更新类型。将 'template' 添加到 updateType 数组中
    updateType.push(`template`)
    // template is inlined into main, add main module instead
    // 无模板情况,说明模板被内联到主模块中
    if (!templateModule) {
      // 添加 mainModule 到受影响模块集合
      affectedModules.add(mainModule)

      // 有模板的情况,且 mainModule 未被添加到受影响模块
    } else if (mainModule && !affectedModules.has(mainModule)) {

      // 找到 mainModule 的所有样式导入模块
      const styleImporters = [...mainModule.importers].filter((m) =>
        isCSSRequest(m.url),
      )
      // 将样式导入模块添加到受影响模块集合
      styleImporters.forEach((m) => affectedModules.add(m))
    }
  }

  // 样式发送变化,将 'style' 添加到 updateType 数组中
  if (didUpdateStyle) {
    updateType.push(`style`)
  }
  if (updateType.length) {
    // 针对vue文件,使描述符缓存失效
    if (file.endsWith('.vue')) {
      // invalidate the descriptor cache so that the next transform will
      // re-analyze the file and pick up the changes.
      invalidateDescriptor(file)
    } else {
      // https://github.com/vuejs/vitepress/issues/3129
      // For non-vue files, e.g. .md files in VitePress, invalidating the
      // descriptor will cause the main `load()` hook to attempt to read and
      // parse a descriptor from a non-vue source file, leading to errors.
      // To fix that we need to provide the descriptor we parsed here in the
      // main cache. This assumes no other plugin is applying pre-transform to
      // the file type - not impossible, but should be extremely unlikely.
      // 将解析的描述符设置到主缓存中
      cache.set(file, descriptor)
    }
    debug(`[vue:update(${updateType.join('&')})] ${file}`)
  }
  return [...affectedModules, ...(typeDepModules || [])].filter(
    Boolean,
  ) as ModuleNode[]
}

使用 Claude Code 将 Google Stitch 设计稿转换为代码

使用 Claude Code 将 Google Stitch 设计稿转换为代码

概述

Google Stitch 是 Google Labs 推出的 AI UI 设计工具,能够通过自然语言生成高保真 UI 设计,并支持导出 HTML/CSS 代码或复制到 Figma 。配合 Claude Code,你可以将 Stitch 生成的设计进一步精细化转换为生产级前端代码。

整体工作流

Stitch 生成设计 → 导出设计稿/代码 → Claude Code 代码转换/优化 → 集成到项目

方法一:Stitch + Figma + Claude Code(推荐)

Step 1:在 Stitch 中生成设计

  1. 访问 Stitch 官网 并使用 Google 账号登录
  2. 选择 Standard Mode(基于 Gemini 2.5 Flash,支持导出到 Figma)
  3. 输入设计提示词,选择界面类型(移动端/网页端)

提示词示例:

Design a modern and user-friendly fitness mobile app interface. The app should feature a bottom navigation bar with four tabs: Home, Workouts, Shop, and Profile.

Step 2:导出设计到 Figma

  1. 生成满意设计后,点击页面顶部的 Figma 按钮
  2. 在 Figma 画布中 Ctrl+V 粘贴,所有组件、文字、图片会自动分层并保留 Auto Layout
  3. 在 Figma 中精修设计细节(可选)

Step 3:配置 Figma MCP 连接 Claude Code

Figma MCP 能让 Claude Code 直接读取 Figma 设计稿的结构化数据,生成高保真代码 。

启动 Figma MCP Server:

  1. 打开 Figma 桌面客户端(非网页版)
  2. 进入 Preferences → Enable Dev Mode MCP Server

配置 Claude Code 的 MCP: 在项目根目录的 .mcp.json 中添加:

{
  "mcpServers": {
    "Figma": {
      "url": "http://127.0.0.1:3845/sse"
    }
  }
}

或通过命令行添加:

claude mcp add --transport sse Figma http://127.0.0.1:3845/sse

添加 Figma 自定义规则到 CLAUDE.md

<user_custom_rules>
# Figma Dev Mode MCP Rules
- IMPORTANT: If Figma MCP returns a localhost source for an image/SVG, use that source directly
- IMPORTANT: DO NOT import/add new icon packages, all assets should come from Figma
- IMPORTANT: DO NOT use or create placeholders if a localhost source is provided
</user_custom_rules>

Step 4:使用 Claude Code 转换设计为代码

在 Figma 中选中要转换的设计画板,然后在 Claude Code 中输入:

请根据 Figma 当前选中的设计稿,将其转换为代码实现。

要求:
1. 使用 get_code 方法读取设计稿的 UI 细节
2. 使用 [React/Vue/HTML/CSS] 框架
3. 保持设计稿的间距、颜色、字体完全一致
4. 优先使用设计系统的 token/变量
5. 组件需要具备响应式能力

Claude Code 会调用 Figma MCP 的 get_code 工具读取设计稿细节,并生成对应代码 。

Step 5:预览和迭代

Claude Code Desktop 提供内置预览功能:

  • 点击 Preview 按钮可实时查看生成的页面
  • 如需微调,直接描述修改需求:"那个按钮的圆角改成 8px"
  • Claude 会即时更新代码

方法二:直接使用 Stitch 导出的 HTML + Claude Code 优化

Step 1:从 Stitch 导出 HTML 代码

Stitch 支持直接导出 HTML/CSS 代码 :

  1. 在 Stitch 中生成满意的设计
  2. 导出 HTML 代码包

Step 2:让 Claude Code 优化和重构代码

将导出的代码放入项目目录,然后使用 Claude Code 优化:

请分析并优化这个 Stitch 导出的 HTML/CSS 代码:

1. 将样式重构为更可维护的 CSS 模块/组件结构
2. 将静态 HTML 转换为 [React/Vue] 组件
3. 优化语义化 HTML 标签和无障碍访问
4. 添加响应式断点适配移动端/平板/桌面
5. 使用 CSS 变量统一管理设计 token

Step 3:集成到现有项目

请将这些重构后的组件集成到我的现有项目结构中:
- 组件放在 src/components/[功能名]/
- 样式文件使用 [CSS Modules/Tailwind/Styled Components]
- 路由配置添加到 src/router/index

高级技巧:使用 Intuition 工作流进行系统化开发

对于复杂项目,推荐使用 Intuition 工作流系统,它提供 trunk-and-branch 的规范开发流程 。

安装 Intuition

npm install -g @tgoodington/intuition

完整工作流

/intuition-initialize          # 初始化项目记忆(仅首次)
/intuition-start               # 检查状态,获取下一步路由
/intuition-prompt              # 描述设计需求,生成结构化简报
/intuition-outline             # 创建策略蓝图,分解任务
/intuition-assemble            # 匹配任务到领域专家
/intuition-detail              # 生成详细代码规格
/intuition-build               # 执行实现并验证
/intuition-test                # 质量检验

结合 Stitch 设计的使用方式:

  1. 在 Stitch 中生成页面设计
  2. 截图或导出设计稿作为视觉参考
  3. /intuition-prompt 阶段附上设计稿截图,描述设计意图
  4. 后续阶段 Intuition 会生成结构化的代码方案并指导实现

常用 Claude Code 命令速查

命令 功能
/init 初始化项目记忆文件,扫描代码库生成架构说明
/review 执行代码审查,检查语法错误和最佳实践
/compact 压缩对话历史,释放上下文 token
/export 导出完整对话记录为 Markdown
Ctrl+R 搜索历史 Prompt
!命令 直接执行终端命令(如 ! npm run dev

注意事项与最佳实践

  1. 设计还原度:Figma MCP 能最大程度保留设计稿的精确度,优先使用此方式
  2. Token 管理:复杂对话后使用 /compact 释放上下文,避免超出 token 限制
  3. 安全第一:使用 Plan Mode 让 Claude 先规划再执行,避免误修改
  4. 环境变量:如需连接数据库或 API,向开发同事获取 .env 文件,切勿在对话中粘贴密钥
  5. 预览调试:如内置预览失败,可让 Claude 启动本地服务器并提供 localhost 链接

快速上手示例:从 Stitch 设计到 React 组件

# 1. 启动 Figma MCP(Figma 桌面端 → Preferences → Enable Dev Mode MCP)

# 2. 在 Claude Code 中配置
claude mcp add --transport sse Figma http://127.0.0.1:3845/sse

# 3. 启动 Claude Code
claude

# 4. 在对话中输入
> 请根据 Figma 当前选中的 Stitch 设计稿,生成一个 React + TypeScript 组件。
> 使用 Tailwind CSS 处理样式,组件放在 src/components/Dashboard。
> 确保响应式布局和暗色模式支持。

Claude Code 会读取 Figma 中的设计稿结构,生成可直接使用的 React 组件代码,包括完整的 props 类型定义和样式。

❌