普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月15日首页

智能体与工作流:从「想做一个应用」到「能跑通一条链」

作者 颜酱
2026年4月15日 18:00

智能体与工作流:从「想做一个应用」到「能跑通一条链」

这篇博客用睡前故事串起两件事:概念上分清智能体与工作流;操作上Coze 搭画布(大模型 / 循环 / 插件)、发布工作流,再 创建对话智能体 把链挂上去并用预览验收;最后对比 Coze 与 Node.js 自研。配图已上传 OSS(与本地 assets/agent-workflow/*.png 同源文件名,便于你用 img 脚本覆盖更新)。

本文结构(按需跳读)

部分 内容
概念与需求 智能体、工作流、为何不能一次调模型、串行链、mermaid
设计四步 只在脑子里/文档里「生成」草图,不涉及 Coze 点击路径
实践一 Coze 工作流画布:节点类型、从「开始」到「结束」的配置与截图
实践二 发布工作流创建智能体 → 编排里 挂工作流 → 预览与 发布智能体(截图)
收尾 Coze 优缺点、与自研关系、全文小结

智能体是什么:不止「和大模型聊一句」

智能体(Agent)在经典定义里,是能感知环境做决策再行动的系统。落到今天的大模型应用上,可以把它理解成:

以模型为「大脑」之一,再叠上检索、工具调用、业务规则、多模态输出等能力,按固定或可变策略运转,最终对用户给出一个完整结果单元(而不只是一段即时回复)的那一层产品形态。

用户输入、系统提示词、中间调用的搜索与 TTS 等,都是「环境」与「指导信息」;智能体要做的,是在这些约束下把多步事情办完。


工作流是什么:把能力排成一条(或多条)流水线

智能体里真正承担业务骨架的,是 工作流(Workflow):把「分析意图 → 查资料 → 写稿 → 润色 → 念出来」这类步骤,变成可执行、可观测、可迭代的节点图。

  • 串行工作流:上一步输出是下一步输入,适合故事生成这类主线清晰的任务。
  • 并行与分支:实际业务里常有「同时查多个源」「某步失败则降级」等,图会变复杂;入门阶段先把一条串行链画清楚,价值最大。

一句话:智能体是「产品视角」的说法,工作流是「工程视角」的实现方式。


用「6~8 岁睡前故事」理解:为什么不能只调一次文本模型

假设产品需求是:

  1. 用户给一个故事主题;尽量讲经典民间故事,没有经典则围绕主题创作
  2. 内容与语言要符合 6~8 岁认知,不「超龄」。
  3. 最后用亲切的语音把故事念出来。

若只做 chat.completions 一次调用,模型既可能胡编典故,又无法引用可靠原文,更没有声音。因此这个应用本质上需要多能力组合:

能力 作用
搜索 / 检索 找到故事原文或参考资料(常与 RAG / 检索增强生成 一起讨论)
写作与润色 在检索结果上写草稿,再按儿童口吻改写
语音合成(TTS) 把定稿文本变成可播放音频

这些能力不会自动长在一起,要靠你在产品里编排顺序、约定每步输入输出。这就是工作流要解决的问题。


核心工作流长什么样(串行示例)

把上面的需求压成一条链,可以是:

输入主题 → 生成检索 query → 搜索并整理材料 → 撰写草稿 → 语言与风格润色 → 语音合成 → 输出(文本 + 音频)

用流程图表示更直观:

flowchart LR
  A[用户主题] --> B[生成搜索 query]
  B --> C[搜索 / 整理]
  C --> D[写草稿]
  D --> E[儿童向润色]
  E --> F[TTS]
  F --> G[文本 + 语音]

实现顺序上的建议:先在纸上或文档里画出这条链,标清每一步的输入输出数据结构;再决定用 Coze 拖拽,还是用代码(例如 Node.js)写「调度器」。顺序对了,换工具只是换壳。


设计阶段的四步清单(还不打开 Coze 也能做)

你可以把下面四步当作任意业务的模板,先在文档或白板完成;它们回答的是「做什么」,而不是「在 Coze 里点哪个菜单」:

  1. 定义成功态:用户最终拿到什么?(一段 JSON、一篇带出处的文章、一条语音……)
  2. 拆能力:需要模型、搜索、数据库、支付、TTS 中的哪几项?哪些可以合并成一步?
  3. 定依赖与顺序:哪一步必须等上一步结束?哪一步可以并行?失败时是否重试或降级?
  4. 选承载:原型期用 Coze 等低代码快速验证;上线前再评估是否迁到 自研编排(数据隐私、细粒度调试、成本结构)。

做到这里,你已经「生成」了智能体的设计稿。接下来两节是落地:先在 Coze 里把 工作流画布 跑通,再 发布挂到智能体


实践一:在 Coze 里搭「睡前故事」工作流(画布)

扣子 Coze 提供了工作流编排:用节点把大模型、循环、插件等连成图,适合快速验证「这条链跑不跑得通」。下面配图来自同一套「睡前故事」示例画布,界面以你当前 Coze 版本为准;若菜单文案略有差异,对照节点职责即可。

节点类型与添加路径(和本文截图一致)

在 Coze 工作流画布上点 「添加节点」 时,可按下面方式选类型(不同版本菜单层级可能微调,核心是节点类型要对):

画布上的职责 添加节点时的选择 说明
开始 / 结束 无需添加 新建工作流后画布默认自带;只需配置入参、出参。
生成 query、撰写草稿、润色 大模型 三处都是「大模型」节点,分别改节点标题与提示词、输入输出即可。
搜索并整理内容(外层) 循环 先加循环节点,再在循环体内部加搜索用的插件节点。
循环体内的搜索 插件 → 必应搜索 每次迭代用当前 query 调必应,把结果汇总给后续大模型。
语音合成 插件 → 搜索文本转语音 将润色后的正文交给插件生成音频(插件名以控制台为准)。

下文按数据从左到右的顺序讲配置要点;你在菜单里选对的节点类型,就和「一步步实现」对上了。

画布总览:一条从主题到「文本 + 语音」的链

整体从左到右大致是:开始(默认)→ 生成 query(大模型)→ 搜索并整理内容(循环,循环体内必应搜索)→ 撰写草稿(大模型)→ 润色(大模型)→ 语音合成(插件:搜索文本转语音)→ 结束(默认)。多模态输出在「结束」节点里一次性返回给上层 Bot 或 API。

Coze 工作流画布总览:睡前故事智能体

1. 新建工作流

进入 Coze 控制台 → 工作空间资源库 → 新建 工作流,例如命名为 bedtime_story,描述写清「给 6~8 岁孩子讲睡前故事」。进入画布后,「开始」与「结束」是默认节点,不必在「添加节点」里再选一次;后面所有节点都是从「开始」往后串、最后收进「结束」。

「开始」节点:声明工作流对外的入参。示例里只暴露一个字符串 input(故事主题),后续大模型节点通过模板变量 {{input}} 引用。

开始节点:配置入参 input

2. 第一个大模型节点:从主题到「检索 query」

添加节点 → 大模型,将节点标题改为「生成 query」(名称可自定)。用于:根据用户输入分析意图,并输出一组搜索用 query(后续由循环消费)。

系统提示词可围绕「目标 + 分析方法 + 任务」来写,例如(节选思路):

  • 若主题是常见民间故事名,则生成便于检索原文的 query;
  • 否则结合文化背景生成能搜到参考资料的 query;
  • 明确输出格式要求(如字符串数组)。

用户提示词里使用 Coze 的模板变量,把「开始」节点的输入接进来,例如:

{{input}}

双花括号中的名字需与开始节点里定义的输入字段名一致(默认常为 input)。

输出变量建议配置两个(示例命名):

输出名 类型 含义
querys 字符串数组 多条检索 query
intent 字符串 对用户意图的简短概括

这里 intent 未必被后续节点消费,但让模型多输出一个「对自己有用」的字段,往往能起到链式思考(chain-of-thought)外显的效果,有助于提高 querys 质量——这是很多工作流里的小技巧。

联调小技巧:开发时可以把该节点输出直接连到 结束 节点,在结束节点里配置要暴露的变量,先验证「query 生成」是否稳定,再往下接搜索与写作。

下图可见:模型选用「豆包·2.0·pro」等;输入绑定「开始 → input」;输出解析为 JSON 字段(如 intentquerys 数组),供下一节点消费。

生成 query 节点:系统提示词、用户侧 {{input}}、JSON 输出 intent / querys

3. 循环 + 必应搜索:对多条 query 逐个检索

因为 querys 是数组,在「生成 query」后面 添加节点 → 循环;外层循环节点标题可写成「搜索并整理内容」一类,便于读图。

  • 循环类型:选「使用数组循环」;循环数组绑定上一大模型节点的 querys
  • 循环体内部:再点 添加节点 → 插件 → 必应搜索(或你工作区里可用的等价联网搜索插件)。每次迭代把当前元素映射为搜索的 querycount 控制条数;输出里的 data 等字段供循环汇总。
  • 输出映射:界面上常有经验顺序——先在循环体里把「必应搜索」节点接好、跑通,再回来配置循环节点对外的输出数组(否则没有可引用的中间结果)。

循环节点:数组绑定「生成 query → querys」,输出汇总检索结果

循环体内插件「必应搜索」:query 来自循环、count 控制返回条数

若不需要循环内的临时中间变量,可在 Coze 里按界面提示精简变量,避免图越来越乱。

4. 撰写草稿 → 润色(大模型)→ 语音合成(插件)→ 结束(默认)

在循环之后,把整理后的检索结果交给两个连续的大模型节点做「写稿 + 润色」,最后用插件出音。

撰写草稿添加节点 → 大模型。输入侧接入「搜索并整理内容」汇总后的材料;系统提示词约束「6~8 岁、经典尽量忠于原文」等;用户提示词用模板 参考资料:{{input}} 把变量喂进模型;输出 output(及可选 reasoning_content)供下一步使用。

撰写草稿节点:大模型,输入检索整理结果

润色:同样 添加节点 → 大模型。输入接 撰写草稿 → output;系统提示词切换为「温柔大姐姐给妹妹讲睡前故事」等人设;用户侧 故事材料:{{input}};输出仍为字符串 output

润色节点:大模型,承接草稿 output

语音合成添加节点 → 插件 → 搜索文本转语音(若控制台插件名称有细微差别,以实际列表为准)。将正文字段绑定 润色 → output;并按插件面板填写音色、cluster(如 volcano_tts)、app_id / app_token 等;输出里常见 link 指向生成音频 URL。

语音合成节点:插件「搜索文本转语音」,文本来自润色 output

结束:使用画布默认的「结束」节点即可;在配置里选 「返回变量」:例如 text 映射润色后的正文,audio 映射语音合成返回的 link(或平台等价字段),这样上层一次拿到「可读文本 + 可播音频」。

结束节点:返回变量 text(润色)与 audio(语音 link)

每一段的输入输出变量名要与前后节点对齐;逻辑顺序应与上文「核心工作流」示意图一致——你在 Coze 里是在「画图实现」同一张设计稿。


实践二:发布工作流,并挂到「对话智能体」

画布上的 工作流 解决「一条链怎么跑」;智能体(Bot) 解决「用户怎么对话触发这条链」。建议顺序:试运行并发布工作流 → 在资源库 创建智能体编排 → 技能 → 工作流 里添加已发布的工作流 → 预览与调试 验证触发与入参 → 发布智能体

试运行与发布工作流

bedtime_story(或你的工作流名)编辑页里先 试运行,确认「开始 → … → 结束」整条链无报错后,使用平台提供的 发布(或「上线」类)能力,把工作流变为 已发布 状态。只有发布后,智能体侧「添加工作流」列表里才容易稳定搜到它(具体按钮名称以 Coze 当前版本为准)。

在资源库创建智能体

进入 工作空间 → 资源库,右上角 「+ 创建」,选择 「创建智能体」(适用于对话式智能体)。

资源库中创建智能体入口

填写名片并确认

在弹窗里选 标准创建(或你需要的创建方式),填写 智能体名称功能介绍工作空间图标 等。示例中与睡前故事一致:儿童睡前故事 / 给 6-8 岁儿童讲睡前故事 等。点 确认 进入编排页。

创建智能体:名称、介绍、空间与图标

编排里挂载工作流技能

打开 编排,在 技能 区域找到 工作流 一行,点击右侧 「+」(提示为 添加工作流)。在列表中选中已发布的 bedtime_story,点 添加,把它挂到当前智能体上。这样用户发一句自然语言时,智能体才会按策略去 调用 你刚编排好的那条链。

编排页:技能 → 工作流 → 添加工作流

添加工作流弹窗:选择已发布的 bedtime_story

预览调试并发布智能体

右侧 预览与调试 里直接输入用户会说的主题(例如 「狼来了」)。若编排正确,应能看到 正在调用 bedtime_story 一类状态,并走完整条工作流(含你结束的 text / audio 等返回)。确认满意后,再在平台里 发布智能体,对外分享或接入渠道。

预览与调试:用户输入触发 bedtime_story 工作流

与「实践一」的关系:工作流 = 可复用的业务链;智能体 = 对话壳 + 默认模型 + 挂载的一条或多条工作流。先发布链,再把链挂到 Bot 上,用预览验证「用户一句话 → 工作流入参 input」是否对齐。


Coze 工作流的优缺点:适合当「哪一级」

优点

  • :复杂分支、流式输出、插件生态都能较快搭出可演示版本。
  • 省成本:适合创业者、团队做原型验证与需求对齐。
  • 可视化:非纯研发也能参与讨论「第几步该干什么」。

局限

  • 平台绑定:流程与数据多在 Coze 侧,对强隐私、强合规、专有部署的场景要慎重。
  • 节点内部偏黑盒:要做极致的数据结构优化、细粒度耗时分析时,不如代码透明。

因此常见节奏是:Coze 验证工作流是否合理 → 定型后用 Node.js(或其它后端) 把同一条 DAG 写成可维护的服务(与本仓库 server.js 编排多厂商 API 的思路一致)。理解「工作流原理」之后,换承载并不神秘。


小结:从草图到可对话的一条龙

  1. 先定义成功态与边界(对应上文「设计四步」前两条):用户拿什么结果、年龄与体裁等约束。
  2. 再画工作流(设计四步后两条 + mermaid):拆能力、定顺序、选承载。
  3. 实践一:Coze 画布:「大模型 → 循环 + 必应 → 大模型 ×2 → 搜索文本转语音」,开始/结束用默认节点。
  4. 实践二:上架:发布工作流 → 创建智能体 → 编排 → 技能 → 工作流 挂载 → 预览 → 发布智能体。
  5. 再决定要不要自研:原型通过后,用 Node.js 等复刻同一条 DAG(见本仓库 server.js 一类编排)。

智能体不是「多调几次模型」的代名词,而是**「多步能力 + 清晰编排」**的产物;设计稿 → 工作流画布 → 对话壳挂载 走完,就是从想法到可演示产品的完整一程。

让 Claude Code 在你睡觉时持续运行:完整实战指南

作者 唐巧
2026年4月15日 13:44

让 Claude Code 在你睡觉时持续运行:完整实战指南

Claude Code 可以通过 -p 标志、权限绕过、循环模式和终端持久化的组合,实现数小时甚至整夜的无人值守运行。 开发者社区已经形成了一套可靠的操作手册:容器化运行环境、使用 “Ralph Wiggum” 循环模式、安装四个关键 Hook 防止卡死、保持 CLAUDE.md 精简。有开发者记录了 27 小时连续自主会话完成 84 个任务;另一位在睡觉时让 Claude 构建了一个 15,000 行的游戏。但社区也反馈,大约 25% 的过夜产出会被丢弃,而且如果没有适当的防护措施,Claude 曾在至少一位开发者的机器上执行过 rm -rf /。以下是你今晚就能用上的完整设置方案。


一、消除人工干预的三种模式

Claude Code 提供三个级别的自主运行模式,每个级别都在安全性和速度之间做取舍。理解它们是所有过夜方案的基础。

模式 1:-p(print/pipe)标志 —— 所有自动化的核心。 这是非交互式运行模式。接收 prompt,执行到完成,输出到 stdout,然后退出。无需 TTY,512MB 内存的服务器也能跑。

1
claude -p "查找并修复 auth.py 中的 bug" --allowedTools "Read,Edit,Bash"

模式 2:--permission-mode auto —— 更安全的折中方案。 2026 年初推出,使用 Sonnet 4.6 分类器自动批准安全操作,同时阻止高风险操作。分类器分两阶段运作:快速判定(8.5% 误报率),对标记项目进行思维链推理(0.4% 误报率)。如果连续 3 次操作被拒绝或单次会话累计 20 次被拒,系统会升级到人工介入——或者在 headless 模式下直接终止。

1
claude --permission-mode auto -p "重构认证模块"

模式 3:--dangerously-skip-permissions —— 完全绕过权限。 所有操作无需确认直接执行。Anthropic 自己的安全研究员 Nicholas Carlini 也使用这个模式,但有一个关键前提:*”在容器里跑,不要在你的真实机器上。”* 一项调查发现 32% 的开发者使用这个标志时遭遇了意外的文件修改,9% 报告了数据丢失

1
2
# 仅限 Docker/VM —— 绝对不要在宿主机上运行
claude --dangerously-skip-permissions -p "构建这个功能"

推荐的过夜运行方式是将 -p 与细粒度工具白名单 --allowedTools 结合使用,允许特定命令而非授予全面访问权限:

1
2
3
4
claude -p "修复所有 lint 错误并运行测试" \
--allowedTools "Read" "Edit" "Bash(npm run lint:*)" "Bash(npm test)" "Bash(git *)" \
--max-turns 50 \
--max-budget-usd 10.00

--max-turns--max-budget-usd 是无人值守会话的必备成本控制手段。没有它们,一个失控的循环可以在几分钟内烧光你的 API 预算。


二、Ralph Wiggum 循环:开发者的实际过夜方案

最经过实战验证的长时间自主工作模式是 Ralph Wiggum 循环——以《辛普森一家》中的角色命名,现已成为 Anthropic 官方插件。概念非常简单:一个 bash while 循环持续向 Claude 喂相同的 prompt。每次迭代中,Claude 查看当前文件状态和 git 历史,选择下一个未完成的任务,实现它,然后提交。

1
2
3
4
5
while true; do
claude --dangerously-skip-permissions \
-p "$(cat PROMPT.md)"
sleep 1
done

那位记录了 27 小时会话 的开发者使用了这个模式,配合一个详细的 prompt 文件,包含架构说明、目标、约束条件和明确的”完成”标准。他的核心发现:*”一句话 prompt 在一两个小时后就没劲了。27 小时的会话能持续下去,是因为 prompt 文件有足够多的上下文。”*

Prompt 文件比循环本身更重要。 一个有效的过夜 PROMPT.md 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 任务:测试并加固认证系统

## 上下文
- 后端:Express + TypeScript,位于 src/api/
- 数据库:PostgreSQL,schema 在 prisma/schema.prisma
- 认证流程:JWT 中间件在 src/middleware/auth.ts

## 目标
- 查看 docs/plan.md,选择下一个未完成的任务
- 实现它,包含完善的错误处理
- 运行测试,修复失败,确认没有回归
- 做通用修复,不要打临时补丁
- 每完成一个任务后用描述性消息提交

## 成功标准
- 每次修改后所有测试通过
- 不会引入之前修复的回归
- 当 plan.md 中所有任务完成后输出 DONE

社区有几个工具扩展了这个基础循环。Ralph CLI 增加了速率限制(100次调用/小时)、熔断器、会话过期(默认24小时)和实时监控仪表板。Nonstop 增加了飞行前风险评估和阻塞决策框架——走之前输入 /nonstop 即可。Continuous-claude 自动化完整 PR 生命周期:创建分支、推送、创建 PR、等待 CI、合并。


三、防止过夜灾难的四个 Hook

开发者 yurukusa 记录了 108 小时无人值守运行,识别出七类过夜事故——包括 Claude 执行 rm -rf ./src/、进入无限错误循环、直接推送到 main 分支,以及产生每小时 8 美元的 API 费用。解决方案:四个关键 Hook,共同预防最常见的故障模式。

10 秒快速安装:

1
npx cc-safe-setup

Hook 1:No-Ask-Human 阻止 AskUserQuestion 工具调用,强制 Claude 自主做出决定,而不是坐在那里等几小时等人回复。这个 Hook 决定了 Claude 是整夜工作还是在晚上 11:15 卡住。在你坐在电脑前时,用 CC_ALLOW_QUESTIONS=1 覆盖。

Hook 2:Context Monitor 将工具调用次数作为上下文使用量的代理指标,在四个阈值(剩余 40%、25%、20%、15%)发出分级警告。在临界水平时,配套的空闲推送脚本会自动向终端注入 /compact 命令——两个进程,共 472 行代码,零人工干预

Hook 3:Syntax Check 在任何文件编辑后立即运行 python -m py_compilenode --checkbash -n,在错误级联成 50 次调试之前就捕获它们。

Hook 4:Decision Warn 在执行前标记破坏性命令(rm -rfgit reset --hardDROP TABLEgit push --force)。通过 CC_PROTECT_BRANCHES="main:master:production" 配置受保护分支。

.claude/settings.json 中配置:

1
2
3
4
5
6
{
"permissions": {
"allow": ["Bash(npm run lint:*)", "WebSearch", "Read"],
"deny": ["Read(.env)", "Bash(rm -rf *)", "Bash(git push * main)"]
}
}

四、tmux 设置与保持机器不休眠

Claude Code 的交互模式需要 TTY —— 不能用 nohup 或将其作为 systemd 服务运行(大约 15-20 秒后会因 stdin 错误崩溃)。tmux 是会话持久化的必备工具

1
2
3
4
5
6
7
8
9
10
11
12
13
# 启动命名会话
tmux new -s claude-work

# 在其中启动 Claude
claude --permission-mode auto

# 分离(Claude 继续运行):Ctrl+B,然后按 D

# 从任何地方重新连接(SSH、手机 Termius 等)
tmux attach -t claude-work

# 不连接就查看进度
tmux capture-pane -t claude-work -p -S -50

对于真正的 7×24 运行,社区推荐 VPS + Tailscale + tmux 方案:便宜的 VPS(Hetzner、Vultr、DigitalOcean)提供永不关机的算力,Tailscale 提供私有网络,mosh 在不稳定网络上保持连接持久性。给 Claude 一个任务,分离,合上笔记本,明天再回来。

macOS 防止休眠:

1
2
3
4
5
# 绑定到 Claude 进程
caffeinate -i -w $(pgrep -f claude) &

# 或者在接通电源时全局禁用休眠
sudo pmset -c sleep 0

管理多个并行会话方面,Amux 是一个约 12,000 行的 Python 文件,提供 Web 仪表板、手机 PWA 监控、自愈看门狗(自动重启崩溃会话)、按会话 token 追踪和 git 冲突检测。Codeman 提供类似的 Web UI,带 xterm.js 终端,支持最多 20 个并行会话。

一个强大的过夜 agent tmux 配置:

1
2
3
4
5
6
7
8
9
#!/bin/bash
tmux new-session -d -s claude-dev
tmux rename-window -t claude-dev:0 'Claude'
tmux new-window -t claude-dev:1 -n 'Tests'
tmux new-window -t claude-dev:2 -n 'Logs'
tmux send-keys -t claude-dev:0 'claude --permission-mode auto' Enter
tmux send-keys -t claude-dev:1 'npm run test:watch' Enter
tmux send-keys -t claude-dev:2 'tail -f logs/app.log' Enter
tmux attach-session -t claude-dev

五、CLAUDE.md 与长时间运行的上下文管理

过夜失败的最大原因是上下文窗口耗尽。Claude Code 的上下文窗口大约 200K token,使用率超过 70% 时性能开始下降。自动压缩在接近阈值时触发,但会丢失信息——仅保留 20-30% 的细节。有开发者报告 Claude 压缩后遗忘了所有内容,重新开始同一个任务,浪费了三个小时。

解决方案是检查点/交接模式,能够在上下文重置后存活:

1
2
3
4
5
6
# 在 CLAUDE.md 中
当上下文变大时,将当前状态写入 tasks/mission.md。
包括:已完成的、下一步的、被阻塞的、未解决的问题。
错误处理:最多重试 3 次。如果没有进展,记录到
pending_for_human.md 然后转到下一个任务。
压缩前,务必保存完整的已修改文件列表。

将 CLAUDE.md 控制在 200 行以内——每个词在每个会话中都消耗 token。从 800 行切换到 100 行的开发者达成社区共识:更短的配置实际上表现更好,因为 Claude 不会忽略被噪音淹没的指令。使用”仅在不可逆时才提问”规则,将提问频率降低约 80%:

1
2
3
4
5
6
# 自主运行的决策规则
- 技术方案不确定 → 选择传统方案
- 两种可行实现 → 选择更简单的那个
- 尝试 3 次后仍有错误 → 记录到 blocked.md,切换任务
- 需求模糊 → 应用最合理的理解,记录假设
- 永远不要提问。做出最佳判断然后继续。

CLAUDE.md 文件是分层的:~/.claude/CLAUDE.md(全局)、./CLAUDE.md(项目级,git 追踪)、.claude/CLAUDE.local.md(个人覆盖,gitignore)。自主运行时,全局文件保持最小,把运行特定指令放在项目文件中。

关键 token 节省技巧:在里程碑后主动使用 /compact,而非等待自动压缩;对独立任务使用子 agent(每个有自己的上下文窗口);不相关的工作启动新会话;积极使用 .claudeignore 排除无关文件。


六、过夜运行的速率限制处理

速率限制作为三个独立的、重叠的约束运作:每分钟请求数、每分钟输入 token 数、每分钟输出 token 数。一个可见的命令在内部可能产生 8-12 个 API 调用(lint、修复、测试、修复循环)。15 次迭代后,单个请求可能发送 20 万+ 输入 token

过夜运行速率限制生存策略:

在非高峰时段运行。 Anthropic 确认工作日太平洋时间早 5 点到 11 点限制更严格。过夜运行和周末会话完全避开高峰期限流——恰好就是你在睡觉的时候。

利用 Ralph 循环的内置重试。 运行 while 循环时,速率限制错误只会导致当前迭代失败,但循环不在乎——它在速率限制窗口重置后的下一次迭代中重试。有开发者警告:*”不要在 API/按用量计费模式下运行——重试会烧光你的预算。”*

运行中切换模型。 Sonnet 能处理 60-70% 的常规任务,每 token 成本比 Opus 低约 1.7 倍。过夜工作设置 --model sonnet,将 Opus 留给复杂推理。也可以设置 --fallback-model sonnet,让 Claude 在主模型过载时自动降级。

Token 消耗的真实数据:20 条消息会话消耗约 105,000 token;30 条消息会话跳到 232,000 token。大约 98.5% 的 token 花在重新读取对话历史——只有 1.5% 用于实际输出。这就是为什么全新会话和积极压缩如此重要。

成本估算:持续运行 Sonnet 大约 $10.42/小时。基于 cron 每 15 分钟运行一次的 agent,预计约 $48/天。使用 --max-budget-usd 作为硬上限。


七、CI/CD 流水线与 Cron 任务集成

对于计划性的自动化工作,Claude Code 可直接与 CI/CD 系统集成。官方 GitHub Action 是 anthropics/claude-code-action@v1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "审查这个 PR 的安全和代码质量问题。"
claude_args: "--max-turns 5 --model claude-sonnet-4-6"

对于基于 cron 的自主 agent,Boucle 模式通过 state.md 文件在运行之间维持状态:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# run-agent.sh —— 由 cron 调用
STATE="$HOME/agent/state.md"
LOG="$HOME/agent/logs/$(date +%Y-%m-%d_%H-%M-%S).log"

claude -p "你是一个自主 agent。读取你的状态,决定做什么,
然后用你学到的内容更新 state.md。
$(cat $STATE)" \
--allowedTools Read,Write,Edit,Bash \
--max-turns 20 \
--max-budget-usd 1.00 \
--bare 2>&1 | tee "$LOG"
1
2
# crontab -e
0 * * * * /path/to/run-agent.sh

200 次迭代后的关键教训:state.md 必须保持在 4KB 以下(它会被注入每个 prompt),使用结构化键值对而非散文,并添加文件锁防止重叠运行。每次迭代后 git commit——git log 就是你最好的调试工具。

CI 环境使用 --bare 模式(跳过 hook、MCP 服务器、OAuth 和 CLAUDE.md 加载,最快最可复现的执行方式)和 --permission-mode dontAsk(拒绝所有未显式允许的操作——自动化环境中最安全的模式)。


八、已知陷阱与可能出错的地方

社区已广泛记录了以下故障模式:

故障模式 后果 预防方法
破坏性命令 Claude 运行 rm -rfgit reset --hard 或覆盖生产数据 PreToolUse hook 阻止危险命令;Docker 配合 --network none
无限错误循环 修复 → 测试 → 同样错误 → 修复 → 重复 20+ 次 CLAUDE.md 规则:”最多重试 3 次,然后记录到 blocked.md 继续下一个”
压缩后上下文丢失 Claude 遗忘一切,重新开始同一任务 压缩前将状态写入 mission.md;使用 Ralph 循环获得全新上下文迭代
权限提示阻塞 会话无限期挂起等待人工输入 No-Ask-Human hook;--dangerously-skip-permissions--permission-mode auto
直接推送到 main 未测试的代码部署到生产环境 分支保护规则;PreToolUse hook 阻止 git push 到受保护分支
API 成本失控 子 agent 进入循环调用外部 API($8/小时) --max-budget-usd;速率限制 hook;熔断器
OAuth token 过期 中途打断自主工作流 所有自动化使用 ANTHROPIC_API_KEY 环境变量而非 OAuth
订阅 ToS 违规 用 Pro/Max 订阅(非 API key)的 headless 模式可能违反消费者条款 自动化/脚本使用务必用 ANTHROPIC_API_KEY

最重要的单一安全措施是容器化。多位经验丰富的开发者独立推荐使用带网络隔离的 Docker:

1
2
3
4
5
docker run -it --rm \
-v $(pwd):/workspace -w /workspace \
--network none \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
claude-code:latest --dangerously-skip-permissions -p "$(cat PROMPT.md)"

正如一位开发者所说:*”用 --dangerously-skip-permissions 运行 Claude Code 就像不做防护措施。所以用个套… 我是说容器。”*


九、今晚的快速启动清单

15 分钟设置过夜自主运行:

  1. 创建 git 检查点git add -A && git commit -m "pre-autonomous checkpoint"
  2. 安装四个关键 Hooknpx cc-safe-setup
  3. 编写 PROMPT.md,包含架构上下文、任务列表、成功标准,以及每完成一个任务就提交的指令
  4. 启动 tmux 会话tmux new -s overnight
  5. 防止休眠(macOS):caffeinate -s &
  6. 启动循环
1
2
3
4
5
6
7
8
while true; do
claude -p "$(cat PROMPT.md)" \
--allowedTools "Read" "Edit" "Bash(npm run *)" "Bash(git *)" \
--max-turns 30 \
--max-budget-usd 5.00 \
--permission-mode acceptEdits
sleep 2
done
  1. 分离 tmuxCtrl+B,然后按 D
  2. 去睡觉

早上起来:tmux attach -t overnight,然后查看 git log(git log --oneline)看 Claude 完成了什么。预计保留大约 75% 的产出,丢弃 25%。这很正常——正如一位开发者说的,*”不是完美,甚至不是最终版,但是在前进。”*

单例模式渐进式学习指南

作者 Devin_chen
2026年4月15日 16:22

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

单例写法:始终返回同一个对象

function createSingleUserStore() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        name: 'frontend-store',
      }
    }

    return instance
  }
}

const getStore = createSingleUserStore()

const store1 = getStore()
const store2 = getStore()

console.log(store1 === store2) // true

这段代码发生了什么?

核心在这里:

let instance = null

它会把第一次创建出来的对象缓存起来。

后续再调用时:

  • 如果 instance 不存在,就创建
  • 如果 instance 已存在,就直接返回

于是无论调用多少次,拿到的都是同一个对象。

一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。


四、单例模式的标准结构

虽然前端里未必真的写“类”,但你最好知道它的标准思想。

结构拆解

一个典型的单例通常包含 3 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  3. 访问入口:外部通过统一方法获取实例

用类的方式理解

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance
    }

    this.data = '唯一实例'
    Singleton.instance = this
  }
}

const s1 = new Singleton()
const s2 = new Singleton()

console.log(s1 === s2) // true

更推荐前端中理解成“模块级唯一对象”

在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。

// config.js
const config = {
  apiBaseUrl: 'https://api.example.com',
  timeout: 5000,
}

export default config
// a.js
import config from './config.js'

// b.js
import config from './config.js'

因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。

这也是前端里最常见、最自然的“单例感”来源。


五、前端中常见的单例场景

这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题

1. 全局消息提示(Message / Toast)

很多 UI 库里的全局提示本质就是单例。

class Message {
  constructor() {
    this.queue = []
  }

  show(text) {
    this.queue.push(text)
    console.log('消息:', text)
  }
}

let messageInstance = null

export function getMessageInstance() {
  if (!messageInstance) {
    messageInstance = new Message()
  }

  return messageInstance
}

使用时:

const message1 = getMessageInstance()
const message2 = getMessageInstance()

message1.show('保存成功')
console.log(message1 === message2) // true

2. 全局弹窗管理器

如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。

单例的好处是:

  • 整个应用只维护一个弹窗容器
  • 状态统一管理
  • DOM 节点不会重复创建

3. 请求管理器 / API 客户端

比如你封装了一个请求实例:

class RequestService {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(url) {
    console.log(`GET: ${this.baseURL}${url}`)
  }
}

let requestInstance = null

export function getRequestService() {
  if (!requestInstance) {
    requestInstance = new RequestService('/api')
  }

  return requestInstance
}

这样做可以统一:

  • baseURL
  • 请求拦截器
  • token 注入
  • 错误处理策略

4. 缓存中心

const cache = {
  data: new Map(),
  set(key, value) {
    this.data.set(key, value)
  },
  get(key) {
    return this.data.get(key)
  },
}

export default cache

这本质上也是一个单例对象。

5. 状态共享对象

某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default store

所有页面共享同一份 store,这就是一种模块单例。


六、几种常见实现方式

方式一:闭包实现单例(最适合入门)

function createSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        id: Date.now(),
      }
    }
    return instance
  }
}

const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()

console.log(obj1 === obj2) // true
优点
  • 容易理解
  • 不依赖 class
  • 很适合讲清“缓存实例”的本质
缺点
  • 如果逻辑复杂,代码可维护性一般

方式二:类 + 静态属性

class LoginDialog {
  static instance = null

  constructor() {
    if (LoginDialog.instance) {
      return LoginDialog.instance
    }

    this.visible = false
    LoginDialog.instance = this
  }

  open() {
    this.visible = true
    console.log('登录弹窗打开')
  }
}

const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()

console.log(dialog1 === dialog2) // true
优点
  • 结构清晰
  • 更贴近传统设计模式写法
  • 适合面试表达
缺点
  • 对前端业务代码来说有时略显“重”

方式三:模块单例(现代前端最常见)

// auth-store.js
const authStore = {
  token: '',
  setToken(token) {
    this.token = token
  },
}

export default authStore
import authStore from './auth-store.js'
为什么它是单例?

因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。

优点
  • 写法最自然
  • 非常适合工程化项目
  • 不需要显式写 getInstance
缺点
  • 初学者可能“用了单例却没意识到自己在用单例”

方式四:惰性单例(Lazy Singleton)

惰性单例指的是:

不在一开始就创建实例,而是在第一次真正需要时才创建。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。

惰性单例的意义

  • 减少初始加载开销
  • 按需创建资源
  • 更适合弹窗、通知、复杂组件容器

七、单例模式的优点与缺点

任何设计模式都不是“银弹”,单例也一样。

优点

1. 节省资源

只创建一次对象,避免重复初始化。

2. 统一状态管理

所有地方访问的都是同一份实例,状态天然一致。

3. 便于全局协调

适合处理:

  • 全局配置
  • 全局弹窗
  • 全局缓存
  • 全局事件中心
4. 减少重复代码

不必每次都手动创建和管理相同对象。

缺点

1. 全局状态过多会让系统变复杂

一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。

2. 测试不友好

单例在测试中容易产生状态污染。

比如:

  • 上一个测试改了实例状态
  • 下一个测试拿到的还是同一个实例
  • 测试之间互相影响
3. 模块耦合增强

很多模块都依赖某个全局单例时,重构会变困难。

4. 容易被滥用

不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。


八、使用单例时的常见误区

误区 1:把普通工具函数也做成单例

比如一个纯函数工具库:

function formatDate(date) {
  return String(date)
}

这种函数没有状态,不需要单例。

没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。

误区 2:把“全局可访问”误认为“必须单例”

全局可访问 ≠ 必须只有一个实例。

比如:

  • 表单校验器可能每个表单都应该有独立实例
  • 图表对象可能每个图表容器都应该各自创建

误区 3:忽略实例重置能力

在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。

let instance = null

export function getInstance() {
  if (!instance) {
    instance = { count: 0 }
  }
  return instance
}

export function resetInstance() {
  instance = null
}

误区 4:把单例当作“解决一切共享问题”的万能方案

如果共享状态越来越复杂,应该考虑:

  • 状态管理库(Pinia / Redux / Zustand)
  • 依赖注入
  • 组合式函数(composables)
  • 上下文容器

单例是工具,不是宗教。


九、面试中怎么回答单例模式

如果面试官问:

你怎么理解单例模式?前端中有哪些应用?

你可以这样回答:

标准回答模板

单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。

在前端开发中,它常用于管理全局唯一资源,比如:

  • 全局弹窗
  • 消息提示组件
  • 请求实例
  • 缓存对象
  • 全局配置对象

实现方式通常有:

  • 闭包缓存实例
  • 类的静态属性保存实例
  • ES Module 天然单例

它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。

如果面试官继续追问:ES Module 算不算单例?

你可以回答:

在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。

如果继续追问:单例和全局变量有什么区别?

你可以回答:

  • 全局变量只是“所有地方都能访问”
  • 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”

所以单例比裸露的全局变量更有结构,也更便于维护。


十、练习题与思考题

练习 1:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 5 分钟写一下,再参考下面思路:

function createCacheSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        data: new Map(),
        set(key, value) {
          this.data.set(key, value)
        },
        get(key) {
          return this.data.get(key)
        },
      }
    }

    return instance
  }
}

练习 2:实现一个全局登录弹窗管理器

要求:

  • 整个应用中只能有一个登录弹窗实例
  • 支持 open()close()

练习 3:思考哪些场景不适合单例

请判断以下对象是否适合单例,并说明理由:

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

参考答案方向

对象 是否适合单例 原因
每个页面一个轮播图实例 不适合 每个轮播图通常是独立的
全局埋点上报管理器 适合 全局统一上报规则和缓存队列
每个表格一个筛选器对象 不适合 每个表格状态独立
全局请求客户端 适合 请求配置、拦截器应统一
每个图表一个图表实例 不适合 每个容器对应独立实例

十一、学习总结

你应该记住的 4 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一

git cherry-pick Command: Apply Commits from Another Branch

Sometimes the change you need is already written, just on the wrong branch. A hotfix may land on main when it also needs to go to a maintenance branch, or a useful commit may be buried in a feature branch that you do not want to merge wholesale. In that situation, git cherry-pick lets you copy the effect of a specific commit onto your current branch.

This guide explains how git cherry-pick works, how to apply one or more commits safely, and how to handle the conflicts that can appear along the way.

Syntax

The general syntax for git cherry-pick is:

txt
git cherry-pick [OPTIONS] COMMIT...
  • OPTIONS - Flags that change how Git applies the commit.
  • COMMIT - One or more commit hashes, branch references, or commit ranges.

git cherry-pick replays the changes introduced by the selected commit on top of your current branch. Git creates a new commit, so the result has a different commit hash even when the file changes are the same.

Cherry-Picking a Single Commit

Start by finding the commit you want to copy. This example lists the recent commits on a feature branch:

Terminal
git log --oneline feature/auth
output
a3f1c92 Fix null pointer in auth handler
d8b22e1 Add login form validation
7c4e003 Refactor session logic

The output gives you the abbreviated commit hashes. In this case, a3f1c92 is the fix we want to move.

Switch to the target branch before running git cherry-pick:

Terminal
git switch main
git cherry-pick a3f1c92
output
[main 9b2d4f1] Fix null pointer in auth handler
Date: Tue Apr 14 10:42:00 2026 +0200
1 file changed, 2 insertions(+)

Git applies the change from a3f1c92 to main and creates a new commit, 9b2d4f1. The subject line is the same, but the commit hash is different because the parent commit is different.

Cherry-Picking Multiple Commits

If you need more than one non-consecutive commit, pass each hash in the order you want Git to apply them:

Terminal
git cherry-pick a3f1c92 d8b22e1

Git creates a separate new commit for each one. This works well when you need a few targeted fixes but do not want the rest of the source branch.

For a range of consecutive commits, use the range notation:

Terminal
git cherry-pick a3f1c92^..7c4e003

This tells Git to include a3f1c92 and every commit after it up to 7c4e003. If you omit the caret, the starting commit itself is excluded:

Terminal
git cherry-pick a3f1c92..7c4e003

That form applies every commit after a3f1c92 through 7c4e003.

Applying Changes Without Committing

Sometimes you want the changes from a commit, but not an automatic commit for each one. Use --no-commit (or -n) to apply the changes to your working tree and staging area without creating the commit yet:

Terminal
git cherry-pick --no-commit a3f1c92

This is useful when you want to combine several small fixes into one commit on the target branch, or when you need to edit the files before committing.

After reviewing the result, create the commit yourself:

Terminal
git status
git commit -m "Backport auth null-check fix"

This gives you more control over the final commit message and lets you group related backports together.

Recording Where the Commit Came From

For maintenance branches and backports, it is often helpful to keep a reference to the original commit. Use -x to append the source commit hash to the new commit message:

Terminal
git cherry-pick -x a3f1c92

Git adds a line like this to the new commit message:

output
(cherry picked from commit a3f1c92...)

That extra line makes future audits easier, especially when you need to prove that a fix on a release branch came from a reviewed change on another branch.

Cherry-Picking a Merge Commit

Cherry-picking a regular commit is straightforward, but merge commits need one extra option. Git must know which parent to treat as the main line:

Terminal
git cherry-pick -m 1 MERGE_COMMIT_HASH
  • -m 1 - Use the first parent as the base, which is usually the branch that received the merge.
  • -m 2 - Use the second parent instead.

If you are not sure which parent is which, inspect the history first:

Terminal
git log --oneline --graph

Cherry-picking merge commits is more advanced and easier to get wrong. If the goal is to bring over an entire merged feature, a normal merge is often clearer than cherry-picking the merge commit itself.

Resolving Conflicts

If the target branch has changed in the same area of code, Git may stop and ask you to resolve a conflict:

output
error: could not apply a3f1c92... Fix null pointer in auth handler
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

Open the conflicted file and look for the conflict markers:

output
<<<<<<< HEAD
return session.getUser();
=======
if (session == null) return null;
return session.getUser();
>>>>>>> a3f1c92 (Fix null pointer in auth handler)

Edit the file to keep the final version you want, then stage it and continue:

Terminal
git add src/auth.js
git cherry-pick --continue

If you decide the commit is not worth applying after all, abort the operation:

Terminal
git cherry-pick --abort

git cherry-pick --abort puts the branch back where it was before the cherry-pick started.

A Safe Backport Workflow

When you cherry-pick onto a release or maintenance branch, slow down and make the source obvious. A simple workflow looks like this:

Terminal
git switch release/1.4
git pull --ff-only
git cherry-pick -x a3f1c92
git status

The important part is the sequence. Start from the branch that needs the fix, make sure it is up to date, cherry-pick with -x, then review and test the branch before pushing it. This avoids the common mistake of copying a fix into an outdated branch and shipping an untested backport.

If the picked commit depends on earlier refactors or new APIs that are not present on the target branch, stop there. In that case, either copy the prerequisite commits too or recreate the fix manually.

When to Use git cherry-pick

git cherry-pick is a good fit when you need a precise change without the rest of the branch:

  • Backporting a bug fix from main to a release branch
  • Recovering one useful commit from an abandoned feature branch
  • Moving a small fix that was committed on the wrong branch
  • Pulling a reviewed change into a hotfix branch without merging unrelated work

Avoid it when the target branch needs the full context of the source branch. If the commit depends on earlier commits, shared refactors, or schema changes, a merge or rebase is usually the cleaner option.

Troubleshooting

error: could not apply ... during cherry-pick
The target branch has conflicting changes. Resolve the files Git marks as conflicted, stage them with git add, then run git cherry-pick --continue.

Cherry-pick created duplicate-looking history
That is normal. Cherry-pick copies the effect of a commit, not the original object. The new commit has a different hash because it has a different parent.

The picked commit does not build on the target branch
The commit likely depends on earlier work that is missing from the target branch. Inspect the source branch history with git log and either cherry-pick the prerequisites too or reimplement the change manually.

You picked the wrong commit
If the cherry-pick already completed, use git revert on the new commit. If the operation is still in progress, use git cherry-pick --abort.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Pick one commit git cherry-pick COMMIT
Pick several specific commits git cherry-pick C1 C2 C3
Pick a consecutive range including the first commit git cherry-pick A^..B
Apply changes without committing git cherry-pick --no-commit COMMIT
Record the source commit in the message git cherry-pick -x COMMIT
Continue after resolving a conflict git cherry-pick --continue
Skip the current commit in a sequence git cherry-pick --skip
Abort the operation git cherry-pick --abort
Pick a merge commit git cherry-pick -m 1 MERGE_COMMIT

FAQ

What is the difference between git cherry-pick and git merge?
git cherry-pick copies the effect of selected commits onto your current branch. git merge joins two branch histories and brings over all commits that are missing from the target branch.

Does cherry-pick change the original commit?
No. The source commit stays exactly where it is. Git creates a new commit on your current branch with the same file changes.

Should I use -x every time?
Not always, but it is a good habit for backports and maintenance branches. It gives you a clear link back to the original commit.

Can I cherry-pick commits from another remote branch?
Yes. Fetch the remote branch first, then cherry-pick the commit hash you want. You can inspect the incoming history with git log or review the file changes with git diff before applying anything.

Conclusion

git cherry-pick is the tool to reach for when one commit matters more than the branch it came from. Use it for targeted fixes, keep -x in mind for backports, and fall back to merge or rebase when the change depends on broader branch context.

RN 的新模块系统 Turbo module

作者 Joyee691
2026年4月15日 15:50

本文所有代码如果没有特别标注的话,默认用的都是 v0.76.0 的 RN 代码

TurboModule 是什么

要讲清楚这个问题,我们要先从 bridge 架构的 Native module 系统开始说起,旧架构的 Native module 系统的调用流程大概是这样的:

JS code
  │
  │   UIManager.dispatchViewManagerCommand(tag, commandID, params)
  ▼
+----------------------+
| JS UIManager proxy   |
| (generated function) |
+----------------------+
  │
  │   enqueueNativeCall(moduleID, methodID, args)
  ▼
+----------------------+
|   BatchedBridge      |
|   call queue         |
+----------------------+
  │
  │   batch + flush
  ▼
+----------------------+
|    Bridge payload    |
| [moduleIDs]          |
| [methodIDs]          |
| [params]             |
+----------------------+
  │
  │   pass to native
  ▼
+----------------------+
|  Native bridge side  |
+----------------------+
  │
  ├─ lookup module by moduleID
  ├─ lookup method by methodID
  └─ convert args
  ▼
+----------------------+
|  Native UIManager    |
+----------------------+
  │
  ├─ resolve reactTag
  ├─ resolve command
  └─ dispatch
  ▼
+----------------------+
| ViewManager / View   |
+----------------------+
  │
  ▼
Actual native UI effect

首先 JS 在调用的时候需要知道有哪些模块,以及对应模块的方法,这些资讯是由宿主侧在初始化阶段注入的

当 JS 调用 Native module 时,JS 还有一层代理(proxy)层将模块方法的调用统一转换成 enqueueNativeCall 方法的调用

当这个方法进入 JS 侧 bridge 的时候,会被打包、序列化后传送给宿主侧的 bridge 由这边的 bridge 进行反序列化、解析后调用对应的原生模块方法;如果这个原生模块的方法存在回调,还需要把这个流程反过来一次,然后找到对应的 callID(JS 调用原生模块方法的编号)拿到对应的 callback 执行

这种调用模式会有什么问题呢?我们用一个表格来看看:

旧 Native Module 的架构问题 问题描述 TurboModule 怎么修复
以 Bridge 为中心 模块系统依赖跨桥消息模型,能力受限(强制异步、对高频/小细粒度调度不友好等等) 改为 JSI binding 为中心,使得模块可以像 JS runtime 中的本地对象能力被调用
接口契约松散 原生模块缺乏单一数据源约束,JS/Android/IOS 各自实现,三者依靠文档、约定、测试保持一致,长期容易造成平台不一致重构风险高团队协作成本上升等问题 用 spec 来约束类型 + Codegen 根据类型约束生成 C++/Android/IOS 侧脚手架文件强化契约,建立单一数据源
生命周期治理弱 旧原生模块系统虽然也支持懒加载,但它的生命周期依赖 bridge 上下文驱动,生命周期管理边界也十分分散 TurboModuleManager 统一按需创建和管理
支持两层缓存:模块实例、JS 方法属性按需加载
UI 模块和普通原生能力混杂 旧 Native Module 既承载普通能力模块,也承载像 UIManager 这类 UI 基础设施,导致 “能力模块” 和 “渲染/视图系统” 职责混杂,抽象层次不清晰 TurboModule 负责普通原生能力;Fabric 负责 UI/视图系统,从架构上完成职责拆分

基于这些区别,现在可以回答本章节标题的问题了:TurboModule system 是 RN 新架构中面向非 UI 原生能力的模块系统,它以 JS runtime 为中心,替代框架中以 bridge 为中心的 Native Module system;并通过更清晰的模块边界、按需创建和更强的接口契约支持 RN 架构长期演进

设计方案

新旧模块兼容方案

在进入架构设计前,我们需要先了解一些背景知识:

新的 TurboModule 不仅重写了整个 NativeModule 系统,还更改了之前的调用方式

在 Bridge 时代,NativeModule 的调用是这样子的:

import { NativeModules } from 'react-native';

NativeModules.Vibration.vibrate(500);

但是在 TurboModule 中,调用方式变成了:

import { TurboModuleRegistry } from 'react-native';

const NativeVibration = TurboModuleRegistry.getEnforcing('Vibration')
NativeVibration.vibrate(500)

个人猜测改变调用方式的动机有两个:

  1. 相比于之前分散的模块,新的方法按模块名从 TurboModule 系统取,更适合“按名称查找”和“按需解析”的新语义,且加强了原生模块之间的内聚性
  2. 考虑到原生模块的迁移成本,通过 TurboModuleRegistry 能更好的适配旧的原生模块调用方式,使得旧模块也能在新架构上继续使用

特别是第二点,RN 为了让新架构也能兼容旧写法的 Native module,设计了如下的兼容流程:

RN_legacy_vs_turboModule

说明一下:

  1. 当我们在新架构调用了 Vibration.vibrate(500) 方法后,会进入到对应的 spec 文件中
  2. spec 文件就是 typescript 编写的文件,它声明了原生模块的类型,并且会暴露一个 TurboModuleRegistry.getEnforcing('Vibration') 方法的返回值
  3. getEnforcing 方法中,会判断当前的模块是否为 TurboModule
  4. 【接步骤 3】如果是,则通过原生平台的 TurboModuleProvider 模块去查找对应实现(对应图中最右边的橘色正方形)
  5. 【接步骤 3】如果不是,则要先判断是否要启用兼容逻辑(判断依据是 useLegacyNativeModuleInterop 是否为 true,只有当前 RN 在 bridge 模式或者原生平台提供了 LegacyModuleProvider 才会返回 true)
  6. 【接步骤 5】如果不启用则直接返回 null,因为没有找到对应原生模块
  7. 【接步骤 5】如果启用了,就判断原生平台是否提供 LegacyModuleProvider,如果有则走 LegacyModuleProvider 的逻辑
  8. 【接步骤 7】如果没有就回退到 bridge 的 enqueueNativeCall 解决方案(对应到我们开篇的流程图)

至于 TurboModuleProviderLegacyModuleProvider 分别做了什么,以及 RN 是如何管理模块的生命周期,我们可以来看看下个章节的架构设计~

调用链路

由于 LegacyModuleProviderTurboModuleProvider 基本类似,区别在于 LegacyModuleProvider 需要兼容之前基于 Bridge 的原生模块,所以我们重点看一下新的 TurboModule 调用链路:

turboModule flow

TurboModule 的调用链路中总共有两个主要角色:Js runtime 以及 C++ 中的 TurboModuleBinding

看过本专栏 JSI 文章的读者应该知道,在新架构中的 XXXbinding 一般就是负责把各种能力挂到 global 对象上

整个调用链路分为两个部分:初始化阶段、调用阶段

在 RN 应用初始化过程中,会调用 TurboModuleBinding::install 方法,这个方法会做两件事:

  1. 往 global 挂 __turboModuleProxy 属性,这是一个 C++ 侧的方法,也是 JS 调用 turboModule 的入口
  2. 如果当前是 bridgeless 模式(也就是使用 JSI),会往 global 挂 nativeModuleProxy 属性,这也是一个 C++ 侧的方法,负责兼容老架构的原生模块

至此,初始化阶段就完成了,接下来进入调用阶段

调用的发起点是 JS 侧(对应图中左侧 JS runtime),总共分为两步:

  1. 获得 TurboModule 对应 JS 对象的引用(对应到图中左侧的 jsRepresentation,为了方便理解我们把它赋值给了 NativeVibration 这个更加语义化的变量)
  2. 从该引用取得方法然后调用

首先是第一步获取 TurboModule 对应 JS 对象的引用,我们可以通过 global.__turboModuleProxy 取得,这是一个可以通过 JSI 调用的 C++ 方法,最后会调用 TurboModuleBinding::getModule 方法,该方法会做四件事:

  1. 创建一个 JS 对象 jsRepresentation,用来存放后续所需 TurboModule 的实例
  2. 去宿主平台的 TurboModuleManager(IOSAndroid)取得对应 TurboModule 的实例
  3. 把 TurboModule 的实例放到 jsRepresentation.__proto__ 中,这是 TurboModule 系统实现模块按照方法/属性级别缓存的主要设计
  4. 最后,我们把 jsRepresentation 返回给 JS runtime,让 JS 持有这个对象的引用以便后续直接调用其中的方法

以上的步骤只有第一次的时候需要,后续由于 JS 已经持有了 jsRepresentation 的引用,所以 JS 直接通过这个对象访问所需方法即可~

源码解析

在这个小节,我们来看看上个小节的初始化以及调用阶段的具体代码实现

首先是初始化阶段,我们先来看看 TurboModuleBinding::install 是怎么实现的:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 这个方法会在应用初始化的时候被宿主平台各自实现的 TurboModuleManager 调用
// 目的是为了在 global 对象上挂载原生模块调用所需方法
void TurboModuleBinding::install(
    jsi::Runtime &runtime, TurboModuleProviderFunctionType &&moduleProvider,
    TurboModuleProviderFunctionType &&legacyModuleProvider,
    std::shared_ptr<LongLivedObjectCollection> longLivedObjectCollection) {
  // 挂载 __turboModuleProxy
  runtime.global().setProperty(
      runtime, "__turboModuleProxy",
    // 这是一个 C++ 方法
      jsi::Function::createFromHostFunction(
          runtime, jsi::PropNameID::forAscii(runtime, "__turboModuleProxy"), 1,
        // 传入了一个 lymbda,这个 lymbda 会在 JS 访问 global.__turboModuleProxy 时被调用
          [binding = TurboModuleBinding(runtime, std::move(moduleProvider),
                                        longLivedObjectCollection)](
              jsi::Runtime &rt, const jsi::Value &thisVal,
              const jsi::Value *args, size_t count) {
            // 检查参数的代码
            if (count < 1) {
              throw std::invalid_argument(
                  "__turboModuleProxy must be called with at least 1 argument");
            }
            std::string moduleName = args[0].getString(rt).utf8(rt);
            // 真正做事的方法
            return binding.getModule(rt, moduleName);
          }));

  // 因为 0.76.0 版本还需要兼容之前的 bridge 模式,所以会用 RN$Bridgeless 来标识现在用的是 JSI 架构的代码
  if (runtime.global().hasProperty(runtime, "RN$Bridgeless")) {
    bool rnTurboInterop = legacyModuleProvider != nullptr;
    auto turboModuleBinding =
        legacyModuleProvider ? std::make_unique<TurboModuleBinding>(
                                   runtime, std::move(legacyModuleProvider),
                                   longLivedObjectCollection)
                             : nullptr;
    auto nativeModuleProxy = std::make_shared<BridgelessNativeModuleProxy>(
        std::move(turboModuleBinding));
    defineReadOnlyGlobal(runtime, "RN$TurboInterop",
                         jsi::Value(rnTurboInterop));
    // 主要代码,目的是挂载 nativeModuleProxy 到 global 对象上
    defineReadOnlyGlobal(
        runtime, "nativeModuleProxy",
        jsi::Object::createFromHostObject(runtime, nativeModuleProxy));
  }
}

接下来我们来看看 getModule 做了什么:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 获得具体模块实现的方法,目标是最后返回一个带着模块实现的 JS 对象 jsRepresentation
jsi::Value TurboModuleBinding::getModule(jsi::Runtime &runtime,
                                         const std::string &moduleName) const {
  std::shared_ptr<TurboModule> module;
  {
    SystraceSection s("TurboModuleBinding::moduleProvider", "module",
                      moduleName);
// 调用双端实现的 TurboModuleProvider 来获取对应的模块实例
    module = moduleProvider_(moduleName);
  }
  if (module) {
    // 这里是第一层缓存
    // 如果这不是第一次获取该模块实例,我们可以从模块中找到上一次返回的 jsRepresentation 
    // 这里直接返回即可,无需再创建一次 jsRepresentation 对象
    auto &weakJsRepresentation = module->jsRepresentation_;
    if (weakJsRepresentation) {
      auto jsRepresentation = weakJsRepresentation->lock(runtime);
      if (!jsRepresentation.isUndefined()) {
        return jsRepresentation;
      }
    }

    // 如果是第一次获取该模块实例,我们就需要先创建一个空的 jsRepresentation 对象
    jsi::Object jsRepresentation(runtime);
    weakJsRepresentation =
        std::make_unique<jsi::WeakObject>(runtime, jsRepresentation);

    // ⚠️ 核心代码
    // jsRepresentation 解决的是 “实例属性的缓存问题”
    // 具体做法是:
    // 1. 第一次创建并返回的 jsRepresentation 是一个空对象,但是我们用原型链将其与模块实例关联起来
    // 2. 如果我们第一次调用了该模块的方法,会因为当前对象为空而去原型链找,于是就进入了模块原型的 get 方法
    // 3. 模块的 get 方法被触发会把对应的属性缓存在 jsRepresentation 的属性中
    // 4. 这样一来,后面如果我们再次调用同样的方法,就可以直接在 jsRepresentation 的属性中查找到,不需要再进行原型链查找了
    // 具体的实现等我们聊到 TurboModuleProvider 的实现会再进行分析
    auto hostObject =
        jsi::Object::createFromHostObject(runtime, std::move(module));
    jsRepresentation.setProperty(runtime, "__proto__", std::move(hostObject));

    // 把刚刚创建的对象返回给 JS
    return jsRepresentation;
  } else {
    // 如果找不到对应的模块,直接返回 null
    return jsi::Value::null();
  }
}

接下来,我们来看看神秘的 TurboModuleProvider 都做了什么~

TurboModuleProvider 是由平台自行实现,所以会有两套实现,我们先来看看 IOS 的实现:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

// 这个方法是 IOS 侧把 TurboModule 系统安装进 JS runtime 的入口
// 在这里 IOS 提供了 turboModuleProvider 以及 legacyModuleProvider 并在最后调用了上述 TurboModuleBinding::install 方法
- (void)installJSBindings:(facebook::jsi::Runtime &)runtime
{
  // 创建 turboModuleProvider,可以看到这是一个 lymbda
  // 当 getModule 需要查找模块的时候就会调用这个 lymbda
  // 留意这里的返回值是一个 TurboModule,这个跟后续的 legacyModuleProvider 是一致的
  // 也就是说,新旧原生模块的差异在这里被抹平了
  auto turboModuleProvider = [self,
                              runtime = &runtime](const std::string &name) -> std::shared_ptr<react::TurboModule> {
    auto moduleName = name.c_str();
// 性能埋点
    TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);
    // 判断当前模块是否初始化了,如果没有的话也要埋个性能埋点
    // 这里只记录 objc 实现的原生模块,因为 objc 原生模块的初始化过程比较复杂,这个我们后面会聊
    auto moduleWasNotInitialized = ![self moduleIsInitialized:moduleName];
    if (moduleWasNotInitialized) {
      [self->_bridge.performanceLogger markStartForTag:RCTPLTurboModuleSetup];
    }

    // 关键代码,真正获取模块的逻辑
    // 如果该模块已经初始化了会直接返回实例,否则会执行初始化流程
    auto turboModule = [self provideTurboModule:moduleName runtime:runtime];

    // 初始化性能埋点
    if (moduleWasNotInitialized && [self moduleIsInitialized:moduleName]) {
      [self->_bridge.performanceLogger markStopForTag:RCTPLTurboModuleSetup];
    }

    // 埋点,记录是否获取成功
    if (turboModule) {
      TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    } else {
      TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
    }
    // 返回一个模块实例
    return turboModule;
  };

  // 一个开关,如果在 bridgeless 模式下默认是开启的
  // 用来开启兼容旧模块的开关,在开启的情况下才会提供 legacyModuleProvider
  if (RCTTurboModuleInteropEnabled()) {
    // 这里的代码跟上面 turboModuleProvider 基本一致,区别只在于获取模块的方法变了
    // 这里变成了 provideLegacyModule
    auto legacyModuleProvider = [self](const std::string &name) -> std::shared_ptr<react::TurboModule> {
      auto moduleName = name.c_str();
      
      TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

      // 关键代码
      auto turboModule = [self provideLegacyModule:moduleName];

      if (turboModule) {
        TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
      } else {
        TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
      }
      return turboModule;
    };

    // 调用带有 legacyModuleProvide 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider), std::move(legacyModuleProvider));
  } else {
    // 调用只有 turboModuleProvider 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider));
  }
}

接下来我们就要进入 provideTurboModule 的具体实现(由于 provideLegacyModule 方法逻辑类似,这里就不额外说明了)

不过在看代码之前,我们需要先了解一些背景知识:TurboModule 内部实现到底有几种

  1. 纯 C++ 实现的 TurboModule:最 “纯” 的新架构模块,这也是最接近新架构设计的原生模块

  2. 全局导出的 C++ TurboModule:这个跟第一类其实一样,唯一的区别在于它俩的查找方式不同,上一个是通过 delegate 查找、它是靠 register 查找

  3. Objc 平台模块:这一类泛指所有基于 objc 实现的平台模块,细分下去可以分为三类:

    1. ObjC 实现的 TurboModule:这个指的是由 Objc 实现且实现了 getTurboModule 方法的模块
    2. legacy ObjC NativeModule:这个由 provideLegacyModule 负责处理
    3. RCTCxxModule:这个是在旧架构中使用 C++ 实现的模块,因为在旧架构中 C++ 模块需要 Objc 来中转,所以它在之前也被当成了 Objc 的模块

而 provideTurboModule 查找的顺序也是依据上面的顺序来的,下面我们看看具体代码:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

/**
 * provideTurboModule 方法接受一个模块名作为参数,然后查找并返回返回对应的 TurboModule 模块实例
 * 查找流程如下:
 * 1. 查 _turboModuleCache
 * 2. 查 delegate 提供的 pure C++ TurboModule
 * 3. 查 global exported C++ TurboModule map
 * 4. 再查 ObjC module
**/
- (std::shared_ptr<TurboModule>)provideTurboModule:(const char *)moduleName runtime:(jsi::Runtime *)runtime
{
  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   * _turboModuleCache 是一个 unordered_map,保存 “模块名” 到 “模块实例指针” 的映射
   */
  auto turboModuleLookup = _turboModuleCache.find(moduleName);
  if (turboModuleLookup != _turboModuleCache.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    int32_t moduleId = getUniqueId();
    TurboModulePerfLogger::moduleCreateStart(moduleName, moduleId);
    // 往 delegate 上找模块的实例
    auto turboModule = [_delegate getTurboModule:moduleName jsInvoker:_jsInvoker];
    if (turboModule != nullptr) {
      // 如果找到了就保存到缓存中
      _turboModuleCache.insert({moduleName, turboModule});
      TurboModulePerfLogger::moduleCreateEnd(moduleName, moduleId);
      // 然后返回实例
      return turboModule;
    }

    TurboModulePerfLogger::moduleCreateFail(moduleName, moduleId);
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto &cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(moduleName);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(_jsInvoker);
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 IOS 中就是 objc 模块
   */
  // 只有当 TurboModuleInterop 关闭或者当前模块是 TurboModule 的时候才会调用 _provideObjCModule 方法查找
  // legacyModule 不在这里处理,而是直接交给了 legacyModuleProvider
  // _provideObjCModule 会找到对应的模块,并且返回模块实例
  id<RCTBridgeModule> module =
      !RCTTurboModuleInteropEnabled() || [self _isTurboModule:moduleName] ? [self _provideObjCModule:moduleName] : nil;

  TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

  // 如果没有找到直接返回空指针
  if (!module) {
    return nullptr;
  }

  // 从模块实例找到对应的类
  Class moduleClass = [module class];

  // 找到模块需要的 queue
  dispatch_queue_t methodQueue = (dispatch_queue_t)objc_getAssociatedObject(module, &kAssociatedMethodQueueKey);
  if (methodQueue == nil) {
    RCTLogError(@"TurboModule \"%@\" was not associated with a method queue.", moduleClass);
  }

  // 根据 queue 创建 nativeMethodCallInvoker
  std::shared_ptr<NativeMethodCallInvoker> nativeMethodCallInvoker =
      std::make_shared<ModuleNativeMethodCallInvoker>(methodQueue);

  // 在 bridgeless 模式下没有 bridge,所以忽略
  // 这个方法主要是把 TurboModule 的 native method invoker 交给 RCTCxxBridge 包装一下,让 bridge 能感知 TurboModule 的异步 native 调用,从而维持 onBatchComplete 等旧 bridge 行为
  if ([_bridge respondsToSelector:@selector(decorateNativeMethodCallInvoker:)]) {
    nativeMethodCallInvoker = [_bridge decorateNativeMethodCallInvoker:nativeMethodCallInvoker];
  }

  // 处理 RCTCxxModule
  if ([moduleClass isSubclassOfClass:RCTCxxModule.class]) {
    // 直接用一个 TurboCxxModule 类包起来完事
    auto turboModule = std::make_shared<TurboCxxModule>([((RCTCxxModule *)module) createModule], _jsInvoker);
    // 还是一样放入 cache 中
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  // 最后我们来处理有 getTurboModul 方法的 objc 模块
  if ([module respondsToSelector:@selector(getTurboModule:)]) {
    ObjCTurboModule::InitParams params = {
        .moduleName = moduleName,
        .instance = module,
        .jsInvoker = _jsInvoker,
        .nativeMethodCallInvoker = nativeMethodCallInvoker,
        .isSyncModule = methodQueue == RCTJSThread,
        .shouldVoidMethodsExecuteSync = (bool)RCTTurboModuleSyncVoidMethodsEnabled(),
    };

    auto turboModule = [(id<RCTTurboModule>)module getTurboModule:params];
    if (turboModule == nullptr) {
      RCTLogError(@"TurboModule \"%@\"'s getTurboModule: method returned nil.", moduleClass);
    }
    _turboModuleCache.insert({moduleName, turboModule});

    if ([module respondsToSelector:@selector(installJSIBindingsWithRuntime:)]) {
      [(id<RCTTurboModuleWithJSIBindings>)module installJSIBindingsWithRuntime:*runtime];
    }
    return turboModule;
  }

  return nullptr;
}

总结一下,IOS 这块代码主要就是根据不同的模块类型一一处理,并且最后统一包裹成 TurboModule 返回

其中 _turboModuleCache 是一个关键的缓存机制,它保证了模块最多只会初始化一次

RN 团队在注释中有写了一个 TODO 想要把模块的生命周期管理下放到由模块自己管理(就是让 _turboModuleCache 不要像现在长时间保存实例缓存),但是我看了下到目前最新的 0.85 版本这个 TODO 还在

接下来我们来看看 Android 的 TurboModuleProvider 做了啥吧~

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

void TurboModuleManager::installJSIBindings(
    jni::alias_ref<jhybridobject> javaPart,
    bool shouldCreateLegacyModules) {
  auto cxxPart = javaPart->cthis();
  if (cxxPart == nullptr || !cxxPart->jsCallInvoker_) {
    return; 
  }

  // 从 runtimeExecutor 拿到 js runtime
  cxxPart->runtimeExecutor_([cxxPart,
                             javaPart = jni::make_global(javaPart),
                             shouldCreateLegacyModules](jsi::Runtime& runtime) {
    // 跟 IOS 一样,也是在这里调用了 install 方法
    TurboModuleBinding::install(
        runtime,
      // TurboModuleProvider
        cxxPart->createTurboModuleProvider(javaPart, &runtime),
        shouldCreateLegacyModules
      // LegacyModuleProvider
            ? cxxPart->createLegacyModuleProvider(javaPart)
            : nullptr);
  });
}

接下来我们看看具体实现:

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

TurboModuleProviderFunctionType TurboModuleManager::createTurboModuleProvider(
    jni::alias_ref<jhybridobject> javaPart,
    jsi::Runtime* runtime) {
  return [runtime, weakJavaPart = jni::make_weak(javaPart)](
             const std::string& name) -> std::shared_ptr<TurboModule> {
    auto javaPart = weakJavaPart.lockLocal();
    if (!javaPart) {
      return nullptr;
    }

    auto cxxPart = javaPart->cthis();
    if (cxxPart == nullptr) {
      return nullptr;
    }
// 具体实现在这~下面有代码~
    return cxxPart->getTurboModule(javaPart, name, *runtime);
  };
}

// 这个方法对应 IOS 的 provideTurboModule 方法
std::shared_ptr<TurboModule> TurboModuleManager::getTurboModule(
    jni::alias_ref<jhybridobject> javaPart,
    const std::string& name,
    jsi::Runtime& runtime) {
  const char* moduleName = name.c_str();
  TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   */
  auto turboModuleLookup = turboModuleCache_.find(name);
  if (turboModuleLookup != turboModuleCache_.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  auto cxxDelegate = delegate_->cthis();
  auto cxxModule = cxxDelegate->getTurboModule(name, jsCallInvoker_);
  if (cxxModule) {
    turboModuleCache_.insert({name, cxxModule});
    return cxxModule;
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto& cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(name);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 Android 中就是 java 模块
   */
  static auto getTurboJavaModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<JTurboModule>(const std::string&)>(
              "getTurboJavaModule");
  auto moduleInstance = getTurboJavaModule(javaPart.get(), name);
  if (moduleInstance) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);
    JavaTurboModule::InitParams params = {
        .moduleName = name,
        .instance = moduleInstance,
        .jsInvoker = jsCallInvoker_,
        .nativeMethodCallInvoker = nativeMethodCallInvoker_};

    auto turboModule = cxxDelegate->getTurboModule(name, params);
    if (moduleInstance->isInstanceOf(
            JTurboModuleWithJSIBindings::javaClassStatic())) {
      static auto getBindingsInstaller =
          JTurboModuleWithJSIBindings::javaClassStatic()
              ->getMethod<BindingsInstallerHolder::javaobject()>(
                  "getBindingsInstaller");
      auto installer = getBindingsInstaller(moduleInstance);
      if (installer) {
        installer->cthis()->installBindings(runtime);
      }
    }

    turboModuleCache_.insert({name, turboModule});
    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  // 处理旧架构中使用 C++ 实现的模块(对应 IOS 的 RCTCxxModule)
  static auto getTurboLegacyCxxModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<CxxModuleWrapper::javaobject>(
              const std::string&)>("getTurboLegacyCxxModule");
  auto legacyCxxModule = getTurboLegacyCxxModule(javaPart.get(), name);
  if (legacyCxxModule) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

    auto turboModule = std::make_shared<react::TurboCxxModule>(
        legacyCxxModule->cthis()->getModule(), jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});

    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  return nullptr;
}

可以看到 Android 的实现跟 IOS 基本没区别

在这个小节的最后,我们来聊一下 TurboModule 的两层缓存是怎么实现的:

  1. 第一层缓存是在宿主的 turboModuleCache_ 中这里存放了所有被初始化了的原生模块实例
  2. 第二层缓存是在 C++ 的 jsRepresentation,这是一个 JS 对象,每个对象对应到一个模块实例,模块实例通过挂在它的原型链上使得其可以访问模块的方法

访问模块的核心方法在 TurboModule.h

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h

// 在 TurboModuleBinding::getModule 方法中,我们知道它返回了 jsRepresentation 并且把对应模块实例挂上了它的原型链
// 如果在 JS 侧访问某个模块方法但是在 jsRepresentation 中找不到时,会试图往原型链上找,于是就会命中这里的 get 方法
facebook::jsi::Value get(
      facebook::jsi::Runtime& runtime,
      const facebook::jsi::PropNameID& propName) override {
    {
      // 在当前 TurboModule 实例上找到对应的属性
      auto prop = create(runtime, propName);
      
      // 对于访问过的实例,我们把它放进 jsRepresentation 的属性中,这样下次访问就不用在往原型链上走了
      if (jsRepresentation_ && !prop.isUndefined()) {
        jsRepresentation_->lock(runtime).asObject(runtime).setProperty(
            runtime, propName, prop);
      }
      return prop;
    }
  }

可以看到,这一个小小的 get 方法就完成了对模块属性级别的缓存

Codegen

在了解了 TurboModule 的设计方案以及调用链路后,我们接下来要补齐 TurboModule 的最后一块拼图:Codegen

根据 官网博客 的描述,codegen 是一个可选的工具,所以我们首先需要知道的是它的优势是什么,以及什么时候推荐使用

Codegen 的优势

Codegen 的优势可以一句话总结:它用一份强类型 JS/TS spec 生成 Android、iOS、C++ 原生层所需的接口与胶水代码,从而减少样板代码维护成本,并降低 JS 与 Native 之间类型不一致跨语言调用出错的风险

怎么理解这句话呢?

假设我们想要实现一个原生模块叫 NativeNotifier 它的职责是调用原生的能力,生成一个系统弹窗出来(示例见下图)

因为这个模块调用了 原生的能力 所以我决定用 objc 来实现,下面来讲一下如果不使用 codegen 的话,要怎么做:

第一步,先创建 JS wrapper:

// NativeNotifier.ts
import {TurboModuleRegistry} from 'react-native';

type NativeNotifierType = {
  show(message: string): void;
};

export default TurboModuleRegistry.getEnforcing<NativeNotifierType>(
  'NativeNotifier',
);

第二步,创建 IOS header:

// NativeNotifier.h
#import <React/RCTBridgeModule.h>

// 用 RCTBridgeModule 是为了让 RN 能发现这个 ObjC module,并读取 RCT_EXPORT_MODULE / RCT_EXPORT_METHOD 产生的 metadata
@interface NativeNotifier : NSObject <RCTBridgeModule>
@end

第三步,创建 iOS 实现:

// NativeNotifier.mm
#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <ReactCommon/RCTInteropTurboModule.h>
#import <ReactCommon/RCTTurboModule.h>
#import <UIKit/UIKit.h>

using namespace facebook::react;
@interface NativeNotifier () <RCTTurboModule>
@end

@implementation NativeNotifier

// 暴露模块与方法名
RCT_EXPORT_MODULE(NativeNotifier)
RCT_EXPORT_METHOD(show:(NSString *)message)
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<TurboModule>)getTurboModule:
    (const ObjCTurboModule::InitParams &)params
{
  // 把这个 ObjC module 包装成 TurboModule
  return std::make_shared<ObjCInteropTurboModule>(params);
}

@end

第四步,为了让 xcode 编译 NativeNotifier.mm,我们需要把它加入到 Xcode target 中

第五步,在 App 中调用它:

import NativeNotifier from './NativeNotifier';

export default function App() {

  return (
    <SafeAreaView style={styles.container}>
      <Pressable
        style={({ pressed }) => [
          styles.button,
          pressed && styles.buttonPressed,
        ]}
        // 调用这个模块方法
        onPress={() => NativeNotifier.show('Hello from native iOS code')}>
        <Text style={styles.buttonText}>Show native message</Text>
      </Pressable>
    </SafeAreaView>
  );
}

如果我们纯手写的话,我们不仅要先知道 TurboModuleRegistry.getEnforcing 的用法,还需要知道 RCTBridgeModuleRCTTurboModuleObjCInteropTurboModule 的用法与区别,最关键的是,这个流程我们需要在 Android 再来一次

这还是初次开发这种简单模块的情况,如果后续需要迭代修改,或者是模块的复杂度上去了,维护成本巨大,双端的一致性也是个大问题

Codegen 是怎么解决的呢?

第一步,在项目根目录创建 spec 目录以及 JS wrapper

// specs/NativeNotifier.ts

// 这个文件既是 JS 调用入口,也是 Codegen 读取的 spec
// 文件目录名字可以通过在 package.json 自定义,但约定俗成使用 specs
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

// 很重要的类型声明,Codegen 会通过这个类型生成胶水代码
export interface Spec extends TurboModule {
  show(message: string): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeNotifier');

第二步,去 package.json 配置相关信息,具体配置参考 官方博客

{
  "codegenConfig": {
    "name": "DemoSpec",  // SpecName
    "type": "modules",  // types
    "jsSrcsDir": "specs",  // source_dir
    "android": {
      "javaPackageName": "com.demo"  // java.package.name
    },
}

第三步,创建 iOS 实现:

// ios/Demo/NativeNotifier.h
#import <Foundation/Foundation.h>

@interface NativeNotifier : NSObject
@end
// ios/Demo/NativeNotifier.mm

#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <DemoSpec/DemoSpec.h>
#import <UIKit/UIKit.h>

@interface NativeNotifier () <NativeNotifierSpec>
@end

@implementation NativeNotifier

RCT_EXPORT_MODULE()

- (void)show:(NSString *)message
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<facebook::react::NativeNotifierSpecJSI>(params);
}

@end

第四步,把 NativeNotifier.mm 加入到 Xcode target 中

第五步,实现 App.tsx 中的调用逻辑

完成了这五步后,在配置正确的情况下,IOS/Android build 流程会触发 Codegen,自动生成双端所需的接口和胶水代码;但具体的原生模块实现,例如 IOS 的 NativeNotifier.mm、Android 的 NativeNotifierModule.kt,仍然需要开发者自己实现并注册

细心的读者会发现,即使用了 Codegen,我们仍然需要:

  1. 创建 spec
  2. 配置 package.json
  3. 实现 iOS native 逻辑
  4. 加入 Xcode target
  5. 在 JS 中调用

也就是说,Codegen 并不是让创建 native module 的步骤变少,它真正减少的是隐藏在这些步骤背后的胶水代码:方法表、类型转换、JSI adapter、Android abstract spec、JNI glue,以及双端 API 签名同步的成本

简单画个比较表:

对比点 不使用 Codegen 使用 Codegen
JS/native API 来源 JS wrapper 和 native method 各写一份(总共三份) JS/TS spec 是唯一 API 来源(Single source of truth)
类型检查 主要靠人工保证 Codegen 根据 spec 生成 native 类型约束
iOS glue code 依赖 ObjCInteropTurboModule 运行时解析 RCT_EXPORT_METHOD metadata 生成 NativeNotifierSpec /NativeNotifierSpecJSI
Android glue code 需要手动写 module/package,签名一致性靠人工 生成 abstract Spec 和 JNI 胶水代码
方法名/参数数量错误 运行时更容易暴露 编译期或生成阶段更容易暴露
后续修改 API JS、iOS、Android 多处人工同步 先改 spec,再让生成代码约束 native 实现
适用场景 迁移旧 ObjC bridge module、快速兼容 新架构下新模块,更适合长期维护

总结

本文从 TurboModule 的设计开始,讲到了 Codegen 的优势以及为什么要使用 Codegen

诚然 Codegen 可以降低很多开发 TurboModule 的隐形成本,但作为开发者了解 TurboModule 以及背后的 JSI 才是主要的根基

毕竟只有掌握了底层的机制,才能往上搭出像 Nitro moduleExpo Modules API 这样的上层库/工具

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

作者 王小新_926
2026年4月15日 15:48

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

作者 ouzz
2026年4月15日 15:01

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

如果你想做一个类似 Figma 的设计工具,第一反应往往是:

  • 要有高性能画布渲染
  • 要有可组合的 UI 结构
  • 要有事件命中、选中框、拖拽缩放、文本编辑
  • 还要能接入 AI(生成、改图、改布局)

我这次在 apps/open-canvas-lab 里给出的思路是:
react-canvas 作为渲染与交互底座,逐步搭一个“Figma 工具内核”。

lab.png

项目地址


为什么选 react-canvas

react-canvas 这套能力很适合做设计工具,因为它天然覆盖了编辑器最核心的三层:

  1. 场景渲染层:CanvasKit + 场景树,支持复杂布局、文本、图片、矢量
  2. 交互命中层:pick buffer + pointer 事件分发,支持精确命中
  3. 运行时层:场景节点可增删改查,可做撤销重做、选择态同步

相比“直接裸写 Canvas 2D”,这个方案的关键优势是:
你不是在拼命堆 imperative 绘图代码,而是在维护一套可演进的场景模型。


一个可落地的 Figma 工具架构

建议把应用拆成 4 个子系统:

1) Scene(文档模型)

  • 以节点树表达 Frame / Group / Text / Image / Path
  • 每个节点有 transform、style、约束信息
  • 变更统一走 command(方便 undo/redo)

2) Renderer(渲染与命中)

  • 主渲染:react-canvas 场景渲染
  • 命中:pick buffer 解析到 nodeId
  • 选中态叠加:控制框、锚点、参考线

3) Interaction(编辑器手势)

  • pointer down/move/up 组合成 drag / resize / rotate
  • 框选、吸附、对齐辅助线
  • 多选与分组操作

4) Tooling(工具链)

  • 左侧图层树、右侧属性面板
  • 顶部工具栏(选择、文本、矩形、钢笔)
  • 快捷键系统(复制、粘贴、对齐、撤销重做)

在 open-canvas-lab 的实现路线(推荐)

如果你要从 0 到 1 做出可用 MVP,可以按这个顺序:

  1. 文档与选中

    • 建立 node schema
    • 点选节点、高亮边框
  2. 变换编辑

    • 拖拽移动
    • 8 点缩放
    • 基础旋转
  3. 文本与图片

    • 文本节点样式编辑(字体、字号、行高)
    • 图片节点 object-fit / 裁剪
  4. 编辑器体验

    • 框选、多选、组合
    • 对齐吸附与辅助线
    • 撤销重做 + 操作历史
  5. 协作与 AI(进阶)

    • JSON 文档持久化
    • CRDT 协同(多人编辑)
    • AI 生成组件/版式并写回场景树

AI 能力该怎么落地(重点)

很多编辑器把 AI 做成“聊天框 + 一键生成图”,但真正可用的 AI 设计工具,关键是:
AI 输出必须是结构化编辑指令,而不是一段不可控文本。

ai.png 建议把 AI 能力拆成 3 层:

1) 意图层(Prompt / Plan)

  • 输入:自然语言需求(如“生成一个电商详情页首屏”)
  • 输出:任务计划(页面结构、组件清单、风格约束)
  • 形态:可审阅的中间 plan(用户可确认/修改)

这一层不要直接改场景,先做“可解释计划”,能显著降低误生成成本。

2) 工具层(Structured Tools)

给模型的不是“任意写 JSON”,而是明确工具集合,例如:

  • create_frame({ parentId, x, y, width, height, name })
  • create_text({ parentId, text, style })
  • create_image({ parentId, src, fit })
  • update_style({ nodeId, patch })
  • align_nodes({ nodeIds, mode })

模型只负责“调用工具”,具体执行由编辑器 runtime 保证合法性。
这样能把 AI 变成“受约束的自动化操作员”。

3) 执行层(Command Pipeline)

工具调用最终都转换为 command:

  • command[] -> validate -> apply -> layout -> render
  • 全量写入 undo/redo 栈
  • 每一步都可回滚、可重放

这保证了 AI 操作和手动操作使用同一条数据通路,不会出现“双系统分叉”。

推荐的 AI 能力清单

open-canvas-lab 里,建议优先做这 6 类能力:

  1. 从描述生成线框

    • 输入“做一个登录页”,输出基础布局骨架(frame + text + button)
  2. 风格迁移

    • 对选区做“科技蓝 / 极简黑白 / 品牌色系”重绘(仅改 style,不改结构)
  3. 批量排版

    • 统一间距、字号层级、栅格对齐
  4. 组件重写

    • 例如把“普通卡片”一键转成“带封面 + 标签 + CTA”卡片
  5. 文案智能填充

    • 生成标题、副标题、按钮文案,并支持语气风格切换
  6. 设计审查(AI Review)

    • 检查对齐、对比度、可读性、间距一致性,输出可执行修复建议

一个最小 AI 执行链路(MVP)

可以先实现下面这个闭环:

  1. 用户输入需求
  2. 模型输出 tool calls
  3. 前端校验参数(schema)
  4. 转成 command 执行
  5. 在画布高亮本次改动节点
  6. 用户可 accept / undo / retry

这个 MVP 的价值是:
你不需要先做很强的模型能力,就能把“AI 可控编辑”体验跑通。

AI 接入时最容易踩的坑

坑 1:让模型直接返回整份文档 JSON

问题:diff 巨大、不可控、很难回滚。
建议:必须改为“增量工具调用 + command 化执行”。

坑 2:AI 操作绕开编辑器状态机

问题:会破坏选中态、历史栈、约束关系。
建议:AI 与用户操作走同一 command pipeline。

坑 3:没有失败兜底

问题:工具半执行状态下文档损坏。
建议:每批 AI 操作做事务边界(失败整体回滚)。

坑 4:可解释性不足

问题:用户不知道 AI 改了什么。
建议:展示“本次修改节点清单 + 属性 diff”。


JSON 设计(简版)

为了让 AI、编辑器、存储三方都能稳定协作,建议把 JSON 拆成两层:

  1. document schema:描述页面与节点树(可持久化)
  2. command schema:描述一次编辑动作(可回放、可撤销)

1) document schema 示例

{
  "version": "1.0",
  "meta": { "name": "Landing Page", "updatedAt": 1776259200000 },
  "rootId": "frame_root",
  "nodes": {
    "frame_root": {
      "id": "frame_root",
      "type": "frame",
      "name": "Page",
      "children": ["title_1", "btn_1"],
      "layout": { "x": 0, "y": 0, "width": 1440, "height": 900 },
      "style": { "backgroundColor": "#ffffff" }
    },
    "title_1": {
      "id": "title_1",
      "type": "text",
      "text": "Build with react-canvas",
      "layout": { "x": 120, "y": 160, "width": 600, "height": 72 },
      "style": { "fontSize": 56, "fontWeight": 700, "color": "#111827" }
    },
    "btn_1": {
      "id": "btn_1",
      "type": "frame",
      "name": "CTA",
      "children": [],
      "layout": { "x": 120, "y": 280, "width": 168, "height": 48 },
      "style": { "borderRadius": 12, "backgroundColor": "#2563eb" }
    }
  }
}

2) command schema 示例

{
  "id": "cmd_20260415_001",
  "type": "update_style",
  "payload": {
    "nodeId": "btn_1",
    "patch": { "backgroundColor": "#1d4ed8", "borderRadius": 14 }
  },
  "meta": { "source": "ai", "traceId": "run_xxx" }
}

这套拆分的好处是:

  • 文档 JSON 负责“当前状态”
  • command JSON 负责“如何到达这个状态”
  • AI 输出 command,比直接覆盖整份 document 更安全

关键实现细节(踩坑重点)

坐标系统一

编辑器里至少有 3 套坐标:

  • 视口(client)
  • 画布(stage)
  • 节点局部(local)

一定要优先统一坐标映射,否则拖拽、选框和命中会经常“看起来差几像素但很难查”。

命中与视觉分离

不要用“可见像素”直接做命中判断。
正确姿势是用独立 pick 语义层(nodeId 编码),可维护性和稳定性会高很多。

编辑器状态尽量事件化

把“鼠标按下后进入哪种模式”建成有限状态机(FSM),比 scattered boolean 更稳,后续加钢笔、裁剪工具也不容易崩。


一个简单但重要的结论

做 Figma 工具真正困难的不是“画出来”,而是:

  • 模型是否可持续演进
  • 交互是否可组合
  • 渲染/命中/状态是否解耦

react-canvas 的价值在于,它已经把底层最难啃的部分(渲染与交互基础设施)提前搭好。
你可以把主要精力放在“产品能力”和“编辑体验”上。


结语

如果你正在基于 apps/open-canvas-lab 做编辑器方向的实验,这个方向是可行的:
先做一个“可编辑画板 MVP”,再逐步补齐 Figma 级能力,而不是一上来追求完整复刻。

微软想给所有 Windows 电脑预装龙虾

作者 马扶摇
2026年4月15日 10:30

在这个 AI 如火如荼的时候,「桌面端」似乎显得有些冷清。

归根结底,对于 LLM 类 AI 应用来说,你只需要一个对话框就可以完成交互,在 app、在浏览器,还是在桌面端完全没有区别。

而 OpenClaw 的出现,多少改变了这一点——这种本地部署的软硬件结合方式,重新将「电脑」这一载体扔回了 AI 漩涡的中心。

图|封面新闻

然而 Open-Claw 长期存在着一个底层缺陷:它是基于类 Unix 环境构建的,天生对 Linux 和 macOS 比较友好,在 Windows 上安装起来很麻烦。

由于 OpenClaw 高度依赖类 Unix 环境,许多底层脚本也是基于 Darwin 或 Linux 写的,因此想要在 Windows 上得到一个能用的龙虾助手,光是折腾 WSL2、Docker 和 Nix 就足够劝退 90% 的尝鲜用户了。

面对这种用户需求和系统环境的矛盾,「从善如流」的微软敏锐地察觉到了这之中隐藏的需求,提出了一个雷霆方案:

我们要给 Copilot 也加上类 OpenClaw 能力。

从副驾驶到代理人

虽然我们曾经调侃过微软滥用 Copilot 导致它变「Microslop」的问题,但时至今日,Copilot 的确依然是 Windows 内建的最主要的 AI 方案之一。

在经历过普遍的对于 Copilot 的反对声音之后,微软似乎终于打算做一些有意义的工作了。

根据最新的行业消息,微软全球资深副总裁(CVP)之一奥马尔·沙欣(Omar Shahine)受命组建一支新的「精锐团队」,挖掘 OpenClaw 在企业环境中的潜力,以及将「类 OpenClaw 能力」集成进 Copilot 的可能性。

奥马尔·沙欣(中间)|LinkedIn

长期以来,微软服务、尤其是 Windows 用户,对 Copilot 的评价始终呈现出一种诡异的两极分化。

对于微软自己来说,它在财报中自豪地宣称 Copilot 拥有近 1500 万付费用户(大约相当于 Office 365 用户的 3%),还拥有「巨大的增长空间」。

然而市场却对 Copilot 充满了寒意:微软的股价在 26 年表现惨淡,甚至在大型科技股中垫底,跌幅一度达到了 24% ——

图|Analytics Insight

投资者和股市的逻辑很直白:

如果 Copilot 继续作为一个需要用户不断喂提示词、总结两页文档能反向吐出四页废话的「对话框」,那它永远无法产生真正意义上的生产力溢价。

尤其当隔壁的 Claude 已经能「连接」PowerPoint 和 Excel 来代替用户操作复杂内容的时候,微软必须拿出一些更硬核的东西来证明自己。

这也是微软通过「Copilot 风味 OpenClaw」期望达到的效果,我们可以给它起名叫「MS-Claw」。

图|TechCrunch

毕竟纯粹基于 LLM 的 Copilot 本身实在是太废物了,虽然权限极高,但几乎无法实现任何具备 agentic 能力的代理操作功能。

如果说现有的 Copilot 是一个听命行事的速记员,那么正在开发的 MS-Claw 则是一个全天待命的「数字分身」,旨在让 Copilot 实现 24/7 自主运作电脑的效果。

图|Jukka Niiranen

换言之,MS-Claw 和直接从 GitHub 上部署的开源版本 OpenClaw 能力差不多——

它不再被动地等待用户输入指令,而是主动筛选你的 Outlook 收件箱、梳理日历、在后台自动重组 Excel 数据,在你每天打开电脑之前就准备好待办清单和当日简报。

这种从以 LLM 为代表的「反应式 AI」向「代理式 AI」的跨越,正是为了解决企业级用户最头疼的安全与效率平衡问题,以及挽回 Copilot 的口碑。

图|Microsoft

为了「和 Anthropic 抢时间」,微软 CEO 萨蒂亚·纳德拉(Satya Nadella)重组了工程架构,将消费者与企业版的 Copilot 开发团队合并,并提拔了多位高管直接向其汇报。

比如原本带领 Agent 365 的查尔斯·拉曼纳(Charles Lamanna),就负责监督在现有 365 Copilot 服务中构建 MS-Claw 的工作,奥马尔·沙欣也在他的团队中。

查尔斯·拉曼纳|Microsoft

除了前面提到的代用户操作 Microsoft 365 应用套件之外,微软的另一个目标是让 MS-Claw(以及整个 365 Copilot)更好地在后台参与 Microsoft 应用程序里面工作,无需用户的持续监督。

例如,MS-Claw 可以在用户手动编辑新 Excel 工作表的时候,根据之前的命令,在后台静默整理其他工作表的格式或者信息。

图|Microsoft

另外据知情人士透露,365 Copilot 的产品负责人也讨论直接过为 MS-Claw 构建一些具体的代理(agents),比如市场经理、销售总监或会计。

比起接入外部代理或者不划分角色,这么做可以让每个代理的权限控制变得更简单,增加在处理企业内部敏感信息时的安全性。

总之,抛开混乱的命名不谈,微软希望通过 MS-Claw 实现的——不管套着 365 Copilot 还是 Microsoft 365 的皮——本质上就是借 Copilot 给用户提供一个「开箱即用」的 Agentic AI 解决方案。

Copilot 一共有多少种?|Tey Bannerman

更多厂商,都在预装龙虾

除了微软正在尝试向 Windows 中集成 OpenClaw 类似物之外,越来越多的笔记本厂商也开始「越俎代庖」,先一步为 PC 集成了各种各样的龙虾。

这其中就包括爱范儿先前报道过的联想天禧 Claw,以及荣耀前两天刚刚发布的 YOYO Claw,都属于内置在 OEM 厂商自己 app 中的「预制菜」式龙虾,主打一个开箱即用。

毕竟 OpenClaw 及其变体能在 Mac 生态率先引爆,很大程度上得益于 macOS 的类 Unix 环境。

对于开发者和极客用户来说,各种自动化脚本和权限管理几乎是开箱即用的。

图|YouTube @Andres Vidoza

相比之下,在 Windows 手动部署原版的 OpenClaw 简直是一场噩梦。

不仅需要先配置好 WSL2 子系统,搭建出一个 Linux 虚拟环境,最后还可能因为 Windows 11 混乱的权限设置和各种 bug,导致 AI 代理无法顺利模拟鼠标点击之类的难绷问题。

图|Windows Central

作为 OEM 厂商,联想和荣耀的切入点精准地踩在了这道「部署门槛」上——

对于绝大多数「有点需求又不那么精通技术」的消费者而言,只需要买一台带着天禧 Claw 或者 YOYO Claw 的电脑回家,开机登陆完就能直接开始帮自己整理数据。

厂商通过在 Windows 里预装、预配置、预处理一个 OpenClaw 工具,其实就是在向大众用户售卖这种「AI 便利性」。

这样做有用吗?还真有用。

至少对于普通用户来说,多一个「开箱即用」的功能总归不是一件坏事,哪怕上面写着的是 Copilot。

微软或者 OEM 厂商预装各类 Claw,相当于帮用户完成了最脏最累的底层适配工作——

只有这种时候,AI PC 才真正从贴着炫彩标签的笔记本,变成了内置了数字助理的生产力终端。

另外对于很多用户很重要的,则是「端云混合」的部署逻辑,天生为风险隔离和算力成本提供了解决方案。

不止天禧 Claw 和 YOYO Claw 已经标明端云混合,微软实际选择的,其实也是和之前「Azure 云电脑 – Agent 365 – 365 Copilot」相同的路径。

图|Microsoft Learn

这样一来,「某某 Claw」与各种搭配的代理就可以优先在本地处理敏感的个人数据或屏幕截图,只在涉及复杂逻辑推理时才请求云端模型。

这种隐私保护与性能的平衡,是目前几乎纯云端的 Copilot 365 难以实现的。

最重要、同时也是对用户钱包最友好的一点,是这种由厂商主导的「帮你部署」和「端云混合」的龙虾方案可以非常有效地节省 Token 开销。

图|Notebookcheck

比如通过模型分级路由、本地 RAG(压缩对话历史)、智能提示词缓存等等手段,端云混合的 Claw 方案据估算可以将 token 消耗量压缩到此前的 50% 甚至更低,有效避免「Claw 跑一晚,卡里少三千」的情况。

针对那些 API 开销极度敏感的企业和个人用户来说,这种「省钱办大事」的产品才是最具有吸引力的那个。

AIPC 的终点,是预制菜

站在 2026 这个时间点上,我们可以大胆得出一个结论:

未来的 AI PC,如果做不到出厂预装代理 AI(Agentic AI),那它根本就不配被称为 AI 生产工具。

我们必须意识到——在声势浩大的 LLM 游戏之后,FOMO 的无限叠加已经让我们对 AI 的耐心彻底耗尽。

图|TNW

绝大多数普通用户已经玩腻了「我问你答」的顾问游戏,然而公司却变本加厉地要求人们继续用 AI 提高自己的效率。

做不到,就掉下斩杀线。

正因如此,人们如今需要电脑做到的,不是更快的 CPU 主频,也不是更薄的机身,而是它能否像一个真正的「合伙人」或者「副驾驶」那样,直接帮我执行和解决任务。

图|Itequia

因此,无论是微软这样的 Windows 源头厂商,还是联想、荣耀这种笔记本 OEM 厂商,出厂预装「龙虾」或类似物,将成为未来衡量 PC 厂商核心竞争力的硬指标。

未来的操作系统不会只是一个运行软件的平台,而会变成一种「代理调度中心」。

与此同时,硬件厂商的角色也将发生巨大的转变:它们不再只作为零件的「方案整合商」,而是会兼任「AI 工作流」的定义者——

比如我们可以想象,未来联想预装的 Claw 可能更偏向商务协作,荣耀的 Claw 可能更擅长跨设备调控,而微软的 MS-Claw 则能够一条龙服务代理整个 Office 全家桶……

这种代理式 AI 角色的差异化,将成为 AI PC 品牌忠诚度的新来源。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

用wagmi v2构建DeFi前端:从连接钱包到读取合约数据的完整实战与避坑指南

作者 竹林818
2026年4月15日 10:01

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。

一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。

问题分析

我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。

首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。

排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。

核心实现

1. 配置Provider与多链支持

第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';

// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, polygon],
  connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [polygon.id]: http(),
  },
});

export function WagmiProvider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProviderCore config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProviderCore>
  );
}

2. 实现可靠的钱包连接与链状态同步

接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain

踩过的一个坑:最初我试图用useAccountchainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。

// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';

export function ConnectButton() {
  const { connect, connectors, isPending } = useConnect();
  const { address, chain, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { switchChain } = useSwitchChain();

  const handleConnect = () => {
    // 默认连接第一个注入式连接器(如MetaMask)
    connect({ connector: connectors[0] });
  };

  const handleSwitchChain = (targetChainId: number) => {
    switchChain({ chainId: targetChainId });
  };

  if (!isConnected) {
    return (
      <button onClick={handleConnect} disabled={isPending}>
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Network: {chain?.name} (ID: {chain?.id})</p>
      <div>
        <button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    </div>
  );
}

3. 动态读取多链合约数据

这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的addresschainIdaccount发生变化时,它会自动重新获取数据。

注意这个细节useReadContractquery选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';

// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
  42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
  10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
  137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};

export function useTokenBalance() {
  const { address, chainId } = useAccount();

  const { data: balance, isLoading, error, refetch } = useReadContract({
    abi: erc20Abi,
    address: chainId ? USDC_ADDRESS[chainId] : undefined,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address && !!chainId, // 关键:确保条件满足才查询
      refetchInterval: 10000, // 每10秒自动刷新一次
    },
  });

  return {
    balance,
    isLoading,
    error,
    refetch, // 暴露手动刷新函数,用于交易后更新
  };
}

4. 执行合约写入与交易状态监听

用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。

这里有个大坑useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递accountchainId

// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';

const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址

export function StakeForm() {
  const [amount, setAmount] = useState('');
  const { address, chainId } = useAccount();

  const {
    writeContractAsync,
    isPending: isWritePending,
    data: hash,
    error: writeError,
    reset: resetWrite,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    query: {
      enabled: !!hash, // 只有有交易哈希时才启动监听
    },
  });

  const handleStake = async () => {
    if (!address || !chainId) return;
    try {
      resetWrite(); // 重置上一次的写入状态
      await writeContractAsync({
        abi: stakingPoolAbi,
        address: STAKING_POOL_ADDRESS,
        functionName: 'stake',
        args: [parseUnits(amount, 18)], // 假设代币精度18
        account: address,
        chainId: chainId, // !!!务必显式指定链ID
      });
      // 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
    } catch (err) {
      console.error('Stake failed:', err);
    }
  };

  return (
    <div>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
      <button onClick={handleStake} disabled={isWritePending || !amount}>
        {isWritePending ? 'Confirming in wallet...' : 'Stake'}
      </button>
      {isConfirming && <p>Transaction is being confirmed...</p>}
      {isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
      {writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
    </div>
  );
}

完整代码示例

下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。

// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';

function AppContent() {
  const { balance, isLoading, error, refetch } = useTokenBalance();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi Staking Dashboard</h1>
      <ConnectButton />
      <hr />
      <h2>Your USDC Balance</h2>
      {isLoading && <p>Loading balance...</p>}
      {error && <p>Error loading balance: {error.message}</p>}
      {balance !== undefined && (
        <p>Balance: {balance.toString()} units (raw)</p>
        // 实际应用中,这里需要根据代币精度格式化显示
      )}
      <button onClick={() => refetch()}>Refresh Balance</button>
      <hr />
      <h2>Stake Tokens</h2>
      <StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
    </div>
  );
}

export default function App() {
  return (
    <WagmiProvider>
      <AppContent />
    </WagmiProvider>
  );
}

踩坑记录

  1. useReadContract 不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccountaddress)。

  2. 交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId

  3. “RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因wagmihttp()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viemfallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])

  4. **TypeScript类型错误:0xstring:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是0ˋx{string}`”`**:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是`\`0x{string}`类型。**原因**:viemwagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。

小结

通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

作者 ErpanOmer
2026年4月15日 09:58

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 09:22

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

从零搭建 Monorepo 自动发布工作流(GitHub Actions + pnpm + Lerna)

作者 donecoding
2026年4月14日 23:09

🚀 省流助手 (速通结论)

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面最后的 GitHub Actions 配置即可开箱即用:

三个核心要点

  1. 只监听 PR 合并事件,避免手动推送误触发。
  2. 发布前先将 master 同步到 release,确保基于最新主干代码发版。
  3. 发布后使用 --ff-only 快进 master,保持历史线性且零冲突。

如果你想知道为什么这么设计、如何避坑,请继续阅读全文。

1. 引言:为什么要折腾这套流程?

在 Monorepo 项目中,包的版本管理和发布往往是最繁琐的环节。手动执行 lerna publish 不仅容易忘记切换 Node 版本、打错标签,还可能在多人协作时出现版本冲突或漏发包的情况。

本文将手把手带你用 GitHub Actions 搭建一套完全自动化的发布流水线,实现以下效果:

  • ✅ 开发者只需将 PR 合并到 release 分支,剩下的全部交给机器人。
  • ✅ 自动计算版本号,自动生成 CHANGELOG,自动推送 Git 标签。
  • ✅ 发布完成后自动将 master 分支同步到最新状态,保持双分支一致。

2. 触发时机:如何精确捕获“PR 合并”事件?

很多同学一开始会写成这样:

on:
  push:
    branches:
      - release

问题:任何向 release 分支的推送都会触发(包括手动 git pushgit commit),不符合“只有 PR 合并才发布”的规范。

正确姿势是监听 pull_request 事件的 closed 类型:

on:
  pull_request:
    types:
      - closed
    branches:
      - release

closed 事件包含两种情形:合并后关闭直接关闭(未合并)。因此我们还需要在 Job 级别加一个条件过滤:

jobs:
  publish:
    if: github.event.pull_request.merged == true

这样就能精准命中“PR 已合并”的场景,完美避开直接关闭的空跑。

3. 环境配置:锁定 Node 与 pnpm 版本

为了避免因环境差异导致的构建失败,强烈建议将 Node.js 和 pnpm 的版本写死在环境变量中:

env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

后续步骤通过 ${{ env.NODE_VERSION }}${{ env.PNPM_VERSION }} 引用,日后升级只需改一处即可。

- uses: pnpm/action-setup@v4
  with:
    version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
  with:
    node-version: ${{ env.NODE_VERSION }}
    registry-url: "https://registry.npmjs.org"

4. Git 身份配置:为什么必须用 [bot] 邮箱?

在 CI 中生成的提交需要有一个明确的作者身份。如果随意填写 ci@localhost,GitHub 会将其显示为灰色头像的“幽灵提交”,无法关联到任何账户,也不利于审计追溯。

正确做法是使用 GitHub Actions 官方的 Bot 身份:

- name: Configure Git
  run: |
    git config --global user.name "github-actions[bot]"
    git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

其中 41898282 是 GitHub Actions App 的唯一数字 ID,加上这串数字后提交会明确归属给机器人。

5. 分支同步策略:为什么发布前要合并 master

很多团队允许紧急 Hotfix 直接合并到 master 上线。如果 release 分支长期未更新,就可能基于过时代码发布,导致线上问题复现。

因此我们在发布前增加一步:

- name: Sync master into release
  run: |
    git fetch origin master
    git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"
  • --no-ff 保留合并历史,清晰记录本次同步动作。
  • 提交信息中带上 [skip ci] 是一个防御性习惯:即使未来因某种原因推送了这个合并提交,也不会触发额外的工作流。

6. Lerna 发布:本地生成提交,不着急推送

核心发布命令如下:

- name: Publish packages
  run: |
    npx lerna publish --yes \
      --conventional-graduate \
      --no-push \
      --message "chore(release): publish [skip ci]"
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

参数解释:

  • --yes:跳过所有交互式确认,全自动执行。
  • --conventional-graduate:自动将当前为 alpha/beta 的预发布包“毕业”为正式版本(例如 1.0.0-alpha.01.0.0)。
  • --no-push禁止 Lerna 自动推送,改为后续手动推送。这样可以在 npm 发布成功后再推送 Git 标签,保证原子性。
  • --message:自定义提交信息,包含 [skip ci] 防止推送后再次触发本工作流。

7. 推送与主干快进:如何让 master 历史保持一条直线?

发布完成后,我们分两步推送:

第一步:推送 release 分支及标签

- name: Push release and tags
  run: git push --follow-tags origin release

第二步:将 master 快进到 release

- name: Fast-forward master
  run: |
    git fetch origin master
    git checkout master
    git merge --ff-only origin/release
    git push origin master

由于发布前我们已经将 master 合并到了 release,加上发布提交,release 必然比 master 多一个新提交。此时使用 --ff-only(仅快进)可以将 master 指针直接移动到 release 的位置,不会产生额外的合并提交,历史图谱干净如线。

8. 并发控制与安全兜底

concurrency:
  group: release-publish
  cancel-in-progress: false

这一配置确保同一时刻只有一个发布任务运行,新触发的任务会排队等待,避免多人同时合并 PR 造成 Git 推送冲突。

同时,工作流顶部声明权限:

permissions:
  contents: write

配合 Personal Access Token(需具备 Contents 读写权限),保证 Git 推送操作万无一失。

9. 结语

通过以上配置,我们实现了一套高内聚、低心智负担的 Monorepo 自动发布流水线。开发者只需专注于代码本身,合并 PR 后喝杯咖啡,机器人会自动完成剩下的所有脏活累活。

完整配置文件,欢迎直接复制使用。

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面这份 GitHub Actions 配置即可开箱即用:

name: Publish from Release
env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

on:
  pull_request:
    types: [closed]
    branches: [release]

concurrency:
  group: release-publish
  cancel-in-progress: false

jobs:
  publish:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          registry-url: "https://registry.npmjs.org"

      - name: Configure Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Sync master into release
        run: |
          git fetch origin master
          git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"

      - name: Install dependencies
        run: pnpm install

      - name: Publish packages
        run: |
          npx lerna publish --yes --conventional-graduate --no-push --message "chore(release): publish [skip ci]"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - name: Push release and tags
        run: git push --follow-tags origin release

      - name: Fast-forward master
        run: |
          git fetch origin master
          git checkout master
          git merge --ff-only origin/release
          git push origin master

下一篇我们将深入探讨 Lerna 版本计算的底层逻辑,以及如何解决令人头疼的 bad revision 'undefined' 错误——敬请期待。

CDP、Puppeteer 与无头浏览器:它们到底什么关系?

作者 Bacon
2026年4月14日 20:09

一分钟速览

概念 类比 角色
无头浏览器 一台"看不见屏幕"的电脑 运行环境 / 硬件
CDP 电脑的控制接口(USB / 串口协议) 通信协议
Puppeteer 你写的自动化脚本 / 遥控器 App 高层 SDK

1. 什么是无头浏览器(Headless Browser)

无头浏览器指没有图形界面 (GUI) 的浏览器,它拥有完整的浏览器引擎(HTML 解析、CSS 渲染、JS 执行),但不会在屏幕上绘制任何窗口。

chrome --headless --disable-gpu https://example.com

典型用途:

  • 自动化测试(截图、E2E 测试)
  • 爬虫 / 数据抓取
  • 服务端渲染 SSR 预渲染
  • 生成 PDF / 截图
  • Agent 的浏览器工具调用

常见实现:Chrome/Chromium headless、Firefox headless(历史上还有 PhantomJS,已停维护)。


2. 什么是 CDP(Chrome DevTools Protocol)

CDP 是 Chromium 团队定义的一套用于程序化控制浏览器的通信协议,本质是一套基于 WebSocket + JSON-RPC 的 API 集合。

你在 Chrome DevTools(F12)里做的一切——查看 DOM、网络请求、调试 JS、截图——背后都是 CDP 在驱动。

WS 消息示例(发送):
{
  "id": 1,
  "method": "Page.navigate",
  "params": { "url": "https://example.com" }
}

WS 消息示例(响应):
{
  "id": 1,
  "result": { "frameId": "...", "loaderId": "..." }
}

CDP 核心域(Domain):

Domain 能力
Page 页面导航、截图、PDF
Network 拦截请求、修改 Header
DOM 查询 / 操作 DOM 节点
Runtime 执行任意 JS、获取返回值
Input 模拟鼠标点击、键盘输入
Target 多标签页 / 多 iframe 管理

3. 什么是 Puppeteer

Puppeteer 是 Google 官方出品的 Node.js 库,它封装了 CDP 的所有细节,暴露出人类友好的 API。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example.com');
  const title = await page.title();
  console.log(title);
  await browser.close();
})();

一行 page.goto() 背后,Puppeteer 帮你发了十几条 CDP 命令。


4. 三者关系:分层架构图

graph TB
    subgraph USER["👨‍💻 开发者代码层"]
        A["你的 Node.js / Python 代码<br/>业务逻辑、Agent 工具调用"]
    end

    subgraph SDK["📦 高层 SDK 层"]
        B["Puppeteer"]
        B2["Playwright"]
        B3["selenium-webdriver<br/>(通过 WebDriver 协议)"]
    end

    subgraph PROTOCOL["🔌 协议层"]
        C["CDP\nChrome DevTools Protocol\nWebSocket + JSON-RPC"]
    end

    subgraph BROWSER["🌐 浏览器层"]
        D["Chrome / Chromium 内核"]
        E["无头模式\nHeadless"]
        F["有头模式\nHeaded(可见窗口)"]
    end

    A --> B
    A --> B2
    A --> B3
    B -->|"封装 CDP 调用"| C
    B2 -->|"封装 CDP 调用"| C
    B3 -->|"WebDriver 协议\n(另一套协议)"| D
    C -->|"WebSocket 通信"| D
    D --> E
    D --> F

    style USER fill:#dbeafe,stroke:#3b82f6
    style SDK fill:#d1fae5,stroke:#10b981
    style PROTOCOL fill:#fef3c7,stroke:#f59e0b
    style BROWSER fill:#fce7f3,stroke:#ec4899

层级关系: 无头浏览器是运行环境,CDP 是控制协议,Puppeteer 是对 CDP 的高层封装。


5. 一次页面访问的时序图

page.goto('https://example.com') 为例,看看底层发生了什么。

sequenceDiagram
    autonumber
    participant User as 开发者代码
    participant PP as Puppeteer
    participant WS as WebSocket 连接
    participant CDP as CDP 协议层
    participant Chrome as Chrome (无头)
    participant Net as 网络 / DNS

    User->>PP: page.goto('https://example.com')
    PP->>PP: 内部构建 CDP 命令

    PP->>WS: 发送 Page.navigate 消息
    WS->>CDP: JSON-RPC: { method: "Page.navigate", params: {url} }
    CDP->>Chrome: 触发导航

    Chrome->>Net: DNS 解析 + TCP 握手 + TLS
    Net-->>Chrome: 建立连接
    Chrome->>Net: 发送 HTTP GET 请求
    Net-->>Chrome: 返回 HTML 响应

    Chrome->>Chrome: HTML 解析 → DOM 树
    Chrome->>Chrome: CSS 解析 → CSSOM
    Chrome->>Chrome: JS 执行(同步脚本)
    Chrome->>Chrome: 触发 DOMContentLoaded

    CDP-->>WS: 事件推送: Page.loadEventFired
    WS-->>PP: 接收事件,Promise resolve
    PP-->>User: goto() 完成,返回 Response

6. CDP 连接建立时序图

Puppeteer launch() 时,如何与 Chrome 建立 CDP 连接。

sequenceDiagram
    autonumber
    participant PP as Puppeteer
    participant OS as 操作系统
    participant Chrome as Chrome 进程
    participant WS as WebSocket

    PP->>OS: 启动子进程: chrome --headless --remote-debugging-port=9222
    OS->>Chrome: 创建 Chrome 进程
    Chrome-->>OS: 监听 9222 端口,打印 WS 调试地址

    PP->>Chrome: HTTP GET /json/version
    Chrome-->>PP: 返回 { webSocketDebuggerUrl: "ws://localhost:9222/..." }

    PP->>WS: 建立 WebSocket 连接到调试地址
    WS-->>PP: 连接建立成功

    PP->>WS: 发送 Target.getTargets
    WS-->>PP: 返回已有标签页列表

    PP->>WS: 发送 Target.createTarget(新建标签页)
    WS-->>PP: 返回 targetId

    PP-->>PP: 封装为 Page 对象,供用户使用

7. 核心能力对比

quadrantChart
    title 浏览器自动化工具能力对比
    x-axis 学习曲线低 --> 学习曲线高
    y-axis 能力弱 --> 能力强
    quadrant-1 专家工具
    quadrant-2 首选工具
    quadrant-3 入门工具
    quadrant-4 高风险区
    Puppeteer: [0.35, 0.72]
    Playwright: [0.40, 0.88]
    CDP 原生: [0.80, 0.95]
    Selenium: [0.55, 0.55]
    PhantomJS: [0.30, 0.30]

8. Puppeteer vs 直接用 CDP

flowchart LR
    subgraph RAW["直接使用 CDP(原始方式)"]
        R1["手动管理 WebSocket"]
        R2["手动序列化 JSON 命令"]
        R3["手动等待事件"]
        R4["手动管理多标签页"]
        R5["需要熟记每个 Domain 命令"]
        R1 --> R2 --> R3 --> R4 --> R5
    end

    subgraph PPT["使用 Puppeteer(推荐)"]
        P1["puppeteer.launch()"]
        P2["browser.newPage()"]
        P3["page.goto(url)"]
        P4["page.click(selector)"]
        P5["page.screenshot()"]
        P1 --> P2 --> P3 --> P4 --> P5
    end

    RAW -- "Puppeteer 帮你封装了这些" --> PPT

9. 典型使用场景流程图

flowchart TD
    Start([需要自动化浏览器?]) --> Q1{是否需要\n真实浏览器渲染?}

    Q1 -- 否 --> Axios["使用 axios / fetch\n直接 HTTP 请求更简单"]
    Q1 -- 是 --> Q2{是否需要\n可见界面调试?}

    Q2 -- 是,开发阶段 --> Headed["headless: false\n有头模式,肉眼观察"]
    Q2 -- 否,生产环境 --> Headless["headless: true\n无头模式,服务端运行"]

    Headed --> Q3{用哪个库?}
    Headless --> Q3

    Q3 -- 简单任务 --> Puppeteer2["Puppeteer\n(Google 维护,API 简洁)"]
    Q3 -- 多浏览器兼容 --> Playwright2["Playwright\n(微软维护,支持 Firefox/Safari)"]
    Q3 -- 精细控制 --> CDP2["直接操作 CDP\n(需要深度定制时使用)"]

    Puppeteer2 --> Done["完成自动化任务 🎉"]
    Playwright2 --> Done
    CDP2 --> Done

10. 生态关系图

graph LR
    subgraph Google["Google 生态"]
        Chromium["Chromium 开源浏览器"]
        CDP["CDP 协议"]
        Puppeteer3["Puppeteer"]
        Chromium --> CDP
        CDP --> Puppeteer3
    end

    subgraph Microsoft["Microsoft 生态"]
        Playwright3["Playwright"]
        Playwright3 -->|"复用 CDP"| CDP
        Playwright3 -->|"Firefox Protocol"| FF["Firefox"]
        Playwright3 -->|"WebKit Protocol"| Safari["WebKit/Safari"]
    end

    subgraph W3C["W3C 标准"]
        WebDriver["WebDriver 协议\n(W3C 标准)"]
        Selenium3["Selenium"]
        WebDriver --> Selenium3
    end

    subgraph Agent["AI Agent 工具"]
        BrowserUse["browser-use"]
        LangChain["LangChain Browser Tool"]
        BrowserUse -->|"底层使用"| Playwright3
        LangChain -->|"底层使用"| Puppeteer3
    end

    style Google fill:#e0f2fe
    style Microsoft fill:#e8f5e9
    style W3C fill:#fff8e1
    style Agent fill:#f3e5f5

11. 一句话总结

无头浏览器  是一台"无屏幕的 Chrome"
     ↑
    CDP     是它暴露的"远程控制接口(协议)"
     ↑
 Puppeteer  是对 CDP 的"人性化封装库"
     ↑
 你的代码   调用 Puppeteer 实现自动化 / AI Agent 工具

12. 常见误区澄清

误区 正确理解
Puppeteer = 无头浏览器 ❌ Puppeteer 是库,无头浏览器是 Chrome
无头模式性能更好 ✅ 省去 GPU 渲染管线,内存和 CPU 更低
CDP 只有 Puppeteer 能用 ❌ Playwright、DevTools、各种调试工具都用 CDP
Headless Chrome 和普通 Chrome 行为不同 ⚠️ 部分 CSS / JS 行为有细微差异,需测试覆盖
Puppeteer 只能跑 Chrome ✅ 是的(官方支持 Chromium 和 Edge),跨浏览器用 Playwright

参考资料

你的网站被“下毒”了?XSS和CSRF:前端安全的两大“毒瘤”

作者 kyriewen
2026年4月14日 19:37

你有没有听说过:点了个链接,微博自动转发了奇怪的内容;登录了银行网站,钱莫名其妙被转走。今天我们就来揪出前端安全领域的两个“惯犯”——XSS(跨站脚本攻击)和CSRF(跨站请求伪造)。它们一个像“投毒者”,一个像“冒充者”,专门偷你的数据、干你的坏事。

前言

想象一下,你开了个奶茶店。XSS就是有人在你店里的菜单上贴了一张纸:“凭此券免费喝奶茶”,然后顾客都来找你要免费奶茶。CSRF则是有人冒充你,对供应商说:“老板说再进1000箱珍珠!”结果你莫名其妙多了一仓库珍珠。

这两种攻击方式不同,但都杀伤力巨大。今天我们就来认识它们,然后学会怎么防。

一、XSS:跨站脚本攻击,你的网站被人“投毒”了

XSS(Cross-Site Scripting)的意思是:攻击者在你的网页里注入恶意脚本,当其他用户访问时,这个脚本就会在用户浏览器里执行,偷Cookie、发请求、改页面内容。

反射型XSS:恶意链接里的“定时炸弹”

攻击者把一个带恶意参数的链接发给你,你一点,网站把参数原样输出到页面上,脚本就执行了。

比如一个搜索页面:https://example.com/search?q=<script>alert('XSS')</script>。如果网站直接输出q参数的内容,就会弹出弹窗。

危害:偷Cookie、钓鱼、跳转恶意网站。

存储型XSS:留言板里的“慢性毒药”

更可怕的是存储型。攻击者在评论区、个人简介等地方写入恶意脚本,网站把它存进数据库。每个访问这个页面的用户,都会执行这个脚本。

比如你在博客评论区写<script>fetch('http://evil.com?cookie='+document.cookie)</script>,博主和所有读者看评论时,Cookie就被发送给攻击者了。

危害:持久化,感染所有访客。

DOM型XSS:不经过服务器的“内鬼”

这种XSS不经过服务器,完全由前端JS不安全地操作DOM导致。比如从URL参数取内容直接innerHTML

// 危险代码
const name = new URL(location.href).searchParams.get('name');
document.getElementById('welcome').innerHTML = `Hello ${name}`;

攻击者构造?name=<img src=x onerror=alert(1)>,脚本执行。

怎么防XSS?

  1. 永远不要信任用户输入。任何用户可控制的数据(URL参数、表单、请求头),输出到HTML前都要转义
// 简单转义函数
function escapeHtml(str) {
  return str.replace(/[&<>]/g, function(m) {
    if (m === '&') return '&amp;';
    if (m === '<') return '&lt;';
    if (m === '>') return '&gt;';
  });
}
  1. 使用安全的APItextContent代替innerHTMLsetAttribute代替拼接HTML。
// 安全
element.textContent = userInput;
// 危险
element.innerHTML = userInput;
  1. CSP(内容安全策略):通过HTTP头限制哪些脚本可以执行。比如禁止内联脚本、只允许白名单域名。
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com
  1. 使用框架的自动转义:React、Vue等默认会转义输出,但要注意v-htmldangerouslySetInnerHTML等危险操作。

  2. HttpOnly Cookie:标记HttpOnly的Cookie无法被JS读取,即使有XSS也偷不走。但注意,这只能防偷Cookie,不能防其他恶意操作。

二、CSRF:跨站请求伪造,有人冒充你干坏事

CSRF(Cross-Site Request Forgery)的意思是:攻击者诱导你访问一个恶意网站,这个网站偷偷向你的目标网站(比如银行、微博)发起请求,由于你之前登录过,浏览器会自动带上Cookie,目标网站以为是你本人的操作。

一个典型的CSRF攻击

  1. 你登录了银行网站bank.com,浏览器存了Cookie。
  2. 你访问了恶意网站evil.com
  3. evil.com里有一张图片<img src="https://bank.com/transfer?to=attacker&amount=10000">
  4. 浏览器加载图片时,向bank.com发起请求,自动带上你的Cookie。
  5. 银行验证了Cookie,以为是你在转账,扣了你的钱。

危害:修改密码、发帖、转账、删数据……一切你权限内的操作。

怎么防CSRF?

  1. CSRF Token:服务器生成一个随机Token,存在表单的隐藏字段或请求头里。提交时校验Token,攻击者无法获取Token(因为跨域限制)。
<form>
  <input type="hidden" name="_csrf" value="随机字符串">
  ...
</form>
  1. SameSite Cookie:设置Cookie的SameSite属性为StrictLax,禁止第三方请求携带Cookie。
Set-Cookie: sessionId=abc123; SameSite=Strict
  • Strict:任何跨站请求都不带Cookie。
  • Lax:部分安全的跨站请求(如链接跳转)带Cookie,但POST表单不带。
  1. 验证Referer/Origin:服务器检查请求头中的RefererOrigin,确保来自你自己的域名。但Referer可能被篡改或缺失,不如Token可靠。

  2. 使用自定义请求头:比如X-Requested-With: XMLHttpRequest,因为跨域请求不能随意设置自定义头(需要CORS),可以作为一种简单校验(但也能被绕过,最好配合Token)。

  3. 敏感操作二次验证:修改密码、转账等操作,要求输入密码或短信验证码。

三、XSS和CSRF的“狼狈为奸”

更可怕的是,XSS和CSRF经常联手:先用XSS注入脚本,脚本里发起CSRF攻击。比如在留言板注入<script>fetch('/transfer?to=evil&amount=10000')</script>,每个看留言的人都成了受害者。

所以防御要层层设防:XSS防注入,CSRF防伪造。

四、实战:一个安全的评论显示组件

// 安全地渲染用户评论
function renderComment(comment) {
  const div = document.createElement('div');
  // 用textContent而不是innerHTML
  div.textContent = comment.text;
  // 如果要显示链接,需要单独处理
  return div;
}

对于后端,输出到HTML时也要转义:

<?php echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'); ?>

五、总结:安全三字经

  • 防XSS:转义输出,CSP,HttpOnly。
  • 防CSRF:Token,SameSite,验证Referer。
  • 通用:不要信任用户输入,最小权限原则。

前端安全不是只有大厂才要考虑。你写的一个小博客、一个留言板,都可能被坏人利用。养成良好的安全习惯,比出事后再补窟窿强一百倍。


如果你觉得今天的“安全课”够警醒,点个赞让更多人看到。明天我们将聊聊前端监控与错误上报——怎么第一时间发现线上的Bug,而不是等用户骂你。我们明天见!

手写 instanceof:从原型链聊聊 JS 的实例判断

作者 暗不需求
2026年4月14日 19:10

大家好,我是平时爱折腾前端JavaScript的小伙。最近在看 JS 继承和原型相关的东西并且进行学习,发现我身边的人学习(包括我自己以前) instanceof 的理解还停留在“能判断对象是不是某个类的实例”这个表面。根据师兄的口述,仅仅了解这些是不够面试的。今天就借着这个机会,结合实际代码,一步步手写一个 instanceof,顺便把原型链、继承这些概念也捋清楚。

先说说为什么需要 instanceof

在大项目里,尤其是多人协作的时候,你经常会拿到一个对象,却不知道它到底是从哪个构造函数来的,有哪些方法和属性可用。这时候 instanceof 就特别实用——它就像其他面向对象语言里的“类型检查”运算符,能快速告诉你“这个对象是不是某个类的实例”。

简单说,A instanceof B 的本质就是:A 的原型链上有没有 B 的 prototype。如果有,就返回 true;没有,就 false

这不是 JS 独有的概念,很多 OOP 语言都有类似的机制,但 JS 是基于原型的,所以它的实现特别“接地气”——全靠那条 __proto__ 链。

原型和原型链是什么?

先用一个最常见的例子感受一下(来自 Array):

<script>
const arr = []; // 其实就是 new Array()
console.log(arr.__proto__, arr.__proto__.constructor, arr.constructor);
console.log(arr.__proto__.__proto__,
  arr.__proto__.__proto__.constructor,
  arr.__proto__.__proto__.__proto__,
  arr.__proto__.__proto__.__proto__.__proto__);
</script>

你会看到:

  • arr.__proto__ 指向 Array.prototype
  • Array.prototype.__proto__ 又指向 Object.prototype
  • 最后 Object.prototype.__proto__null,链条结束

这就是原型链:每个对象都有一个 __proto__ 属性(隐式原型),它指向自己构造函数的 prototype(显式原型)。沿着这条链一直往上找,就能找到所有能用的属性和方法(包括 toStringhasOwnProperty 这些)。

理解了这条链,instanceof 就很好解释了。

原生的 instanceof 是怎么工作的?

看下面这个经典的继承例子:

function Animal() {}
function Person() {}

Person.prototype = new Animal();
const p = new Person();

console.log(p instanceof Person);  // true
console.log(p instanceof Animal);  // true

pPerson 的实例,它的原型链上是 Person.prototype → Animal.prototype → Object.prototype → null,所以它既是 Person 的实例,也是 Animal 的实例。

手写一个 isInstanceOf

现在我们来自己实现一个。核心思路就一句话:从 left 的 __proto__ 开始,一路往上找,看能不能找到 right.prototype

完整代码如下(直接复制就能跑):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>手写 instanceof</title>
</head>
<body>
<script>
// B 是否出现在 A 的原型链上
function isInstanceOf(left, right) {
  // 防止 right 不是函数
  if (typeof right !== 'function') {
    return false;
  }
  
  let proto = left.__proto__;
  while (proto) {
    if (proto === right.prototype) {
      return true;
    }
    proto = proto.__proto__; // 继续往上找,直到 null
  }
  return false;
}

function Animal() {}
function Cat() {}
Cat.prototype = new Animal();
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();

console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true
console.log(isInstanceOf(dog, Object));  // true
console.log(isInstanceOf(dog, Cat));     // false
</script>  
</body>
</html>

这个函数和原生的 instanceof 行为几乎一致。注意两点小细节:

  1. 我们加了个 typeof right !== 'function' 的防护,防止传进来奇怪的东西报错。
  2. 循环结束条件是 proto 变成 null,这正是原型链的终点。

结合继承方式再看 instanceof

instanceof 最常出现在继承场景里。我们来对比几种常见的继承写法,看看它在每种方式下的表现。

1. 构造函数绑定继承(call/apply)

function Animal() {
  this.species = '动物';
} 
function Cat(name, color) {
  Animal.apply(this);  // 把 Animal 的属性绑到 this 上
  this.name = name;
  this.color = color;
}

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // 动物

这种方式只继承了属性,没有把原型链连起来。所以 cat instanceof Animal 会是 false。如果你需要原型方法,就得配合后面两种方式用。

2. prototype 模式(推荐)

function Animal() {
  this.species = '动物';
}
function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 关键两步
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向

const cat = new Cat('小黑', '黑色');
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true

这里 Cat.prototype 直接指向一个 Animal 实例,原型链就连上了。记得一定要把 constructor 指回来,不然 cat.constructor 会指向 Animal,容易出 bug。

3. 直接继承 prototype(有坑)

function Animal() {}
Animal.prototype.species = '动物';

function Cat(name, color) {
  Animal.call(this);
  this.name = name;
  this.color = color;
}

Cat.prototype = Animal.prototype; // 直接引用
Cat.prototype.constructor = Cat;
Cat.prototype.sayHello = function() {
  console.log('hello');
};

const cat = new Cat('小黑', '黑色');
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true
console.log(Animal.prototype.constructor); // 变成了 Cat(副作用!)

这种写法性能好(不用 new 一个 Animal 实例),但会污染父类的 prototype。如果你在 Cat.prototype 上加方法,Animal 也能拿到,容易出意外。实际项目里还是推荐用第 2 种,或者用 Object.create(Animal.prototype) 做中介(空对象继承)。

结尾

手写 instanceof 其实就这么简单,核心就是遍历原型链。写完之后,你会对 JS 对象“到底是谁生的”这件事有更直观的理解。在大型项目里,它能帮你快速做类型守护、写工具函数,或者在框架里判断组件类型。

当然,原生 instanceof 已经够用了,我们手写主要是为了加深理解。下次再遇到“这个对象为啥有这个方法”“继承关系乱了”的时候,你就可以顺着 __proto__ 链自己排查了。

如果你也正在看原型链和继承,欢迎评论区一起讨论~代码我都放上去了,直接复制就能跑。希望这篇文章能让你少踩几个坑!并且希望你在面试的时候能拿下instanceof这一难点。早日拿下offer!

从网关的角度理解并实现一个 Mini OpenClaw

作者 Cobyte
2026年4月15日 08:41

1. 前言

OpenClaw 与其他 AI Agent 最本质的区别是什么?首先,OpenClaw 本身也是一个 AI Agent,但关键在于它能连接多种 IM 渠道,并利用这些 IM 工具提供的开发能力来调用自身的 Agent——这种能力被称为“网关”。因此,有后端的技术大咖将 OpenClaw 总结为:OpenClaw = 高权限 AI Agent + 网关

所以只有理解了 OpenClaw 的本质之后,我们才可以实现一个 Mini OpenClaw。

首先我们要实现一个网关,那么网关是什么呢?

网关对于后端的同学来说,肯定不陌生。在 Spring Boot 微服务架构中,API 网关已成为标准的基础设施组件,其核心作用与 OpenClaw 中的“网关”如出一辙:对外隐藏后端的实现细节(服务地址、版本、熔断等),对内统一通信协议,并提供横切能力(如鉴权、限流、日志等) 。两者的区别仅在于作用对象不同——OpenClaw 的网关面向 IM 渠道(消息协议适配),而后端网关面向 HTTP/RPC 调用(协议转换与流量管理)。

所以 OpenClaw 的所谓网关就是一个消息协议适配器。

所以我们先要实现网关最核心的功能:协议适配。这是网关最本质的能力——对外讲 IM 的方言,对内统一说普通话。

2. 网关核心功能:协议适配

不同 IM(飞书、微信 等)的消息格式千差万别:有的用 user_id,有的用 from 字段,有的消息正文可能嵌套在 text 或 message 对象中。我们可以通过设计一个消息协议将这些差异全部“抹平”,这样本地 AI Agent 就只依赖这标准消息协议,无需关心消息来自哪个渠道。

设计一个入站的消息对象 InboundMessage:

# events.py
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class InboundMessage:
    """从聊天频道接收到的消息"""
    channel: str  # 用于区分来源,后续发送回复时需要知道应该调用哪个 IM 的 API(feishu、wechat)
    sender_id: str  # 用户标识符
    chat_id: str  # 聊天/频道标识符
    content: str  # 消息文本
    timestamp: datetime = field(default_factory=datetime.now)  # 消息时间

这样新增一个 IM 渠道时,只需要写一个适配器将私有消息转换成 InboundMessage 即可,其余代码零改动。

简而言之:设计 InboundMessage 就是为了让网关“对外讲方言,对内讲普通话”,所有渠道的消息到达网关后立刻被标准化,Agent 只需处理这一种标准格式。

同样地不同 IM 的发送接口千差万别:飞书需要 receive_id,微信需要 touser,Telegram 需要 chat_id。通过设计一个 OutboundMessage 消息对象,这样 Agent 只需要产出 channelchat_idcontent 三个核心字段,网关再根据 channel 值调用对应的 IM 适配器,由适配器负责转换成目标 IM 的私有请求格式即可。

OutboundMessage 消息对象的字段设计如下:

# events.py
@dataclass
class OutboundMessage:
    """要发送到聊天频道的消息"""
    
    channel: str
    chat_id: str
    content: str
    reply_to: str | None = None # 支持引用回复,用于指明当前回复的是哪一条历史消息

网关的输入是 InboundMessage,输出是 OutboundMessage,这样本地 AI Agent 核心只处理这两种标准格式信息,完全不依赖任何 IM 私有 API。这使得添加新 IM 渠道变得非常简单:只需要写一个适配器,将 InboundMessage 解析出来,并将 OutboundMessage 转换成该 IM 的发送请求即可。因为本地 AI Agent 完全不知道自己在和谁在交互,它只看到 InboundMessage/OutboundMessage,这正是网关隐藏后端实现细节的精髓,也是网关本质的体现

3. 网关内部路由:统一通信总线

根据前面的设计,我们已经将各个 IM 渠道的消息统一成了 InboundMessage,并将 Agent 的回复统一成了 OutboundMessage。但仅仅统一格式还不够,还需要解决一个核心问题:多个渠道的消息并发涌入,而 Agent 的处理可能是同步/半异步的,如何让它们有序、可靠、不互相阻塞?

这就需要一个统一通信总线——本质上是一个轻量级的内部消息路由。而最经典、最可靠的实现方式就是双队列解耦

入站异步队列: 渠道 → Agent
出站异步队列: Agent → 渠道

通过双队列把网关内部的“消息流动”标准化为两个 FIFO 管道:

  • 入站异步队列:所有 IM 渠道的消息汇聚点,Agent 从这头取“原材料”。
  • 出站异步队列:所有回复的汇聚点,分发器从这头取“成品”并发送。

为什么需要这样设计?

每个 IM 渠道(飞书、微信等)都有自己的 Webhook 或长连接,当瞬间收到大量消息(例如群聊刷屏)时,如果直接在回调中同步调用 Agent,Agent 处理耗时较长,会导致 Webhook 超时、连接堆积,甚至被 IM 服务器屏蔽。

我们让每个渠道适配器只做最轻量的事情,每当接收到消息时,就只需要解析消息、封装成上述设计的 InboundMessage,然后立即推送到入站异步队列中,马上返回返回即可。而 Agent 的处理则由一个独立的后台协程从入站异步队列中拉取,这样生产者和消费者的速度完全解耦。即使 Agent 处理得慢,队列也能起到“缓冲”作用,不会丢消息。

同时 Agent 只产出上述设计的 OutboundMessage 的数据并推送到出站异步队列中。另一个独立的分发器协程从出站异步队列中取出消息,找到对应的渠道适配器,调用该适配器的发送方法进行发送消息。这样一来,Agent 完全不需要知道消息要发往哪里、怎么发,路由逻辑全封装在网关内部。

统一通信总线代码实现如下:

# message_bus.py
"""用于解耦频道与智能体通信的异步消息队列"""
import asyncio
from loguru import logger
from events import InboundMessage, OutboundMessage

class MessageBus:
    """
    异步消息总线,用于将聊天频道与智能体核心解耦。
    频道将消息推送到入站队列,智能体处理它们并将响应推送到出站队列。
    """
    def __init__(self):
        # 入站异步队列
        self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
        # 出站异步队列
        self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
    
    async def publish_inbound(self, msg: InboundMessage) -> None:
        """将来自频道的消息发布给智能体"""
        await self.inbound.put(msg)
    
    async def consume_inbound(self) -> InboundMessage:
        """消费下一条入站消息(阻塞直到有消息可用)"""
        return await self.inbound.get()
    
    async def publish_outbound(self, msg: OutboundMessage) -> None:
        """将智能体的响应发布给频道"""
        await self.outbound.put(msg)
    
    async def consume_outbound(self) -> OutboundMessage:
        """消费下一条出站消息(阻塞直到有消息可用)"""
        return await self.outbound.get()

同时入站异步队列和出站异步队列通过 asyncio.Queue 提供。asyncio.Queue 是异步编程中实现生产者-消费者模式的标准工具,它让不同协程之间可以安全、非阻塞地交换数据。在我们上述网关的设计中,正是依赖它实现了入站/出站双队列解耦,从而让多个 IM 渠道可以并发接收消息,同时 Agent 通过并发处理消息,实现效率提高。没有它,你就得自己用锁和条件变量实现类似功能,既复杂又容易出错。

接着我们修改上一篇文章《如何使用飞书机器人连接本地 AI Agent》中实现的飞书连接本地 AI Agent 的飞书频道,实现将来自飞书的消息转发到通信总线。

# feishu.py
+ from events import InboundMessage
+ from message_bus import MessageBus

class FeishuChannel:
    """极简版飞书 WebSocket 长连接机器人"""
+    name = "feishu"
    def __init__(self, config: FeishuConfig, bus: MessageBus):
        self.config = config
        self.bus = bus
        # 省略...

    async def start(self) -> None:
        # 省略...
-    def _on_message(self, data: P2ImMessageReceiveV1) -> None:
+    async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
        """接收到消息时的回调"""
        msg = data.event.message
+        sender = data.event.sender
        # 只处理用户发送的纯文本消息
        if data.event.sender.sender_type == "bot" or msg.message_type != "text":
            return

        content = json.loads(msg.content).get("text", "")
        if not content:
            return
        
+        # 提取发送者信息
+        sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
+        # 获取用于回复的 chat_id
+        chat_id = msg.chat_id
+        chat_type = msg.chat_type  # "p2p" 或 "group"
+        reply_to = chat_id if chat_type == "group" else sender_id
+        # 将消息转发到总线
+        await self._handle_message(
+            sender_id=sender_id,
+            chat_id=reply_to,
+            content=content,
+        )
-        # 启动独立线程处理 AI 逻辑并回复,防止阻塞 WebSocket 接收循环
-        # threading.Thread(
-        #     target=self._process_and_reply, 
-        #     args=(msg.chat_id, content)
-        # ).start()

+    async def _handle_message(
+        self,
+        sender_id: str,
+        chat_id: str,
+        content: str,
+    ) -> None:
+        """
+        处理来自聊天平台的传入消息。
+        此方法将消息转发到总线。
        
+        参数:
+            sender_id: 发送者的标识符。
+            chat_id: 聊天/通道的标识符。
+            content: 消息文本内容。
+        """
        
+        msg = InboundMessage(
+            channel=self.name,
+            sender_id=str(sender_id),
+            chat_id=str(chat_id),
+            content=content
+        )
        
+        await self.bus.publish_inbound(msg)

现在我们已经将飞书发过来的消息推送到通信总线中了,接着我们需要在 Agent 异步处理协程中循环读取总线中的消息进行处理了。

4. 实现并发 Agent Loop

我们上文讲到了通过 asyncio.Queue 实现了入站/出站双队列解耦,从而让多个 IM 渠道可以并发接收消息,同时 Agent 通过并发处理消息,实现效率提高。

但我们前面实现的 Agent Loop 的同步处理数据,所以我们需要重新设计并实现我们的 Agent Loop。

首先我们这个 Agent Loop 需要具备以下功能点:

  1. 持续运行:只要网关没有关闭,Agent Loop 就要一直工作,不能退出。
  2. 响应及时:当有新消息到达时,应尽快开始处理,避免不必要的延迟。
  3. 可优雅停止:外部可以调用 stop() 方法,让循环在安全时机退出,而不是强制杀死协程。
  4. 容错性:单条消息处理失败不应导致整个循环崩溃,并且要能告知用户出错。

那么第一个功能点持续运行,我们可以通过使用一个布尔标志控制循环是否继续。

self._running = True
while self._running:
    # 只要 self._running = True 就一直循环读取通讯总线中的消息进行处理

这样只要 self._running = True 就一直循环读取通讯总线中的消息进行处理。同时我们设计一个 stop() 方法设置 self._running = False,这样外部协程就可以调用 stop() 使得循环将在下一次条件判断时退出。

在读取通讯总线中的消息时,我们需要通过 asyncio.wait_for 实现可中断阻塞读取。即如下实现:

self._running = True
while self._running:
    # 只要 self._running = True 就一直循环读取通讯总线中的消息进行处理
    msg = await asyncio.wait_for(
        self.bus.consume_inbound(), # 本质是 await inbound_queue.get()
        timeout=1.0,
    )

如果不使用 asyncio.wait_for 而是直接使用 await self.bus.consume_inbound() 的话,没有消息就一直等着,那么循环永远不会走到 while self._running 的条件判断。此时调用 stop() 设置 self._running = False 是无效的,因为协程卡在 get() 上,永远没有机会检查 self._running 标志。

而使用 asyncio.wait_for 并设置超时为 1 秒,也就是如果 1 秒内返回了消息,就正常得到 msg。如果 1 秒后队列仍为空,wait_for 会抛出 asyncio.TimeoutError。这样,协程最多阻塞 1 秒就会醒来一次,重新检查 while self._running。因此,即使没有消息,循环也能每秒检查一次退出标志,实现可中断的阻塞读取

根据上述设计我们初步实现 Agent Loop 如下:

import asyncio
import json
import os
from typing import Any

from dotenv import load_dotenv
from loguru import logger
from openai import AsyncOpenAI

from events import InboundMessage, OutboundMessage
from message_bus import MessageBus

load_dotenv()

class AgentLoop:
    def __init__(
        self,
        bus: MessageBus,
        max_iterations: int = 200,
        api_key: str | None = None,
        base_url: str = "https://api.deepseek.com",
        model: str = "deepseek-chat",
    ):
        self.bus = bus
        # 最大工具调用轮次,防止死循环
        self.max_iterations = max_iterations
        self.model = model
        self._running = False
        # 初始化 OpenAI异步客户端 兼容客户端(如 DeepSeek)
        self.client = AsyncOpenAI(
            api_key=api_key or os.getenv("DEEPSEEK_API_KEY"),
            base_url=base_url,
        )

    # ------------------------------------------------------------------
    # 主循环:持续消费 入站异步队列
    # ------------------------------------------------------------------

    async def run(self) -> None:
        """运行智能体循环,处理来自总线的消息。"""
        self._running = True
        logger.info("Agent loop started")

        while self._running:
            try:
                # 从入站队列消费下一条消息,设置超时以便能定期检查 _running 标志
                msg = await asyncio.wait_for(
                    self.bus.consume_inbound(),
                    timeout=1.0,
                )
                try:
                    # 处理消息并获取响应
                    response = await self._process_message(msg)
                    if response:
                        # 将响应发布到出站队列
                        await self.bus.publish_outbound(response)
                except Exception as e:
                    logger.error(f"Error processing message: {e}")
                    await self.bus.publish_outbound(
                        OutboundMessage(
                            channel=msg.channel,
                            chat_id=msg.chat_id,
                            content=f"抱歉,处理消息时出错:{e}",
                        )
                    )
            except asyncio.TimeoutError:
                continue

    def stop(self) -> None:
        """停止智能体循环。"""
        self._running = False
        logger.info("Agent loop stopping")

上述的 run 方法需要在一开始就启动,这样才可以实现一有消息就马上处理,而不会漏消息。我们把上一篇讲解实现飞书接入本地 AI Agent 的启动文件 test_feishu.py 重命名为 gateway.py,也就是网关的意思,并且修改其中的启动代码:

+ from message_bus import MessageBus
+ from loop import AgentLoop
async def main():
    # 1. 填入你的飞书机器人凭证
    config = FeishuConfig(
        app_id="xxx",         # 替换为真实的 App ID
        app_secret="xxx",    # 替换为真实的 App Secret
        encrypt_key="",                      # 如果飞书后台配置了 Encrypt Key 则填入,否则留空
        verification_token=""                # 如果配置了 Verification Token 则填入,否则留空
    )
+    deepseek_key = os.getenv("DEEPSEEK_API_KEY", "")
+    bus = MessageBus()
+    agent = AgentLoop(
+        bus=bus,
+        api_key=deepseek_key,
+        base_url="https://api.deepseek.com",
+        model="deepseek-chat",
+        max_iterations=20,
+    )
    
    # 2. 初始化频道并启动长连接
-    channel = FeishuChannel(config=config)
+channel = FeishuChannel(config=config, bus=bus)
    
    logger.info("正在启动飞书机器人长连接...")
    
-    # 3. 启动并保持运行
+    # 3. 并发运行
    try:
-        await channel.start()
+        await asyncio.gather(
+            agent.run(),          # 持续消费 inbound 队列,调用 LLM
+            channel.start(),      # 飞书启动
+        )
    except KeyboardInterrupt:
        logger.info("收到退出信号,正在关闭...")

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

通过上述修改我们就实现了 Agent 和飞书频道在初始化的时候并发运行,从而实现了一开始就监听入站异步队列的消息。

上述 Agent Loop 的 self._process_message 方法是还没实现的,所以我们继续实现 Agent 对消息的处理。本质就是实现大模型的工具调用循环。

在实现 Agent 对消息的处理之前,我们先要重新设计一下会话历史。

5. 会话历史设计

在前面的文章中我们的会话历史就是一个数组,结构如下:

history = [
    {"role": "system", "content": getattr(agent, "SYSTEM", "你是一个助手")},
    {"role": "user", "content": content}
]

后续如果继续有消息就根据角色往数组 history 中追加用户消息和助手消息即可。

但在 OpenClaw 中需要保证不同渠道、不同群、不同用户的历史会话完全隔离。我们可以使用 dict[str, list[dict]] 作为存储结构,相当于在 JavaScript 中设置一个对象,然后通过 key 作为唯一标识进行会话隔离。

key 设计:

这个 key 我们可以设置由 channel + chat_id 组合而成,例如 "feishu:oc_xxx"。然后我们在之前设计的 InboundMessage 对象中设置一个 session_key 方法用于返回会话唯一标识。设置如下:

@dataclass
class InboundMessage:
    # 省略...
    
+    @property
+    def session_key(self) -> str:
+        """用于会话标识的唯一键"""
+        return f"{self.channel}:{self.chat_id}"

value 设计:

value 其实就是上述的历史会话数组,即:

[
    {"role": "system", "content": getattr(agent, "SYSTEM", "你是一个助手")},
    {"role": "user", "content": content}
]

同时我们设计一个 _get_history 的函数来实现对会话历史的懒加载,如果 session_key 不存在,自动创建新列表并插入 system prompt,如果 session_key 存在则返回内部列表的直接引用,调用方可以修改它,即追加消息。这样设计可以避免拷贝带来的性能开销。

实现如下:

# ---------- 会话历史管理(按 session_key 隔离) ----------
# 全局字典:存储所有会话的对话历史
# - Key: session_key,用于唯一标识一个会话(例如 "feishu:chat_id")
# - Value: 消息列表,每个元素是 OpenAI API 兼容的消息字典(包含 role, content 等字段)
_sessions: dict[str, list[dict]] = {}

# 系统提示词:定义 AI 助手的角色、能力和行为准则
SYSTEM_PROMPT = (
    "你是一个智能助手,可以通过工具帮助用户完成任务。"
    "请简洁、准确地回答用户问题。"
)
# 获取会话历史
def _get_history(session_key: str) -> list[dict]:
    # 若为新会话,自动初始化一条包含 system prompt 的消息
    if session_key not in _sessions:
        _sessions[session_key] = [{"role": "system", "content": SYSTEM_PROMPT}]
    # 返回该会话的历史列表(引用,允许外部修改)
    return _sessions[session_key]

6. Agent Loop 的核心:消息处理

在完成了会话历史管理和主循环的可中断阻塞读取之后,Agent Loop 最核心的部分就是 单条消息的处理逻辑——即 _process_message 方法。该方法实现了 ReAct(推理+行动)模式:调用 LLM → 若需要工具则执行工具 → 将结果返回 LLM → 重复直到得到最终答案。下面详细解析其实现:

class AgentLoop:
    # 省略...

    # ------------------------------------------------------------------
    # 单条消息处理:tool-call 循环
    # ------------------------------------------------------------------
    async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
        # 1. 获取当前会话的历史,并追加用户消息
        messages = _get_history(msg.session_key)
        messages.append({"role": "user", "content": msg.content})

        final_content: str | None = None
        # 2. 进入工具调用循环(最多 max_iterations 次)
        for iteration in range(self.max_iterations):
            # 3. 调用 LLM(异步非阻塞)
            response = await self.client.chat.completions.create(
                model=self.model,
                messages=messages, 
                tools=TOOLS,
                tool_choice="auto",
            )
            assistant_msg = response.choices[0].message

            # 将助手消息追加到历史
            messages.append(assistant_msg)

            # 4. 如果没有 tool_calls,说明任务完成
            if not assistant_msg.tool_calls:
                final_content = assistant_msg.content or ""
                break

            # 5. 执行所有工具调用,并将结果以 role=tool 追加到历史记录
            for tool_call in assistant_msg.tool_calls:
                name = tool_call.function.name
                args = json.loads(tool_call.function.arguments)
                logger.debug(f"Executing tool: {name}, args: {args}")

                result = _execute_tool(name, args)
                logger.debug(f"Tool result: {result[:100]}")

                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "name": name,
                        "content": result,
                    }
                )
        else:
            # 达到最大迭代次数
            final_content = "已达到最大处理轮次,无法给出最终答案。"

        if final_content is None:
            final_content = "处理完成,但没有内容返回。"
        # 6. 构造出站消息返回给用户
        return OutboundMessage(
            channel=msg.channel,
            chat_id=msg.chat_id,
            content=final_content,
        )

上述代码的实现跟我们前面文章实现 Agent Loop 是一样的,所以大家还有不懂的话,可以回看前面文章的详细解析。最最重要的就是最后返回了构造了 OutboundMessage 格式的出站消息,然后在 run 方法中通过 self.bus.publish_outbound(response) 将消息发布到出站队列。

其中工具定义实现如下:

# ---------- 内置工具定义 ----------
TOOLS: list[dict] = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取本地文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "文件路径"},
                    "encoding": {
                        "type": "string",
                        "enum": ["utf-8", "gbk"],
                        "description": "文件编码,默认 utf-8",
                    },
                },
                "required": ["path"],
            },
        },
    }
]

def _execute_tool(name: str, arguments: dict) -> str:
    """同步执行内置工具,返回字符串结果。"""
    if name == "read_file":
        from pathlib import Path

        path = arguments.get("path", "")
        encoding = arguments.get("encoding", "utf-8")
        try:
            p = Path(path).expanduser()
            if not p.exists():
                return f"❌ 文件不存在: {path}"
            return p.read_text(encoding=encoding)
        except Exception as e:
            return f"❌ 读取失败: {e}"
    return f"❌ 未知工具: {name}"

我们这里先只实现一个读取文件内容的工具,后续再实现更多的工具。

7. 构建网关的渠道层

7.1 为什么需要渠道层?

在上一小节中,我们实现在 Agent 中构造了 OutboundMessage 格式的出站消息,然后将消息发布到出站队列中。但还缺少关键的一环:出站异步队列中的消息由谁来消费?如何将 Agent 的回复正确地发送回原来的聊天频道?

我们知道每个即时通讯平台都有自己独特的 API 协议,如果让 Agent 直接处理这些差异,会导致 Agent 逻辑中混杂大量渠道特定代码,每增加一个渠道就要修改 Agent 核心逻辑,这会造成维护噩耗。

所以我们需要构建一个 渠道管理器(ChannelManager),作为网关的出站交通枢纽,负责管理所有 IM 适配器的生命周期,并将出站消息路由到正确的渠道。具体需要实现以下功能:

  1. 注册与管理渠道实例

    • 运行时动态注册各个渠道
    • 维护渠道状态信息
    • 提供统一的渠道访问接口
  2. 协调启动与停止流程

    • 控制渠道启动顺序,避免竞态条件
    • 实现优雅停止,防止消息丢失
    • 处理异常情况下的资源清理
  3. 消息路由与派发

    • 根据消息的 channel 字段路由到正确渠道
    • 调用渠道的发送方法
    • 实现错误隔离和重试机制

7.2 渠道层的设计与实现

如果把整个网关系统比作一个繁忙的交通枢纽,那么渠道层就是站在十字路口中央的交警。它不亲自运送货物,但指挥着所有运输车辆有序通行。

具体来说,渠道层连接着:

  • 上游:内部消息总线(MessageBus),接收标准化的出站消息
  • 下游:各个 IM 渠道适配器(FeishuChannel、WechatChannel 等)

我们先实现一个 ChannelManager 类,并实现数据结构与初始化。代码如下:

import asyncio
from loguru import logger
from message_bus import MessageBus
from feishu import FeishuChannel


class ChannelManager:
    def __init__(self, bus: MessageBus):
        self.bus = bus
        # 存储已注册的渠道适配器,key 为渠道名称(如 "feishu")
        self.channels: dict[str, FeishuChannel] = {}
        # 出站分发器的任务句柄,用于优雅停止
        self._dispatch_task: asyncio.Task | None = None

ChannelManager 的核心数据结构 channels 是一个字典: channel_name → 适配器实例

  • Key = 渠道名称(如 "feishu"、"wechat")
  • Value = 渠道实例对象

这个设计实现了运行时动态注册,可以在不重启服务的情况下添加新渠道。

接着我们来实现注册渠道功能:

class ChannelManager:
    def __init__(self, bus: MessageBus):
        # 省略...
    def register(self, channel: FeishuChannel) -> None:
        """注册一个渠道适配器。要求该适配器必须有 name 属性和 send 方法。"""
        self.channels[channel.name] = channel
        logger.info(f"Channel registered: {channel.name}")

上述注册渠道的代码实现看起很简单,其实背后的设计原理一点也不简单。它应用了工厂模式 + 依赖注入的设计模式。

  1. 工厂模式体现在:渠道的创建由外部完成,ChannelManager 只负责使用
  2. 依赖注入体现在:渠道实例通过 register() 方法注入,而非在 ChannelManager 内部创建

我们已经实现了一个飞书渠道 FeishuChannel,所以现在需要通过以下方式进行注册飞书渠道:

manager.register(FeishuChannel(...))

同时将来如果我们想新增一个微信渠道,就可以这样实现了,先实现一个 WechatChannel,然后:

manager.register(WechatChannel(...))

这样网关核心代码零改动,真正实现了"开闭原则":对扩展开放,对修改关闭。

接着实现启动所有已注册的频道以及出站分发器。

代码实现如下:

class ChannelManager:
    def __init__(self, bus: MessageBus):
        # 省略...
    def register(self, channel: FeishuChannel) -> None:
        # 省略...
    async def start_all(self) -> None:
        """启动所有已注册的频道以及出站分发器。"""
        if not self.channels:
            logger.warning("No channels registered")
            return

        # 先启动出站分发器协程(确保一有出站消息就能被处理)
        self._dispatch_task = asyncio.create_task(self._dispatch_outbound())

        # 并发启动所有渠道(每个渠道的 start 方法负责建立长连接或监听 Webhook)
        tasks = []
        for name, channel in self.channels.items():
            logger.info(f"Starting {name} channel...")
            tasks.append(asyncio.create_task(channel.start()))

        # 注意:通常渠道的 start 会永久阻塞(如 WebSocket 循环),因此 gather 不会返回
        await asyncio.gather(*tasks, return_exceptions=True)

我们上述的代码实现了一个看似简单却至关重要的设计决策,就是先启动分发器再启动渠道。那么为什么先启动分发器再启动渠道呢?

主要是为了防止消息丢失与响应延迟。让我们分析两种启动顺序的后果:

场景 A:先启动渠道,后启动分发器 时间线:

  1. 飞书渠道启动成功 ✓
  2. 用户立即发送消息:"你好"
  3. Agent 快速处理,生成回复:"你好!我是AI助手"
  4. 回复进入出站队列...
  5. 但是!分发器还没启动 ❌
  6. 回复消息在队列中堆积
  7. 用户等待...等待...(用户体验差)

场景 B:先启动分发器,后启动渠道(我们采用的方式) 时间线:

  1. 分发器启动,开始监听出站队列 ✓
  2. 飞书渠道启动成功 ✓
  3. 用户发送消息:"你好"
  4. Agent 处理,生成回复:"你好!我是AI助手"
  5. 回复进入出站队列
  6. 分发器立即发现新消息 ✓
  7. 路由到飞书渠道,立即发送 ✓
  8. 用户秒级收到回复(体验流畅)

在实际的生产环境经验中,"空转等待"比"忙中丢消息"要好得多。分发器提前就位,就像快递员提前在仓库门口等待,包裹一出来就能立即配送。

接着我们实现出站消息分发器

代码实现如下:

class ChannelManager:
    def __init__(self, bus: MessageBus):
        # 省略...
    def register(self, channel: FeishuChannel) -> None:
        # 省略...
    async def start_all(self) -> None:
        # 省略...
    async def _dispatch_outbound(self) -> None:
        """
        出站分发器:持续消费 outbound 队列,将消息发送到对应的渠道。
        这是一个后台协程,在 start_all 时启动。
        """
        logger.info("Outbound dispatcher started")

        while True:
            try:
                # 可中断阻塞读取,每隔1秒检查一次取消信号
                msg = await asyncio.wait_for(
                    self.bus.consume_outbound(),
                    timeout=1.0,
                )
                # 根据消息中的 channel 字段找到对应的适配器
                channel = self.channels.get(msg.channel)
                if channel:
                    try:
                        # 调用适配器的 send 方法(各渠道自己实现转换和发送逻辑)
                        await channel.send(msg)
                    except Exception as e:
                        logger.error(f"Error sending to {msg.channel}: {e}")
                else:
                    logger.warning(f"Unknown channel: {msg.channel}")

            except asyncio.TimeoutError:
                # 超时不是错误,只是没有消息,继续循环
                continue
            except asyncio.CancelledError:
                break

我们上一小节中所说的先启动分发器,本质就是通过 while True 不断循环使用 asyncio.wait_for 消费 outbound 队列,然后根据 msg.channel 路由并调用 send 方法。

设计亮点:

  1. 拉模式(Pull)而非推模式(Push)

    • 主动从消息队列拉取消息,控制权在自己手中
    • 相比回调式的推模式,更容易控制消费速率和错误处理
  2. 可中断的事件循环

    • timeout=1.0 让循环能定期"抬头看路",检查是否有停止信号
    • 没有这个超时,任务会一直阻塞在 consume_outbound() 上,难以优雅停止

接着我们继续实现渠道的发送方法,这是协议翻译的最后一步。

为了让 ChannelManager 能够统一管理,每个 IM 适配器必须实现以下两个成员:

  1. name: str:渠道唯一标识(如 "feishu")。
  2. async send(msg: OutboundMessage) -> None:发送回复的方法。

以飞书适配器为例,我们之前已经定义了 name = "feishu",现在补充 send 方法的实现:

class FeishuChannel:
    # 省略...
    async def send(self, msg: OutboundMessage) -> None:
        """通过飞书发送消息。"""
        if not self._client:
            logger.warning("飞书客户端未初始化")
            return

        try:
            # 根据 chat_id 格式确定 receive_id_type
            # open_id 以 "ou_" 开头,chat_id 以 "oc_" 开头
            if msg.chat_id.startswith("oc_"):
                receive_id_type = "chat_id"
            else:
                receive_id_type = "open_id"

            # 构建文本消息内容
            content = json.dumps({"text": msg.content})

            request = CreateMessageRequest.builder() \
                .receive_id_type(receive_id_type) \
                .request_body(
                    CreateMessageRequestBody.builder()
                    .receive_id(msg.chat_id)
                    .msg_type("text")
                    .content(content)
                    .build()
                ).build()

            # OpenAPI 调用是同步的,在线程中运行以避免阻塞
            response = await asyncio.to_thread(
                self._client.im.v1.message.create, request
            )

            if not response.success():
                logger.error(
                    f"发送飞书消息失败:code={response.code}, "
                    f"msg={response.msg}, log_id={response.get_log_id()}"
                )
            else:
                logger.debug(f"飞书消息已发送至 {msg.chat_id}")

        except Exception as e:
            logger.error(f"发送飞书消息时出错:{e}")

本质是就是将我们上一篇文章中的 FeishuChannel 类中 _process_and_reply 方法改成 send 方法即可。这样,ChannelManager 就可以统一调用 await channel.send(msg),完全不需要关心飞书 API 的具体细节。

8. 集成到网关启动入口

现在,我们将 MessageBus、AgentLoop、FeishuChannel 和 ChannelManager 全部串联起来。实现如下:

# gateway.py
import os
from loguru import logger
from feishu import FeishuChannel, FeishuConfig
from message_bus import MessageBus
from loop import AgentLoop
from manager import ChannelManager

async def main():
    # 1. 填入你的飞书机器人凭证
    config = FeishuConfig(
        app_id="xxx",         # 替换为真实的 App ID
        app_secret="xxx",    # 替换为真实的 App Secret
        encrypt_key="",                      # 如果飞书后台配置了 Encrypt Key 则填入,否则留空
        verification_token=""                # 如果配置了 Verification Token 则填入,否则留空
    )
    deepseek_key = os.getenv("DEEPSEEK_API_KEY", "")
    # 2. 创建总线
    bus = MessageBus()
    # 3. 创建 Agent 循环
    agent = AgentLoop(
        bus=bus,
        api_key=deepseek_key,
        base_url="https://api.deepseek.com",
        model="deepseek-chat",
        max_iterations=20,
    )
    
    # 4. 创建飞书渠道(传入总线,以便它 publish_inbound)
    feishu_channel = FeishuChannel(config=config, bus=bus)
    # 5. 创建渠道管理器,并注册飞书渠道
    channels = ChannelManager(bus=bus)
    channels.register(feishu_channel)
    
    logger.info("正在启动 Mini OpenClaw 网关...")
    
    # 6. 并发运行
    try:
        await asyncio.gather(
            agent.run(),          # 持续消费 inbound 队列,调用 LLM
            channels.start_all(), # 飞书长连接 + 出向派发器
        )
    except KeyboardInterrupt:
        pass
    finally:
        logger.info("收到退出信号,正在关闭...")
        agent.stop()
        await channels.stop_all()

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

至此整个网关的运行流程如下:

1. 网关“通电”

  • 我们启动 manager.start_all(),它立刻做了两件事:
    • 先派一个“快递员”(_dispatch_outbound 后台任务)守在 发件箱(outbound 队列) 旁边,随时准备把回复送出去。
    • 然后接通 飞书这个“电话线”feishu_channel.start()),开始等待用户发消息。

2. 用户发来消息

  • 用户在飞书群里说了一句“帮我读一下 /tmp/note.txt”。
  • 飞书适配器收到这条“方言消息”,立即翻译成网关内部的 普通话(InboundMessage),然后丢进 收件箱(inbound 队列)

3. Agent 大脑开始思考

  • agent.run() 一直在盯着 收件箱,一看到有新消息就取出来。
  • 它调用大模型并可能执行工具(比如读取文件),最终生成一段回复文本。
  • 然后把回复包装成 标准包裹(OutboundMessage),扔进 发件箱(outbound 队列)

4. 快递员送货

  • 守在 发件箱 旁边的快递员(_dispatch_outbound)发现新包裹,看看上面写的“收件渠道”是 feishu
  • 他马上找到飞书适配器,把包裹交给它:“请发到这个 chat_id 的群里”。
  • 飞书适配器又把回复从 普通话 翻译回 飞书的方言,调用飞书 API 发回群里。

5. 用户看到回复

  • 用户收到助手返回的文件内容,整个流程结束。

我们上述的 channels.start_all() 方法是还没实现的,我们实现一下:

class ChannelManager:
    def __init__(self, bus: MessageBus):
        # 省略...
    async def start_all(self) -> None:
        # 省略...
    async def stop_all(self) -> None:
        """优雅停止所有渠道和出站分发器。"""
        logger.info("Stopping all channels...")

        # 第一阶段:取消出站分发器任务
        if self._dispatch_task:
            self._dispatch_task.cancel()
            try:
                await self._dispatch_task
            except asyncio.CancelledError:
                pass

        # 第二阶段:逐个停止渠道(每个渠道的 stop 方法应关闭连接、释放资源)
        for name, channel in self.channels.items():
            try:
                await channel.stop()
                logger.info(f"Stopped {name} channel")
            except Exception as e:
                logger.error(f"Error stopping {name}: {e}")

实现也很简单,首先停止出站分发器的任务,再逐个停止渠道的连接,释放资源。

接着我们启动网关:

python gateway.py

启动结果如下:

01.png

然后我们接着在上一篇文章中设置了的飞书机器人中进行发消息。

然后我们发现报错了:

image.png

报错原因是因为飞书 SDK 的 register_p2_im_message_receive_v1 要求注册一个同步回调函数(不能是 async def),但消息处理逻辑(如解析内容、发布到 MessageBus)是异步的。因此,我们需要实现一个跨线程调度适配器,用于将飞书 WebSocket 线程中的同步回调安全地桥接到 asyncio 主事件循环。

9. 跨线程调度适配器

首先我们需要保存主事件循环对象,我们是在网关启动文件 gateway.py 中通过 asyncio.run(main()) 启动的主循环。因为飞书 WebSocket 客户端运行在一个独立的后台线程中(见 threading.Thread(target=run_ws, daemon=True).start()),它的回调需要一个同步函数,但真正的消息处理逻辑 _on_message 是一个异步协程,需要被提交到主事件循环中执行,因为 MessageBus 等组件是绑定到主循环的。为了从另一个线程安全地将协程投递到主事件循环,就需要持有主事件循环的引用

先保存主事件循环对象:

class FeishuChannel:
    def __init__(self, config: FeishuConfig, bus: MessageBus):
        self.config = config
        self.bus = bus
+        self._loop = None
        self._client = lark.Client.builder() \
            .app_id(config.app_id) \
            .app_secret(config.app_secret) \
            .build()

    async def start(self) -> None:
        # 省略...
+        # 保存主事件循环对象
+        self._loop = asyncio.get_running_loop()
        def run_ws():
            # 省略...

接着我们创建了一个同步函数 _on_message_sync 作为 register_p2_im_message_receive_v1 的实际回调,然后在 _on_message_sync 中将真正异步的处理函数 _on_message 调度到主事件循环中执行。实现如下:

def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
    try:
        if self._loop and self._loop.is_running():
            # 将异步处理函数调度到主事件循环
            asyncio.run_coroutine_threadsafe(
                self._on_message(data),
                self._loop
            )
        else:
            # 备用方案:在新事件循环中运行
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                loop.run_until_complete(self._on_message(data))
            finally:
                loop.close()
    except Exception as e: logger.error(f"处理飞书消息时出错:{e}")

接着我们修改 register_p2_im_message_receive_v1 的实际回调函数为上述我们实现的 _on_message_sync

class FeishuChannel:
    def __init__(self, config: FeishuConfig, bus: MessageBus):
        # 省略...
    async def start(self) -> None:
        # 省略...
        # 注册接收消息事件处理函数 im.message.receive_v1
-        handler = builder.register_p2_im_message_receive_v1(self._on_message).build()
+        handler = builder.register_p2_im_message_receive_v1(self._on_message_sync).build()
        # 保存主事件循环对象
        self._loop = asyncio.get_running_loop()

总的来说就是在主事件循环中“记住”主循环对象,供后续其他线程通过 asyncio.run_coroutine_threadsafe 将协程调度回主循环执行,是实现跨线程异步任务调度

同时当主事件循环不存在时创建一个全新的临时事件循环,在当前线程(WebSocket 线程)中同步运行 self._on_message(data),执行完毕后关闭循环。

经过上述迭代后,我们再次启动我们的程序:python gateway.py

然我们再在飞书设置的 AI 机器人上跟我们的 Mini OpenClaw 进行对话,结果如下:

1cbfafacd6d84ef03bd64151f081c17a.jpg

然后我们再根目录下创建一个 test.txt 文件,内容为:“从网关的角度理解并实现一个 Mini OpenClaw”,然后在飞书设置的 AI 机器人输入:“帮我读取 test.txt 文件”,结果如下:

e85ee7fd4d5df8c7fa605994b44a19e4.jpg

至此我们的 Mini OpenClaw 就实现了。

10. 总结

经过上述文章我们可以更加透彻地理解为什么说 OpenClaw 可以简单总结为“高级 Agent + 网关”了。它把飞书、微信这些聊天软件的“方言消息”统一通过一个网关转成内部能听懂的“普通话”(InboundMessage),Agent 只处理这种标准消息。

为了防止消息太多堵死系统,用了两个队列(入站异步队列出站异步队列,相当于收信箱和发件箱)把接收和回复解耦开,像流水线一样互不干扰。Agent 处理完后把回复扔进发件箱,再由分发器根据渠道标签(feishu、wechat)转回对应平台的格式发回去。

这样一来,添加新平台就像加个翻译插件,核心代码完全不用动。最后用跨线程调度解决了飞书回调异步的问题。整个网关跑起来就是:用户发消息 → 标准化 → 入站队列 → Agent 思考(可调用工具)→ 出站队列 → 翻译回原平台 → 用户收到回复

上述实现也是港大开源的 Nanobot 的核心实现,Nanobot 可以说是 Python 版的 OpenClaw,是学习研究场景的轻量选择。

我是程序员Cobyte,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

昨天 — 2026年4月14日首页

AI聊天界面的布局细节和打字跟随方法

2026年4月14日 18:06

AI 问答界面如何布局?

在豆包的AI问答聊天界面,为什么输入框总是会跟随在最底部?左边有导航栏,无论怎么缩小放大屏幕都会在当前问答展示界面的水平线中间?

难道是通过 position: fixed; 来实现的?但是它怎么能够解决第二个问题呢?先打开控制台看看。

在问答界面,输入框是一直被挤在最下方的,通过检查控制台会发现输入框好像会一直跟随在屏幕最下方?

image.png

但是随着控制台一直向上拉长,输入框又会被控制台覆盖?

image.png

说明根本不是通过固定定位来实现的效果。

下面来实现一下它的这种效果:这里展示的是最外层容器的布局。

<!-- 根容器 -->
<div class="chat">
    <!-- 展示容器 -->
    <div v-show='!isChat' class="chat-content">
    </div>
    <!-- 对话界面 -->
    <div v-show='isChat' class="chat-scroll-container">
    </div>
    <!-- 输入框 -->
    <div class="input-section">
    </div>
</div>

.chat {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.chat-container {
  width: 100%;
  max-width: 1000px;
  flex: 1;
  overflow-y: auto;
}
.chat-scroll-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  flex: 1;
  /* 隐藏滚动条但保留功能 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge */
}
.input-section {
  width: 100%;
  height: 150px;
  flex-shrink: 0;
}

可以看到核心的实现,其实就是让输入框的兄弟容器将剩余空间全部占据,而 input-section 本身只需要不压缩自身的高度就可以,当屏幕缩小后,flex:1;能占据的空间变小,而输入框高度不变将一直在外层容器最下方,当空间展示不下时会出现的可视区域外。

flex-shrink

  • 父容器必须是弹性容器。
  • 默认表示子元素固定宽度,不被压缩。
  • 在父元素使用 flex-direction: column; 改变了弹性方向后,表示子元素固定高度,不被压缩。
  • 为 1 时,表示容器会适应父容器高度被压缩。

如何让视线跟随 AI 生成的内容

下方父容器为滚动容器,子元素为主要内容展示容器。以下介绍两种 AI 打字跟随的监听方法,控制跟随与用户操作停止跟随。

<div class="chat-scroll-container" ref="scrollContainerRef" @scroll="handleScroll">
    <div class="chat-messages" ref="chatMessagesRef">
    </div>
</div>

const chatMessagesRef = ref(null);
// 滚动容器引用
const scrollContainerRef = ref(null);
// 是否启用自动滚动跟随
const enableAutoScroll = ref(true);

// 上次滚动位置
let lastScrollTop = 0;

const handleScroll = throttle(() => {
  const el = scrollContainerRef.value;
  if (!el) return;
  const { scrollTop, scrollHeight, clientHeight } = el;
  // 判断当前是否已经在底部(留20px容差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20;
  // 如果用户在向上滚动超过阈值,取消自动跟随
  if (isAtBottom === false && scrollTop < lastScrollTop) {
    const upDistance = lastScrollTop - scrollTop;
    if (upDistance > 10) {
      enableAutoScroll.value = false;
    }
  }
  // 如果滚动到底部,重新开启自动跟随
  if (isAtBottom) {
    enableAutoScroll.value = true;
  }

  // 记录本次滚动位置
  lastScrollTop = scrollTop;
}, 100);

.chat-scroll-container{
    height: 1000px; 
    .chat-messages{
    // 高度由内容支撑
    }
 }

MutationObserver

监听容器的变化,包括高度、内容变化、DOM的操作等等。大多都抛弃该做法

  • 触发次数极多

  • 性能开销

  • 容易抖动、重复触发

  • 性能不如ResizeObserver

let observer = null;
onMounted(() => {
  initObserver();
});

// 初始化 MutationObserver
const initObserver = () => {
  if (!scrollContainerRef.value) return;
  // 断开旧的 observer
  if (observer) {
    observer.disconnect();
  }
  observer = new MutationObserver(() => {
    if (!enableAutoScroll.value) return;
    // 内容变化时,自动滚动到底部
    scrollToBottom();
  });
  observer.observe(scrollContainerRef.value, {
    childList: true,
    subtree: true,
    characterData: true,
  });
};
// 滚动操作
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

ResizeObserver

监听容器是否发生尺寸变化,而不是滚动容器。操作跟随需要操作滚动容器。

// 启用监听
onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value); // 监听高度变化容器
});
// 滚动操作,操作滚动容器
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

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

作者 竹林818
2026年4月14日 18:02

背景

上个月,我接手了一个新的 NFT 铸造平台前端项目。项目要求很简单:用户点击一个“连接钱包”按钮,弹出 MetaMask 进行连接和授权,然后前端获取到用户的以太坊地址并显示出来。这听起来是 Web3 开发的“Hello World”,对吧?我心想,用老伙计 ethers.js 应该分分钟搞定。毕竟我之前在 DeFi 项目里用过很多次了。于是,我自信满满地开始敲代码,没想到接下来的几个小时,我几乎把 ethers.js 连接钱包的常见坑全踩了一遍。从 window.ethereumundefined 到账户切换监听失效,再到网络切换时的状态混乱,整个过程堪称一部小型历险记。

问题分析

我最开始的思路非常直接:在 React 组件的 useEffect 里,或者在一个按钮的点击事件中,直接调用 ethers.providers.Web3Provider 并传入 window.ethereum,然后调用 provider.send(“eth_requestAccounts”) 来请求账户。代码大概长这样:

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const accounts = await provider.send(“eth_requestAccounts”, []);
    setAccount(accounts[0]);
  } else {
    alert(‘请安装 MetaMask!’);
  }
};

但一运行就出问题了。首先,开发服务器热更新时,有时会报错 window.ethereum is undefined。其次,连接成功后,我切换到 MetaMask 的另一个账户,前端页面上的地址并没有自动更新。最后,当用户在 MetaMask 里切换网络(比如从 Goerli 切到 Mainnet),我的应用完全感知不到,还显示着旧网络下的状态。

我意识到,我把问题想得太简单了。一个健壮的钱包连接模块,至少需要处理三件事:1. Provider 的可靠获取(处理未安装钱包、页面加载时机);2. 账户变化的监听;3. 网络变化的监听。而我最初的代码,只完成了最基础的“一次性连接”功能。

核心实现

第一步:安全地获取 Provider 并连接账户

首先,我们不能直接假设 window.ethereum 存在。用户可能没安装 MetaMask,或者我们的代码在服务器端渲染(SSR)时执行。所以,获取 Provider 的逻辑必须放在客户端生命周期内,并且做好错误处理。

这里有个坑: window.ethereum 的类型。在 TypeScript 中,直接访问会报错。我们需要扩展 Window 接口。同时,MetaMask 注入的 ethereum 对象有一个 request 方法,但 ethers.jsWeb3Provider 封装得很好,我们通常用 provider.sendprovider.getSigner

我的思路是:创建一个自定义 Hook,比如叫 useEthereumProvider,来安全地创建和管理 Provider 实例。

import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import { useEffect, useState } from ‘react’;

// 扩展 Window 接口以包含 ethereum
declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useEthereumProvider = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);

  useEffect(() => {
    // 确保在客户端环境下执行
    if (typeof window !== ‘undefined’ && window.ethereum) {
      // 注意:ethers v6 中,Web3Provider 已更名为 BrowserProvider
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);

      // 尝试获取已连接的账户
      ethersProvider.getSigner().then(s => setSigner(s)).catch(console.error);
    }
  }, []); // 空依赖数组,仅初始化一次

  return { provider, signer };
};

注意,我用了 ethers v6 的 BrowserProvider。如果你还在用 v5,请使用 ethers.providers.Web3Provider。这个 Hook 在组件挂载时安全地初始化 Provider。

第二步:实现连接钱包函数

有了 Provider,接下来实现具体的连接函数。这个函数需要处理用户点击“连接钱包”按钮的动作。

const [account, setAccount] = useState<string>(‘’);
const { provider } = useEthereumProvider();

const handleConnect = async () => {
  if (!provider) {
    alert(‘未检测到钱包Provider,请确认MetaMask已安装’);
    return;
  }

  try {
    // 请求账户访问权限。这里会弹出MetaMask授权窗口。
    const accounts = await provider.send(‘eth_requestAccounts’, []);
    if (accounts && accounts[0]) {
      setAccount(accounts[0]);
      // 获取 Signer 实例,用于后续签名交易
      const signer = await provider.getSigner();
      // 你可以将 signer 存储到状态或 context 中
    }
  } catch (error: any) {
    console.error(‘连接钱包失败:’, error);
    // 用户拒绝了请求
    if (error.code === 4001) {
      alert(‘您拒绝了连接请求。’);
    }
  }
};

注意这个细节: provider.send(‘eth_requestAccounts’, []) 是触发 MetaMask 弹出授权窗口的关键调用。它返回一个 Promise,用户授权后 resolve,拒绝后 reject 并带有错误码 4001

第三步:监听账户和网络变化

这是让应用“活”起来的关键。MetaMask 允许用户随时切换账户或网络,我们的前端需要实时响应。

window.ethereum 对象提供了 on 方法用于监听事件。主要监听两个事件:‘accountsChanged’‘chainChanged’

useEffect(() => {
  // 确保 ethereum 对象存在
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log(‘accountsChanged’, accounts);
    if (accounts.length === 0) {
      // 用户断开了连接,或者锁定了钱包
      setAccount(‘’);
      alert(‘请连接您的钱包。’);
    } else if (accounts[0] !== account) {
      // 切换到了新账户
      setAccount(accounts[0]);
      // 通常这里需要重新获取 Signer,因为账户变了
      if (provider) {
        provider.getSigner().then(newSigner => {
          // 更新 signer 状态
        });
      }
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // _chainId 是十六进制字符串,例如 ‘0x1’ (Mainnet)
    console.log(‘chainChanged’, _chainId);
    // 当网络切换时,MetaMask 建议页面重载
    // 但为了更好体验,我们可以不重载,而是更新应用内的网络状态,并重置相关数据
    window.location.reload(); // 简单粗暴但有效
    // 更优方案:更新 networkId 状态,并重新初始化合约实例等
  };

  // 添加监听
  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, provider]); // 依赖 account 和 provider

这里有个大坑: 关于 chainChanged 事件的处理。MetaMask 官方文档早期建议在 chainChanged 时刷新页面,因为许多 dApp 的状态(如合约实例)依赖于网络。虽然不刷新也可以,但你需要手动更新所有依赖网络的状态。为了简单可靠,我选择了刷新页面。在更复杂的应用中,你可能需要设计一个状态管理系统来优雅地处理网络切换。

第四步:获取当前网络信息

除了账户,我们通常还需要知道用户当前连接到了哪个网络。

const [chainId, setChainId] = useState<number | null>(null);
const { provider } = useEthereumProvider();

useEffect(() => {
  if (!provider) return;

  const fetchNetwork = async () => {
    try {
      const network = await provider.getNetwork();
      // network.chainId 是 BigInt 类型 (ethers v6)
      setChainId(Number(network.chainId));
    } catch (error) {
      console.error(‘获取网络信息失败:’, error);
    }
  };

  fetchNetwork();
  // 注意:provider.getNetwork() 可能不会随 chainChanged 自动更新。
  // 所以我们依赖上一步的 chainChanged 事件,触发重新获取或页面刷新。
}, [provider]);

完整代码

下面是一个整合了以上所有功能的、可直接运行的 React 组件示例。

// WalletConnector.tsx
import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import React, { useEffect, useState } from ‘react’;

declare global {
  interface Window {
    ethereum?: any;
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [account, setAccount] = useState<string>(‘’);
  const [chainId, setChainId] = useState<number | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  // 1. 初始化 Provider
  useEffect(() => {
    if (typeof window !== ‘undefined’ && window.ethereum) {
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);
      // 尝试静默获取已连接的账户
      ethersProvider.getSigner()
        .then(s => {
          setSigner(s);
          s.getAddress().then(addr => setAccount(addr));
        })
        .catch(() => {/* 用户未连接,忽略错误 */});
    }
  }, []);

  // 2. 获取初始网络
  useEffect(() => {
    if (!provider) return;
    provider.getNetwork().then(network => {
      setChainId(Number(network.chainId));
    });
  }, [provider]);

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

    const handleAccountsChanged = (accounts: string[]) => {
      console.log(‘账户变更:’, accounts);
      if (accounts.length === 0) {
        // 断开连接
        setAccount(‘’);
        setSigner(null);
        alert(‘钱包已断开。’);
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        // 更新 signer
        provider?.getSigner().then(s => setSigner(s));
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log(‘网络变更:’, _chainId);
      // 简单处理:刷新页面
      window.location.reload();
    };

    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, provider]);

  // 4. 连接钱包函数
  const handleConnect = async () => {
    if (!provider) {
      alert(‘请安装 MetaMask 钱包扩展!’);
      return;
    }
    setLoading(true);
    try {
      const accounts = await provider.send(‘eth_requestAccounts’, []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      const currentSigner = await provider.getSigner();
      setSigner(currentSigner);
      // 获取并更新网络
      const network = await provider.getNetwork();
      setChainId(Number(network.chainId));
    } catch (error: any) {
      console.error(‘连接失败:’, error);
      if (error.code === 4001) {
        alert(‘连接请求被拒绝。’);
      }
    } finally {
      setLoading(false);
    }
  };

  // 5. 断开连接 (MetaMask 没有真正的“断开”,这里只是清除本地状态)
  const handleDisconnect = () => {
    setAccount(‘’);
    setSigner(null);
    alert(‘已断开本地连接。如需完全断开,请在 MetaMask 中操作。’);
  };

  return (
    <div style={{ padding:20px’, border:1px solid #ccc’, borderRadius:8px’ }}>
      <h3>钱包连接状态</h3>
      {!provider ? (
        <p>⚠️ 未检测到钱包Provider。请确保 MetaMask 已安装并启用。</p>
      ) : (
        <>
          <p>
            <strong>网络ID:</strong> {chainId ? `0x${chainId.toString(16)}` : ‘未知’}
          </p>
          <p>
            <strong>当前账户:</strong> {account ? `${account.substring(0, 6)}…${account.substring(account.length - 4)}` : ‘未连接’}
          </p>
          <div>
            {!account ? (
              <button onClick={handleConnect} disabled={loading}>
                {loading ? ‘连接中…’ : ‘连接 MetaMask’}
              </button>
            ) : (
              <div>
                <button onClick={handleDisconnect} style={{ marginLeft:10px’ }}>
                  断开连接
                </button>
              </div>
            )}
          </div>
          {signer && (
            <p style={{ marginTop:10px’, color:green’ }}>
              ✅ Signer 已就绪,可进行签名操作。
            </p>
          )}
        </>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum is undefined (Next.js/SSR 环境)

    • 现象: 在 Next.js 项目中,组件首次渲染或热更新时控制台报错。
    • 原因: 代码在服务端或构建时执行,window 对象不存在。
    • 解决: 所有访问 window.ethereum 的代码都必须包裹在 if (typeof window !== ‘undefined’) 条件判断中,或放在 useEffect、事件处理函数等客户端生命周期钩子中。
  2. 账户切换后页面不更新

    • 现象: 在 MetaMask 里切换了账户,但 dApp 页面上显示的地址还是旧的。
    • 原因: 没有监听 accountsChanged 事件。
    • 解决: 按照上文所述,正确添加 window.ethereum.on(‘accountsChanged’, callback) 监听,并在回调中更新 React 状态。注意: 当用户断开连接时,accounts 数组为空,需要处理这个情况。
  3. 网络切换后合约调用出错

    • 现象: 用户从 Goerli 切换到 Mainnet,dApp 仍尝试在旧网络的合约地址上调用,导致 RPC 错误。
    • 原因: 没有监听 chainChanged 事件,或监听后没有更新依赖网络的合约实例等状态。
    • 解决: 监听 chainChanged 事件。采用简单方案(刷新页面)或复杂方案(更新全局网络状态并重新初始化所有网络相关的对象,如 Provider、Signer、合约实例等)。
  4. ethers v5 与 v6 的 API 差异

    • 现象: 照着旧教程写代码,发现 Web3Provider 等类找不到。
    • 原因: 项目安装的是 ethers v6,其 API 有重大变更。
    • 解决: 查阅官方升级指南。关键变化:ethers.providers.Web3Provider 变为 ethers.BrowserProviderprovider.getSigner().getAddress() 返回 Promise;chainId 是 BigInt 类型。务必检查你使用的版本。

小结

通过这次实践,我深刻体会到 Web3 前端开发中“细节决定成败”。一个看似简单的钱包连接,需要考虑 Provider 的生命周期、用户交互的多种可能(授权、拒绝、切换)以及钱包状态的持续同步。最终稳定可用的代码,是初始化、请求授权、事件监听和状态管理四部分紧密协作的结果。如果你要在此基础上继续深挖,下一步可以考虑将钱包状态管理抽象为 Context 或使用状态管理库(如 Zustand、Jotai),以支持多组件共享,并集成更多钱包类型(如 WalletConnect)以实现更好的用户体验。

TailwindCSS 核心概念与实用技巧:从传统CSS到Utility-First迁移指南

作者 CodeAI
2026年4月14日 17:48

引言:为什么越来越多人用Tailwind?

你是否还在为CSS命名发愁?

.container .header .button-primary 想破脑袋还是避免不了命名冲突。

传统CSS开发中,我们常常遇到这些痛点:

1. CSS文件越来越臃肿 项目迭代一段时间后,你会发现写了大量重复样式,却不敢删除旧代码,怕哪里出问题。最后CSS文件几千行,大部分都是无用代码。

2. 命名是永恒的难题 使用BEM命名规范?button button--primary button--large 虽然规范,但写起来冗长又繁琐。稍微复杂点的组件,命名就变成了玄学。

3. 样式和组件分离 写React/Vue组件时,JSX/模板里写了结构,还要跑到另一个CSS文件写样式,来回切换上下文,开发效率被打断。

4. 改样式要改多个文件 调整个间距颜色,要找到对应的CSS类,修改完还要回来检查,一不小心影响其他地方样式。

TailwindCSS 为什么能在近几年迅速流行?因为它从根本上解决了这些问题。

它把CSS带回到你的HTML中,用原子化的Utility类让你不用再写CSS,同时保持代码整洁可维护。

据统计,npm 下载量已经突破百万,Vue、React、Next.js 等主流框架都官方支持,越来越多团队开始全面采用。


什么是Utility-First?和传统CSS/BEM/CSS-in-JS的区别

先搞懂核心思想:Utility-First就是原子化CSS

简单说,Tailwind提供了大量功能单一的工具类,比如 text-center 代表文字居中,pt-4 代表上内边距1rem。

你不需要再写新的CSS,只需要在HTML中组合这些工具类就能构建出任何样式。

我们来对比一下不同方案:

传统CSS写法

<!-- HTML -->
<button class="btn btn-primary">点击我</button>
/* CSS */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
}

.btn-primary {
  background-color: #3b82f6;
  color: white;
}

.btn-primary:hover {
  background-color: #2563eb;
}

BEM写法

<button class="button button--primary button--medium">点击我</button>
.button {
  font-family: system-ui;
  border: none;
  outline: none;
}

.button--primary {
  background-color: var(--color-primary);
  color: white;
}

.button--medium {
  padding: 8px 16px;
  font-size: 14px;
}

BEM解决了命名冲突问题,但还是需要不断写新的CSS,类名越来越长。

Tailwind Utility-First 写法

<button
  class="px-4 py-2 font-medium text-white bg-blue-500
             hover:bg-blue-600 rounded"
>
  点击我
</button>

不需要写任何CSS!所有样式都通过组合Utility类直接在HTML中完成。

CSS-in-JS 写法(对比参考)

// Styled Components 写法
const Button = styled.button`
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
  background-color: #3b82f6;
  color: white;

  &:hover {
    background-color: #2563eb;
  }
`;

<Button>点击我</Button>;

CSS-in-JS把CSS放到JS里,解决了作用域问题,但运行时有开销,调试也相对麻烦。

Tailwind 则是纯CSS方案,构建时移除无用代码,最终产物体积很小,同时保留了CSS的原生优势。

一句话总结区别:

  • 传统CSS/BEM:语义化命名,一个类对应多个样式属性
  • Utility-First:功能单一,一个类只做一件事
  • CSS-in-JS:JS掌管样式,组件级作用域

Tailwind核心概念详解

1. 配置文件 tailwind.config.js

安装完Tailwind后,根目录会有一个 tailwind.config.js 配置文件。

这是Tailwind的神经中枢,你可以在这里自定义主题、断点、颜色、间距等等。

基础配置示例:

/** @type {import('tailwindcss').Config} */
module.exports = {
  // 扫描所有项目文件,找出用到的类,用于Tree Shaking
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,vue,html}"],
  theme: {
    // 扩展默认主题,不会覆盖
    extend: {
      // 自定义颜色
      colors: {
        primary: "#165DFF",
        secondary: "#6b7280",
      },
      // 自定义字体
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
      },
      // 自定义断点
      screens: {
        "3xl": "1920px",
      },
    },
  },
  // 第三方插件
  plugins: [],
};

如果你想完全覆盖默认主题,可以直接在 theme 里定义,不使用 extend

theme: {
  // 完全自定义颜色,会替换Tailwind默认颜色
  colors: {
    blue: {
      50: '#f0f9ff',
      100: '#e0f2fe',
      // ... 一直到 900
      600: '#2563eb',
    }
  }
}

对于中文开发者,建议在配置中加入中文字体优化:

theme: {
  extend: {
    fontFamily: {
      chinese: ['PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif'],
    },
  },
}

使用的时候直接:

<body class="font-chinese"></body>

2. @layer 分层机制

Tailwind 用 @layer 把样式分成三层:basecomponentsutilities

这个分层机制帮你正确排序CSS优先级,避免特异性冲突。

/* 在你的style.css中 */
@tailwind base;
@tailwind components;
@tailwind utilities;

我们分别解释:

@layer base - 基础样式层

用于重置浏览器默认样式,或者给HTML标签添加默认样式。

@layer base {
  h1 {
    @apply text-3xl font-bold mb-4;
  }
  h2 {
    @apply text-2xl font-semibold mb-3;
  }
  a {
    @apply text-blue-600 hover:underline;
  }
}

Base层优先级最低,后面的classes和utilities可以覆盖它。

@layer components - 组件层

用来提取可复用的组件样式,优先级高于base。

@layer components {
  .btn {
    @apply px-4 py-2 rounded font-medium;
  }
  .btn-primary {
    @apply bg-primary text-white hover:bg-primary/90;
  }
  .card {
    @apply bg-white rounded-lg shadow p-6;
  }
}

然后就可以在HTML中直接使用:

<button class="btn btn-primary">提交</button>
<div class="card">内容</div>

@layer utilities - 工具类层

优先级最高,如果你需要添加自定义工具类,放在这里。

@layer utilities {
  .text-shadow {
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  .content-auto {
    content-visibility: auto;
  }
}

放在 @layer utilities 里的自定义工具类,优先级比Tailwind自带的工具类还要高吗?不,它和Tailwind自带的utilities同级,后面写的会覆盖前面的。

记住这个优先级顺序:base < components < utilities,这样就不会出现奇怪的样式覆盖问题。

3. Purge / Tree-shaking 工作原理

Tailwind v3 默认就开启了Tree-shaking,它会扫描你所有的模板文件,只保留实际用到的Utility类。

工作流程:

  1. 你在 content 配置里指定了要扫描的文件路径
  2. 构建时,Tailwind 从这些文件中提取出所有用到的class名称
  3. 只生成这些class对应的CSS,没有用到的全部移除

举个例子,你的项目只用到了 px-4 py-2 bg-blue-500,那Tailwind就只会生成这几个类对应的CSS,其他所有没用到的padding、margin、颜色都不会出现在最终CSS文件中。

所以即使Tailwind默认包含了几千个Utility类,最终打包出来的CSS通常只有几KB到十几KB,比你自己写的CSS还小。

配置示例(v3标准写法):

// tailwind.config.js
module.exports = {
  content: [
    // 所有可能用到Tailwind类的文件都要写在这里
    "./index.html",
    "./src/**/*.{js,jsx,ts,tsx,vue,html}",
  ],
};

注意事项:如果你用了动态class拼接,需要用安全列表:

module.exports = {
  content: [...],
  safelist: [
    // 强制保留这些类,不会被摇掉
    'bg-red-500',
    'bg-green-500',
    'bg-yellow-500',
    // 或者用模式匹配
    {
      pattern: /bg-(red|green|yellow)-.+/,
    }
  ]
}

这在中文开发中很常见,比如后台配置返回不同状态的样式类,一定要记得加safelist,不然生产环境样式会丢。

4. 响应式断点系统

Tailwind的响应式设计非常简单,默认提供了五个断点:

断点 最小值 对应设备
sm 640px 手机横屏
md 768px 平板
lg 1024px 小桌面
xl 1280px 大桌面
2xl 1536px 超大桌面

使用方法非常简单:在类名前加上断点前缀就是了。

示例:移动端单列,平板双列,桌面三列

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div>卡片1</div>
  <div>卡片2</div>
  <div>卡片3</div>
</div>

解释一下:

  • grid-cols-1:默认(小于640px)单列
  • md:grid-cols-2:宽度 ≥768px 变成双列
  • lg:grid-cols-3:宽度 ≥1024px 变成三列

更实际的导航栏示例:移动端汉堡菜单,桌面端全链接

<nav class="bg-white shadow fixed w-full">
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="flex justify-between h-16">
      <div class="flex">Logo</div>
      <!-- 桌面端菜单 -->
      <div class="hidden md:flex items-center space-x-4">
        <a href="#" class="text-gray-700 hover:text-blue-600">首页</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">产品</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">关于</a>
      </div>
      <!-- 移动端汉堡按钮 -->
      <div class="md:hidden flex items-center">
        <button>🍔</button>
      </div>
    </div>
  </div>
</nav>

hidden md:flex 的意思是:默认隐藏,大于等于md(768px)才显示,完美实现响应式切换。

自定义断点也很简单,在配置里加就行:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      screens: {
        xs: "480px", // 比sm更小的断点
        "3xl": "1920px", // 更大屏幕
      },
    },
  },
};

实用开发技巧

1. 提取组件(@apply) vs 保持纯utility

这是Tailwind开发中最常见的问题:什么时候该提取组件,什么时候直接堆Utility类?

两种方式都可以,我们来看具体例子。

直接保持纯Utility写法

<button
  class="px-4 py-2 text-sm font-medium text-white bg-blue-600
             hover:bg-blue-700 rounded-lg focus:outline-none
             focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
  提交
</button>

优点:所有样式都在这里,一目了然,不用跳去别的文件看。适合一次性、不重复使用的按钮。

使用 @apply 提取为可复用组件

@layer components {
  .btn-primary {
    @apply px-4 py-2 text-sm font-medium text-white bg-blue-600
           hover:bg-blue-700 rounded-lg focus:outline-none
           focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
  }
}

然后HTML就很简洁:

<button class="btn-primary">提交</button>
<button class="btn-primary">保存</button>

优点:复用方便,统一修改只改一处。适合项目中多处使用的组件。

在Vue/React组件中提取

这其实是更推荐的方式,因为你已经在使用组件化框架了,为什么不直接用组件呢?

React示例:

function Button({ children, ...props }) {
  return (
    <button
      className="px-4 py-2 text-sm font-medium text-white bg-blue-600
                 hover:bg-blue-700 rounded-lg focus:outline-none
                 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
      {...props}
    >
      {children}
    </button>
  );
}

// 使用
<Button>点击我</Button>;

Vue示例:

<template>
  <button
    class="px-4 py-2 text-sm font-medium text-white bg-blue-600
                hover:bg-blue-700 rounded-lg focus:outline-none
                focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    <slot />
  </button>
</template>

我的建议

  • 如果用组件化框架(React/Vue),优先用JSX/Vue组件提取,不要用@apply写到CSS里
  • 如果是纯HTML项目或者需要配合后端模板引擎,用@layer components提取
  • 不要过度提取,只提取真正会复用的组件,一次性的代码直接堆Utility就好

2. 暗色模式实现

Tailwind v3 内置暗色模式支持,开箱即用。

先在配置中开启:

// tailwind.config.js
module.exports = {
  darkMode: "class", // 或者 'media' 跟随系统
  // ...
};

darkMode: 'media' 会自动根据系统暗色切换,darkMode: 'class' 适合手动切换(用户点击按钮切换)。

使用方式:加上 dark: 前缀。

<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1>你好,世界</h1>
  <p>这是一段文字</p>
</div>

htmlbody 标签加上 dark class 后,暗色模式就激活了:

<html class="dark">
  <!-- 所有dark:前缀的样式都会生效 -->
</html>

实现手动切换的JS代码:

// 检查用户偏好
if (
  localStorage.theme === "dark" ||
  (!("theme" in localStorage) &&
    window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
  document.documentElement.classList.add("dark");
} else {
  document.documentElement.classList.remove("dark");
}

// 切换函数
function toggleDarkMode() {
  if (document.documentElement.classList.contains("dark")) {
    document.documentElement.classList.remove("dark");
    localStorage.theme = "light";
  } else {
    document.documentElement.classList.add("dark");
    localStorage.theme = "dark";
  }
}

卡片带暗色的完整示例:

<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
  <h3 class="text-gray-900 dark:text-white font-semibold">标题</h3>
  <p class="text-gray-600 dark:text-gray-300 mt-2">
    这是描述文字,在暗色模式下会变浅。
  </p>
  <button
    class="mt-4 px-4 py-2 bg-blue-600 dark:bg-blue-500
                 text-white rounded"
  >
    按钮
  </button>
</div>

3. Hover/Focus 等交互状态

Tailwind给所有交互状态都提供了变体前缀,直接用就行。

基础示例:

<!-- Hover -->
<button class="bg-blue-500 hover:bg-blue-600 text-white">Hover我变色</button>

<!-- Focus -->
<input
  class="border focus:outline-none focus:ring-2
             focus:ring-blue-500 border-gray-300 rounded px-3 py-2"
  placeholder="点击我看看"
/>

多个状态可以叠加:

<button
  class="bg-green-500 hover:bg-green-600
             focus:ring-2 focus:ring-green-500 focus:ring-offset-2
             active:bg-green-700
             disabled:opacity-50 disabled:cursor-not-allowed
             text-white px-4 py-2 rounded"
>
  按钮
</button>

常用状态变体列表:

  • hover: 鼠标悬停
  • focus: 获得焦点
  • active: 鼠标按下
  • disabled: 禁用状态
  • first: 第一个子元素
  • last: 最后一个子元素
  • odd: 奇数行
  • even: 偶数行
  • hover:dark: / dark:hover: 暗色模式下的hover

响应式和状态可以组合,顺序没关系:md:hover:bg-blue-500hover:md:bg-blue-500 效果一样。

4. group-hover 群组变体

很多时候我们希望鼠标悬停在父元素上,改变子元素的样式,这就需要 group-hover

使用分两步:

  1. 给父元素加上 group class
  2. 给子元素加上 group-hover: 前缀

卡片示例:鼠标悬停卡片时,让按钮背景变色。

<div class="group card border rounded-lg p-6 hover:shadow-lg">
  <h3 class="group-hover:text-blue-600">卡片标题</h3>
  <p>卡片内容...</p>
  <button
    class="bg-gray-200 group-hover:bg-blue-600
                 group-hover:text-white mt-4 px-4 py-2 rounded"
  >
    查看详情
  </button>
</div>

导航栏下拉菜单示例:

<div class="group relative inline-block">
  <button class="group-hover:text-blue-600">产品菜单 ▼</button>
  <div class="absolute hidden group-hover:block w-48 bg-white shadow">
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品1</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品2</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品3</a>
  </div>
</div>

完美!不需要写任何JS,纯CSS实现悬停显示下拉菜单。

还有 group-focusgroup-active,用法一样,针对focus和active状态。

5. 任意值方括号语法

Tailwind v3 最香的功能就是任意值语法,用方括号 [] 直接写任意值。

什么时候用?当你需要一个Tailwind默认没提供的值,不用去改配置文件,直接写:

/* 自定义宽度 */
<div class="w-[310px]">
  /* 自定义定位 */
  <div class="top-[13px] left-[7px]">
    /* 自定义颜色 */
    <div class="bg-[#165DFF] text-[#fff]">
      /* 自定义字体大小 */
      <h1 class="text-[32px]">
        /* 自定义间距 */
        <div class="m-[14px] p-[8px]"></div>
      </h1>
    </div>
  </div>
</div>

组合响应式也没问题:

<div class="w-[300px] md:w-[500px] lg:w-[720px]"></div>

甚至可以写CSS自定义属性:

<div class="bg-[--primary-color]"></div>

这解决了什么问题?以前你想要一个特殊尺寸,必须去tailwind.config.js里扩展,现在直接方括号搞定,非常方便。

但是注意:不要滥用,能用上默认值就用默认值,比如 px-4 能满足就别写 px-[16px]。只有默认值满足不了的时候再用任意值语法。


常见迁移误区

误区一:过早提取组件

很多人从传统CSS转过来,习惯了一切都抽成组件,刚写了一个按钮就想着提取出来。

错误示例

项目才刚开始,按钮只用到一次,就急着提取:

@layer components {
  .header-button {
    @apply ... /* 只在头部用到一次 */;
  }
  .sidebar-item {
    @apply ... /* 只用到一次 */;
  }
}

问题:需求一变,这个组件就不用了,你白提了,而且还要维护CSS。

正确做法

重复出现第二次的时候再提取。

第一次写,直接堆Utility,第二次碰到一样的,复制过去,第三次还碰到,这时候你知道它真的需要复用,再提取也不迟。

误区二:混乱的class顺序

很多人写Tailwind,class顺序乱排,读起来非常费劲。

混乱示例

<button
  class="text-white hover:bg-blue-600 px-4 bg-blue-500 py-2 rounded"
></button>

顺序乱了,你很难快速读懂这个按钮有哪些样式。

推荐的排序思路

按这个顺序排列,可读性大大提高:

  1. 定位布局类:position, top/right/bottom/left, z-index, display, flex/grid, flex-wrap, justify-, items-, gap, w, h, m, p
  2. 边框阴影:border, rounded, shadow
  3. 背景文字颜色:bg, text
  4. 字体样式:font-, text-
  5. 交互状态:hover:, focus:, active:, disabled:, group-hover:
  6. 响应式变体:sm:, md:, lg:, xl:

整理之后

<button
  class="px-4 py-2 bg-blue-500 text-white rounded
             hover:bg-blue-600"
></button>

舒服多了对不对?

很多编辑器有Tailwind插件,可以自动排序,推荐开启。如果你用VSCode,安装 bradlc.vscode-tailwindcss 插件,开启 editor.codeActionsOnSave 自动排序。

误区三:不知道什么时候用自定义CSS

很多人转了Tailwind之后,觉得什么都能用Utility搞定,其实不是。

Tailwind不排斥自定义CSS,该用的时候就要用。

适合用自定义CSS的场景

场景一:复杂的媒体查询和关键帧动画

/* 自定义动画,这用Utility不好写,放在全局CSS就行 */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fadeIn {
  animation: fadeIn 0.3s ease-out;
}

然后把它放在 @layer utilities 里,就能在HTML中用了:

@layer utilities {
  .animate-fadeIn {
    animation: fadeIn 0.3s ease-out;
  }
}

场景二:复杂伪元素

/* 比如清除浮动 */
@layer utilities {
  .clearfix::after {
    content: "";
    display: table;
    clear: both;
  }
}

场景三:你就是需要写原生CSS的时候

Tailwind只是工具,不是宗教,如果你觉得写原生CSS更清晰更简洁,那就直接写。

错误做法

用一堆方括号拼出一个复杂CSS,可读性极差:

<div class="[&:nth-child(2n+1)]:mr-0 [&>span]:absolute [...]"></div>

这种情况不如抽出来写自定义CSS。

误区四:覆盖Tailwind默认样式时优先级错了

如果你没有用 @layer,直接写在全局CSS,会出现优先级问题。

错误示例

/* 没有加@layer,这个样式会被Tailwind utilities覆盖 */
.btn-primary {
  background-color: red !important; /* 被迫加important */
}

正确做法

@layer components {
  /* components层优先级在utilities之前,不需要important */
  .btn-primary {
    background-color: red;
  }
}

记住:只要是自定义的Tailwind相关样式,都放到 @layer 里面,让Tailwind帮你处理优先级。


总结:给传统CSS开发者的迁移建议

从传统CSS转到Utility-First开发思维,需要一个适应过程,这里给大家几个实用建议:

1. 不要一开始就全量迁移

如果你有一个成熟的老项目,不用一下子全部改成Tailwind。可以配合着用,新组件用Tailwind写,旧组件慢慢迁移。

Tailwind和传统CSS可以和平共处。

2. 不要害怕HTML变"脏"

刚转过来会觉得一堆class写在HTML里很脏,这不符和"表现与结构分离"的思想啊?

这是思维转换中最关键的一步。其实,当你适应了Utility-First,你会发现这样反而更直观,不用来回跳文件找样式。

3. 优先用默认配置,少自定义

Tailwind默认的设计系统已经非常完善了,颜色、间距、断点都有了,能满足90%的场景。不要一开始就去推翻默认配置重写一遍。

默认值够用就用默认值,不够用了再用方括号或者配置扩展。

4. 善用编辑器插件提升开发效率

VSCode的Tailwind CSS IntelliSense 插件一定要装,自动补全class名称,提示颜色,非常好用。

5. 记住这个决策树

遇到问题不知道该怎么做,问自己:

  • 这个会复用吗?不会 → 直接堆Utility
  • 会复用吗?会 → 用React/Vue组件提取(如果用框架)
  • 框架也不好处理 → 用@layer components提取
  • Utility搞不定 → 写自定义CSS,放到@layer utilities

Tailwind不是银弹,但它确实解决了CSS开发中长期存在的很多问题。对于从传统CSS转过来的开发者,只要适应了Utility-First的思维,开发效率会提升很多。

开始动手试试吧,从一个小组件开始,慢慢你就会爱上这种开发方式。


❌
❌