普通视图

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

Solana开发(1)- 核心概念扫盲篇&&扫雷篇

作者 Amos_Web
2026年4月7日 13:35

本篇文章针对的人群是有一些Rust基础,同时想要了解Solana开发的同学。如果您对这些没有任何了解的话,建议您直接点赞、收藏、评论即可,无需往下阅读~ Solana中文开发教程 Solana Rust相关教程

1. 前言

最近在学习Solana开发(别问为什么,问就是前端已死...),以为自己有一些Rust的基础学起来会比较简单,结果没想到阅读上面的教程看的云里雾里,看完了之后就看完了,完全串不起来,各种概念,账户,token、私钥、公钥、合约傻傻分不清楚。

我自己的学习思路是学一个大的概念,也就是把对这个东西的大的框架搭建起来,然后在尝试一点点的去填空。所以这篇内容的话也算是自己理解的整个Solana大框架内容,细节的话感觉上面的教程已经说的很清楚了,推荐如果想学的话先把这篇内容消化一下 然后再看上面的教程会好理解很多。

我把这几天的“填坑日记”整理出来,尽可能的使用大白话的方式来给大家讲清楚Solana的相关基础知识以及其背后的设计理念,尽可能避免一些专有名词的干扰,让你看完了能大概明白是怎么回事。

建议点赞、收藏、评论之后慢慢细品🤣🤣😄😄~~~

2. 核心概念

2.1 账户(Account)

2.1.1 是什么

在 Solana 中,一切皆账户。无论是你的钱包余额、你的代码、还是你发行的 Token 数据,甚至是你创建的智能合约,以及官方代币程序Token Program,全都是保存在一个个“账户”里的。 账户是Solana用于存储状态的基本数据单元。网络将所以状态存储在一个键值对数据库中,每个键是一个32字节的地址,每个值就是一个账户。 image.png

2.1.2 关键信息

  1. 结构:每个账户都包含相同的五个字段:lamports、data、owner、executable、rent_epoch。
  2. 地址:每个账户由唯一的 32 字节地址标识(可以是 Ed25519 公钥或 PDA)。
  3. 所有权:只有账户的 owner 程序可以修改其数据或扣除 lamports。任何程序都可以向任何可写账户充值 lamports。
  4. rent:每个账户都必须持有与其数据大小成比例的最低 lamport 余额,才能保持链上状态。

2.1.3 账户分类

  1. 可执行账户 (Executable Accounts/Program Accounts): 也就是我们常说的 Program (程序/合约)。这类账户里存放的是编译后的 BPF 字节码。它就像一个“只读可执行文件”,只能被调用

    • Program Account用于存储可执行的代码。每个Program Account都由Loader Program拥有。当Program部署时,运行时会创建一个Program Account来存放其字节码。
      image.png
  2. 非可执行账户 (Non-Executable Accounts): 也就是 数据账户 (Data Account)。它是专门用来存状态的(比如余额、游戏积分)。它通过一个 Owner 字段指明哪个程序有权操作它。

    • 程序状态账户:程序会将其状态存储在数据账户中,创建程序状态账户需要两个步骤:

      • 调用 System Program 创建账户。System Program 会将账户所有权转移给指定的程序。
      • 拥有该账户的程序根据其 instructions 初始化账户的 data 字段。 image.png
    • 系统状态账户:创建后仍由 System Program 拥有的账户称为 system account。首次向新地址发送 SOL 时,会在该地址创建一个由 System Program 拥有的新账户。

      • 所有的钱包账户都是System Account。交易的手续费支付者必须是System Account。因为只有System Program拥有的账户才能支付交易手续费 image.png

2.1.4 关键点:程序(Program)是无状态的!

你的 Rust 代码上链后,它只是个“只读的函数库”。它不存你的分数、不存你的余额。数据存哪?存在专门开辟的 Account(账户) 里。 说人话就是每个账户只能是可执行账户、或者是非可执行账户,不可以又包含数据,又包含状态,这就是Solana的设计哲学:数据和状态分离代码和可变状态的分离意味着程序只需部署一次,即可管理任意数量的数据账户。

2.2 指令

指令是对Solana程序执行特定功能的请求。指令是链上操作的基本构建块。 每条指令只指定一个要调用的程序、所需的账户、以及由程序解析的字节数组数据。 每条指令的执行逻辑都存储在程序中,每个程序定义了自己的指令集。要与Solana网络交互,需要将一条或多条指令添加到交易中,并发送到网络进行处理。

image.png

2.2.1 关键点

  1. **单一程序:**每条指令只针对一个程序,通过program_id
  2. 账户元数据: accounts 数组为指令读取或写入的每个账户提供账户元数据。
  3. 不透明数据: data字段是一个字节数组,其格式由目标程序定义。

2.2.2 指令结构

  1. program_id: 被调用的程序的ID 指令的program_id是包含该指令执行逻辑的程序的公钥地址。运行时会使用此字段将指令路由到正确的程序进行处理。
  2. accounts: 一个账户元数据数组 指令的accounts数组是一个有序的Account Meta 结构体列表。每个指令交互的账户都必须提供元数据。validator会利用这些元数据判断哪些交易可以并行运行。写入不同账户的交易可以并行执行。
    image.png
  3. data: 包含额外 数据 的字节数组,供指令使用。 指令的 data 字段是一个字节数组。用于告知程序要调用哪个函数,并为该函数提供参数。数据通常以一个判别符或索引字节开头,用于标识目标函数,后面跟着序列化的参数。 常见的编码约定:
    • 核心程序(System、Stake、Vote): 使用Bincode序列化的枚举变体索引,后跟序列化参数。
    • Anchor程序:使用8字节判别符,后跟Borsh序列化参数。

2.3 交易

按照上面的图片中的标识,一笔交易实际就是多个指令的集合。任何指令失败,整个交易失败,所有的状态更改都会被回滚。

2.3.1 如何界定交易包含多少指令

  • 原子性绑定: 如果指令A 失败,指令B就不能做,那就打包到一起;
  • 容量限制:单个交易包不能超过1232字节(约等于能塞下十几个账户地址);
  • 计算限制:逻辑太复杂会爆CU(计算单元),需要拆分或申请提高预算;

2.4 SPL(Solana Program Library)代币

代币是代表不同类别资产所有权的数字资产。代币化使财产权利的数字化成为可能。

2.4.1 是什么:

Solana 上的所有代币本质上都是由 Token Program 拥有的 数据账户。 基于数据账户这个说明,其实代币有两个核心”概念性“的账户:

  1. 铸币账户 (Mint Account) —— 代表“币”的本体 代表这种 Token 的本质。比如你今天发了一个 "USDT",这个 USDT 的总量、精度(Decimals)、管理员是谁,存在 Mint 账户里
  2. 代币账户 (Token Account) —— 代表“某人的口袋” 用来装这种 Token 的“口袋”。你的主钱包地址(Wallet)不能直接装 Token,必须名下挂一个专属的 "口袋(Token Account)" 才能装 USDT。 代币中的账户
    • Mint: 该token account持有的代币
    • Owner: 有权从该token account转移代币的账户
    • Amount: 该token account当前持有的代币数量

2.5 私钥、公钥、签名

2.5.1 是什么

代表着你在区块链世界的身份凭证和密码

2.5.2 关联

  • 公钥 (PublicKey) :就是我们口头说的“钱包地址”或“账户地址”,它是向外界展示的门牌号。
  • 私钥 (Secret Key) :能对这扇门发号施令的唯一钥匙,常以 id.json 文件的形式存在。
  • 签名 (Signature) :每次转账或修改账户数据时,用你的私钥“盖的章”。节点一验章,就知道“这确实是他本人操作的”,也就是授权。

2.6 RPC (远程过程调用节点)

2.6.1 是什么

区块链外围的服务亭(比如你刚试过的 https://api.devnet.solana.com 或 Helius 的节点)。

2.6.2 作用

不论你是要在网页上查询你的 SOL 余额(Query),还是通过 Web3 SDK 把签名好的交易(Transaction)丢上链,统统得通过 HTTP(甚至是 WebSocket 实时订阅)发给 RPC 节点。节点负责将这些包裹进一步分发到区块链主干网上的那些验证器进行执行打包。

2.7 DApp (去中心化应用前端)

2.7.1 是什么

为了不让用户对着命令行或者代码界面发呆而写的图形化网页系统。

2.7.2 典型框架

像目前 Solana 官方推崇的 @solana/wallet-adapter 组件。它能在网页上悬浮一个类似于 Phantom、Solflare 的插件图标,让用户在可视化的界面里点击一键登录,并对后端的转账交易弹出授权框。

3. 避坑第一条:别跟“代理”较劲!

刚开始跑 solana airdrop 就给我吃了一记闭门羹:connection closed via error

真相大白: Solana CLI 底层用的 Rust reqwest 库,在处理 macOS 代理时简直是“洁癖”。curl 都能通,它就是不通。咱也不知道为啥,但是查了很多资料之后直接放弃死磕到底,走捷径还是很轻松的。

  • 基础知识点: Solana 节点通信走的是 JSON-RPC 协议
  • 代码补充: 只要 RPC 节点配置对,其实一行代码就能查余额。
    // Web3.js 基础:连接节点并查询
    const connection = new Connection("https://api.devnet.solana.com");
    const balance = await connection.getBalance(myPublicKey);
    
  • 解决方案: 别死磕官方 RPC 了。换个 QuickNodeHelius 的免费专用节点,关掉代理直连,瞬间丝滑。

4. SPL Token:为什么我发个币要建一堆“口袋”?

在 Solana 上发币,全网只有一个主程序(Token Program)。你发币其实只是在“填表格”:

  1. Mint Account(铸币账户):这就是币的“身份证”,定义了谁能印钱。
  2. Token Account(代币账户):你的主钱包不能直接装币!想接某种币,必须在名下挂一个专属的“口袋账户”。
    • 核心逻辑: 所有的转账,本质上是 Token Program 这个“大管家”在帮你修改 A 口袋 and B 口袋里的数字。

5. 钱包、签名、公钥:三位一体的“印章”逻辑

  • 钱包(Phantom/CLI):它是你的印章保管员。私钥永远不出钱包,只负责在交易包上盖个戳(签名)。
  • 公钥(Public Key):你的收款码/身份证照,全网公开。
  • 签名验证(Ed25519):节点不需要你的私钥,它通过你的“签名结果”+“公钥照片”+“交易内容”进行数学推导,一眼就能看出这戳是不是印章盖的。

6. 终极奥义:Solana 凭什么这么快?

这绝对是面试必考题。Solana 的“多核并发”不是吹的。

1. 强制“提前报备”(Sealevel 引擎)

在以太坊,你不知道交易会动谁。而在 Solana,每个交易必须明确声明:我要读写哪些账户

  • 专业解释: 调度器一旦发现两笔交易操作的账户互不干扰,就会把它们分给不同的 CPU 核心并行处理

2. Rust 的天生优势

Solana 选 Rust 是有原因的。Rust 的**所有权(Ownership)**和内存安全机制,让它在多核高并发读写内存时,依然能保证不发生数据竞争(Data Race)。

3. PoH(历史证明)

给全网加了个“精准节拍器”。节点之间不用再在那儿磨叽“现在几点了”,大家看时间戳就能排好队,CPU 闷头猛算就行。

总结:Solana 的工程哲学

如果你习惯了 Web2 或者以太坊,初见 Solana 可能会觉得它设计得太繁琐。但一旦你接受了 “数据与代码分离”、“声明式读写” 这种设定,你会发现这是一个极致追求性能的分布式操作系统

AI 时代的管理后台框架,应该是什么样子?

作者 Hooray
2026年4月7日 13:24

这些年我一直在做 Fantastic-admin 这套管理后台框架。也一直在关注这个圈子的发展,虽然“技术栈在升级”、“UI 风格也在变化”,但管理后台框架核心一直在不断解决同一个问题:

如何把那些反复出现、又特别容易失控的工程问题,提前收敛成一套系统能力。

早期,这个问题的答案是“给我一个能跑起来的脚手架”;后来变成“帮我把常见页面骨架搭好”;再后来,变成“不要让我被框架反过来绑架”;而到了今天,在 AI 和 Agent 已经真的进入开发现场之后,我觉得问题已经变成了:

一个管理后台框架,能不能同时服务开发者和 Agent ?

这也是我写这篇文章的原因。在我看来,AI 当下的管理后台,已经不能只是一个后台模板,它必须是一套面向长期协作的工程系统。

再聊之前,不妨先回顾下管理后台框架的发展史。这里以 Vue 生态下的管理后台为主。

第一阶段:脚手架时代,解决了“从 0 到 1”

这个阶段最核心的诉求非常朴素:

  • 不要让我从空目录开始搭项目
  • 不要让我自己接 Vue、路由、状态管理、权限、登录、Mock、构建配置,哪怕其中有些我不用,但也最好有

在这个阶段,vue-element-admin 是绕不过去的一款产品,它除了解决了开发者的基本诉求外,还提供了一套非常前卫的设计:用路由驱动导航菜单

今天看这件事很自然,但在当时,这其实是很关键的一步:

  • 导航菜单不再需要额外维护一份数据
  • 路由结构和导航菜单结构天然一致
  • 标题、图标、权限这类信息可以集中管理

为什么这一步重要?因为后台和普通内容网站不一样,导航本身就是产品的信息架构。导航一乱,整个后台的认知成本就会上去。

所以在我看来,第一个阶段最重要的历史贡献就是这个路由即导航的设计,影响了几乎所有后来诞生的后台框架。

第二阶段:模板繁荣时代,开始出现“虚假的强大”

随着 Vue 3 发布,以及 vue-element-admin 作者的停更,大量新的管理后台框架开始出现。

这一阶段有一个非常明显的现象:与其说是框架,更像是“模板展厅”。因为你会看到:

  • 第三方插件集成示例越来越多
  • 图表、地图、编辑器、拖拽控件、可视化页面一应俱全

很容易让人觉得“这个框架很强”,但真的是这样么?

我们不可能在一个项目中把这些所有插件都用上,即便会用到其中几个,提供的这些示例页面也未必能满足实际的需求。而绝大多数真实业务团队,日常最高频的需求反而是:

  • 列表页怎么高效搭建
  • 搜索区、分页区、操作区怎么统一
  • 新增、编辑、详情页怎么组织
  • 菜单、路由、权限、缓存怎么协同

也就是说,这个阶段很多后台框架在解决的是“看起来像个成熟后台”的问题,而不是“怎样真正高效地服务开发者”的问题。

这是我做 Fantastic-admin 时非常警惕的一件事:

不要把框架做成一个演示效果很强、真正落地时却帮不上太多忙的样子货。

第三阶段:后台框架开始回到“系统能力”本身

如果说第二阶段有不少东西是在做“展示能力”,那么从第三阶段开始,我觉得后台框架终于慢慢回到了更本质的问题上:

它到底能不能成为一套真正服务业务的系统。

在我看来,这一阶段出现了两条很清晰的路线。

一条路线是向内走:把框架本身做得更完整

这条路线的核心是尽量扩充框架自身的系统能力,也是我开发 Fantastic-admin 时侧重的一条路。因为我发现,真正影响一个后台项目长期体验的,往往不是那些最显眼的东西,而是:

  • 导航布局够不够灵活
  • 页面布局能不能适配不同产品形态
  • 路由元信息够不够细
  • 标签栏、工具栏、偏好设置是不是成体系
  • 页面保活是不是只停留在“开/关”两档
  • 有没有合理的扩展位,而不是逼着开发者去改框架源码
  • 等等

这些能力平时开发使用未必会注意到,但它们决定了一个项目在需求扩张的时候,能否让开发者放心,不用担心框架没有提供这个能力的问题。

比如页面保活这件事,我一直觉得很多框架做得太粗了,通常都只是提供一个 keepAlive: true 的开关,虽然能解决一部分问题,但真实后台项目的诉求往往更复杂:

  • 从列表进详情,希望列表保活
  • 从列表跳其他模块,希望列表不保活
  • 标签页合并(Fantastic-admin专有功能)后,有些页面要保活,有些页面返回时必须释放保活

基于这些场景,我更想做的是一套可控的保活策略,而不是一个粗糙的开关,因为这才是业务开发者真正会长期依赖的能力。

另一条路线是向外走:继续靠近业务开发本身

另一条路线也很重要,因为一个事实是:后台大量业务页面,本质上高度重复。

  • 结构重复
  • 交互重复
  • 列表重复
  • 表单重复
  • 弹窗抽屉重复

总的来说就是大量 CRUD 模块高度重复,既然重复,那就不应该每次都从基础组件重新拼。

所以有框架开始探索更高层的业务抽象,比如 vben 就提供了更成熟的 CRUD 能力、更高集成度的表格表单组件,这些方向我都认为目前还是对的。

岔开聊一句,为什么说目前还是对的,因为高集成度的封装和抽象,本质上是减轻人类开发者的工作,假设我们面对一个5000-6000行的代码文件,想要理解它是很痛苦的,所以工程化、组件化的理念才如此重要。但这种大文件却刚好很契合 AI ,毕竟如果文件拆分太多,AI 频繁需要跨文件引入,上下文变得碎片化,必然会出现链路过长,信息丢失的情况,反而不适合 AI 优先的开发模式。

但不管怎么说,从这一步开始,后台框架的竞争终于不再停留在“模板多不多”,而是进入了更实在的层面:

谁能真正把业务开发里的重复劳动继续向上抽象。

补充一点:框架开始和 UI 组件库解耦

第三阶段继续往前走,我自己又越来越强烈地感受到另一个问题:

几乎所有后台框架和某个 UI 组件库绑定死了。

这会直接带来几个问题:

  • 开发者认同你的工程设计,但不认同你的 UI 风格
  • 框架代码和某个 UI 库深度绑定,更换 UI 库成本巨大
  • 一旦 UI 组件库停止维护或维护不积极时,整套系统都会受到牵连

发现这个问题后,我就知道不能把 Fantastic-admin 绑死在某个 UI 库上。

shadcn/ui 以及后来社区出现的 shadcn-vue ,对我来说是一个非常关键的信号。

它带来的最重要启发,不是某个按钮或者弹窗组件本身,而是它在强调一件事:

  • 组件代码应该是开放的
  • 组件应该是可读、可改、可延展的
  • 设计系统应该掌握在项目自己手里
  • 组件不是黑盒消费品,而是工程资产

shadcn/ui 官方甚至直接强调自己 不是传统组件库,而是一种构建组件的方式

当侧边导航、弹窗、抽屉、消息通知等等这些基础组件和 UI 组件库解耦后,Fantastic-admin 彻底变成了一套独立的,不再是某个 UI 组件库生态下的管理后台框架。

第四阶段:Agent 爆发之后,后台框架应该被重新定义

到了今天,AI 和 Agent 的爆发,不是在给后台框架“增加一个新卖点”,而是在逼着整个领域重新回答一个问题:

如果 AI 已经能读代码、改代码、理解目录、执行任务,那么管理后台框架应该如何被重新设计?

我自己简单分析了一下,在 AI 时代,一个管理后台框架至少应该具备下面 5 个特征:

1. 必须能让 AI 看懂项目全貌

这里就绕不开 monorepo 的架构了,过去我们说 monorepo 很多时候是在说工程治理、依赖复用、多应用扩展。

但今天我越来越觉得 monorepo 还有一个非常现实、而且会越来越重要的价值:

它天然更适合让 AI 建立完整上下文,能让 AI 拥有完整信息版图。

当应用代码、公共组件、主题、框架设置、文档、各种CI/CD脚本、技能定义都放在同一个结构清晰的仓库里时,AI 更容易快速理解:

  • 哪些是业务层
  • 哪些是公共能力
  • 哪些是配置边界
  • 哪些是复用资产
  • 哪些是项目约定,哪些只是偶然写法

Google 在那篇著名的 monorepo 文章里,把 monorepo 的价值概括为“common source of truth”。

我不想机械照搬这句话,但在 AI 协作语境下,它确实给了我很强的启发:

统一的代码真相源,也意味着统一的 Agent 理解入口。

这当然不是说用了 monorepo 架构,AI 就自动变聪明了。但至少它更容易看到全貌,减少 AI 幻觉的产生。

2. 必须有一套 AI 能稳定读取的项目协议

只有代码结构还不够,要想让 AI 想稳定工作,还必须有一层项目级协议,也就是 AGENTS.md ,或者 CLAUDE.md

它们本质上都在解决同一件事:

AI 协作不能只靠一次次聊天,而是需要项目内置的长期说明。

这意味着一个现代项目,未来不只是有给人看的 README,也应该有给 Agent 看的 README。

3. 应该把高频任务产品化为 Skills

Prompt 适合解决临时问题,但不适合承载高频、稳定、可复用的项目流程。

后台项目最常见的动作其实非常固定:

  • 生成 CRUD 模块
  • 新增表单页
  • 增加路由
  • 配置国际化
  • 修改框架设置
  • 生成 store
  • 定制主题
  • 优化/美化页面

如果这些事情每次都靠人重新组织一段 Prompt,AI 的表现一定会飘忽不定。这也让我决定要把这些高频动作沉淀成 Skills,把目录约定、实现策略、文件位置、限制条件、注意事项全部前置进去。这样做的好处非常直接:

  • AI 不再靠猜
  • 生成结果更接近项目现有风格
  • 不同 Agent 工具之间更容易复用同一套知识
  • 项目经验不再只存在聊天记录里,而会沉淀成长期资产

在我看来,这一步很重要,因为它意味着我们开始从“会用 AI”走向“把 AI 纳入工程系统”。

4. 必须把“可修改”放在“可调用”前面

在 AI 时代,我越来越觉得一个被黑盒包裹得太深的组件体系,长期价值其实会下降。

因为 Agent 最擅长的,不只是调用 API,而是:

  • 阅读现有代码
  • 理解现有代码
  • 修改现有代码
  • 基于现有代码继续延展

如果组件只是一个外部依赖包里的抽象壳,AI 的可操作空间是受限的;但如果组件体系是开放的、分层清晰的、仓库内可读的,AI 的工作质量通常会高很多。

相信这也是 shadcn/ui 爆火的原因之一。

这里说一个暴论,目前国内比较火的 UI 库,我一直都没有看到官方有提供 skills ,在一个既没有 skill ,AI 又无法直接阅读 UI 库的源码,这在当前环境下,很有可能会被逐渐弃用。

未来的软件系统,不只是给人维护的,也会越来越多地交给 AI 一起维护。

所以我理解的现代管理后台,不是“我有一堆组件”就够了,而应该是:

  • 有可读的组件实现
  • 有统一的组件约定
  • 有能沉淀后台业务场景的内建组件层
  • 有可替换的底层 UI 能力

5. 最终服务的是“长期协作”,而不只是“快速生成”

很多人一谈 AI,就会把重点放在“生成更快”上。但我做后台项目这些年越来越觉得:快,从来不是唯一问题,甚至很多时候都不是核心问题。

真正重要的是:

  • 生成出来以后,能不能做 code review
  • 多个页面之间风格能不能保持一致(UI风格、代码风格)
  • 多应用、多主题、多品牌场景下会不会慢慢失控
  • 人和 Agent 或多 Agents 混合协作时,项目是否仍然稳定

所以在我看来,AI 时代最好的后台框架,不一定是第一次生成最惊艳的那个,而应该是:

最适合持续迭代、持续扩展、持续被 AI 正确理解的那个。

最后聊一聊 Fantastic-admin 即将发布的 6.0 版本

v6-is-coming.png

如果把前面这几个阶段串起来看,其实就很容易理解,为什么 Fantastic-admin 要在这个阶段发布一个大版本更新。

因为对我来说,它已经不只是“一个 Vue 3 管理后台框架”,而是在尝试回答一个更具体的问题:

如果管理后台框架要面向下一个阶段,它应该提前长成什么样子?

1. 一套可长期演进的工程底座

Fantastic-admin v6 采用了 pnpm monorepo 架构,仓库里把应用、公共包、文档、脚本、技能清晰拆开:

fantastic-admin/
├── apps/              # 应用目录
│   ├── core           # 应用源码
│   └── example        # 示例应用
├── packages/          # 公共包目录
├── docs/              # 文档站点
├── scripts/           # 脚本工具
├── skills/            # AI 技能
└── package.json       # 根目录 package.json

这么做当然也有工程治理层面的考虑,但更重要的是,我希望“代码、文档、约定、技能”能够在同一个仓库里形成闭环。对于人来说,这是更清楚的工程边界;对于 Agent 来说,这是一张更完整的信息地图。

2. 把项目协议写进了仓库

仓库根目录有 AGENTS.md 文件,里面明确说明了:

  • 项目技术栈
  • 目录结构
  • 开发命令
  • 开发规范
  • 注意事项
  • 对技能使用的补充约束

这么做的原因很简单:我不希望 AI 每次都靠对话去猜这个项目是什么样子。

3. 把高频动作沉淀成了一套 Skills

目前已经有的 Skills ,包括但不限于:

  • CRUD 模块生成
  • 表单页生成
  • 路由生成
  • 国际化管理
  • 框架设置管理
  • 页面优化
  • 预留插槽创建
  • Store 生成
  • 主题定制

Skill 一方面是可以节省 token ,另一方面是将我的能力和我对框架的理解,形成了一套任何人都可以直接复用的标准,这是一份给 AI 的指导方针,让 AI 不再是猜测你的需求,或者可以说相当于你“雇用”了作者本人帮你完成需求😁。

4. 一套更加完善的系统设计

Fantastic-admin 一直以来的重点,都不是去堆砌多少示例页面,而是把后台真正核心的问题做成一套可配置化的系统:

这些能力拆开看都不算噱头,但组合在一起,我认为它们构成的不是一个“模板展示项目”,而是一套真正的后台基础设施。


image.png

至此,Fantastic-admin 即将发布的 6.0 全新版本就是我对 AI 时代管理后台框架的全部理解

如果你对 Fantastic-admin 开始感兴趣了,现在已经发布了 6.0 beta 版,欢迎来尝试体验,我将在4月中旬左右发布正式版本。

重新思考模板语言与 TypeScript 的结合:一条可落地的新路径

作者 梁高强
2026年4月7日 12:58

前端框架语法大致可以分为两类:模板语言框架(如 Vue、Svelte、Qingkuai)和 JSX/TSX 框架(如 React、Solid)。

在模板语言中,开发者通常可以在嵌入脚本块里获得接近原生 JS/TS 的编写体验,同时借助更简洁的模板语法完成常见渲染逻辑;代价是组件文件的灵活性会受到一定约束。JSX/TSX 则几乎让你在整份文件里都处在 JS/TS 的表达体系中,灵活性更高,但也会让 HTML 标签、CSS 样式与 JavaScript 代码深度交织,语法边界相对模糊。

以上只是对两类语法核心差异的简化描述,具体体验因人而异。本文聚焦一个长期存在的痛点:模板语言如何更好地支持 TypeScript

一、组件中的类型声明

在日常使用模板语言时,我一直有一个明显感受:主流框架对 TypeScript 的支持虽然已经很强,但在关键场景仍有不小门槛。最典型的就是几乎所有组件化框架都会遇到的 props 类型声明。

在这件事上,VueSvelte 采用了相近思路:通过编译标记(不同框架术语略有区别,例如 Vue 常称为编译器宏)声明类型。对于简单 props 这套方案基本够用;但进入泛型场景后,通常需要在 <script> 标签上额外声明泛型作用域,例如:

<script generics="T extends { id: number; name: string }"></script>

这在一定程度上背离了模板语言的核心优势:在嵌入脚本里提供一个纯净的 JS/TS 编程环境,让开发者专注业务逻辑,而不是额外语法细节。

更现实的问题是隐性成本。比如在 generics 属性中,是否可以访问嵌入脚本块内声明的类型?经过测试,Vue 与 Svelte 的表现一致但并不理想:

对于导入的外部类型,generics 可以访问;对于脚本块内部声明的类型,则无法访问。

导入类型.png

内部类型.png

我推测这与泛型组件的导出形态有关:语言服务可能需要将组件默认导出处理为函数,而 import 声明只能位于模块顶层,因此需要提升到函数外部,进而产生这种可见性差异。无论具体实现原因如何,这都会增加开发成本,并削弱模板语言应有的流畅体验。

这也是我在 Qingkuai 中做的一个核心取舍:保留 Props 作为组件全局类型声明。只要声明了 Props,就等于声明了 props 类型。这样一来,嵌入脚本块的编写体验和普通 JS/TS 基本一致。

props类型声明.png

这个设计还有一个额外收益:在非 TypeScript 项目中,仍可通过 JSDoc 注释声明 Props 类型,从而获得类型检查与补全能力。

jsdoc定义组件类型.png

二、泛型实参的传递

除了 props 声明之外,另一个高频痛点是:无法为组件泛型参数传递实参

在 Vue 与 Svelte 中,目前都缺少一套明确机制来向组件泛型传入实参。这会导致调用方即使具备明确的业务上下文,也无法通过显式传入泛型实参来收窄并主动约束组件类型。

组件泛型实参.png

三、插槽上下文类型推导

在插槽上下文类型推导上,模板语言相较 JSX/TSX 其实有天然优势:多数模板语言通过 slot 标签声明插槽出口,并可在标签上直接绑定要传递给插槽的数据。这为自动推导提供了明确入口,不必强迫开发者在组件内部增加额外类型标注。

反过来看 JSX/TSX(如 React),其并没有原生插槽概念,通常只能通过 children 模拟类似能力。这样一来,类型推导会明显更难,往往需要开发者手工声明函数类型来描述 children 的参数与返回值。

遗憾的是,当前主流模板语言仍未实现插槽上下文自动推导。Vue 支持手动标注插槽上下文类型;Svelte v4 使用 slot 定义插槽但不能标注其类型,v5 虽引入 Snippet 机制,仍需要开发者手动标注片段上下文类型,二者都不支持自动推导。

但从可行性看,这件事并不遥远。通过编译期静态分析、IR 标记与 TypeScript 语言服务提取类型的组合,模板语言完全可以实现插槽上下文自动推导。例如下面两个组件中,组件内部没有额外类型标注,调用方仍可获得完整推导与补全,甚至在纯 JavaScript 项目中也能自动推导插槽上下文类型:

插槽上下文类型推导.png

插槽上下文自动推导的价值不只在于减少类型声明成本,更在于 IDE 交互质量。借助 查找定义查找引用,开发者可以直接跳转到上下文定义源头,而不是落在类型定义中转层。

qingkuai插槽跳转.gif

vue插槽跳转.gif

在复杂组件里,这个差异非常直观。没有自动推导时,你往往需要先定位 <slot>,再分析绑定字段,最后回溯字段定义;有自动推导时,只需在插槽内容处执行一次 查找定义,即可直达源头,开发效率和可维护性都会明显提升。

四、组件类型导出

目前几乎所有模板语言都不要求手工定义组件导出类型,语言服务会根据组件内部声明自动推导默认组件类型。这本身是合理且高效的设计。

但另一个问题是:推导出的导出类型是否足够可读。Vue 可能因兼容历史语法而导致类型展示偏冗长;Svelte 虽然更简洁一些,但仍会暴露部分内部类型细节,容易增加理解成本。

vue组件导出类型.png

svelte组件导出类型.png

通过更清晰的导出类型结构设计,这个问题是可以优化的:

qingkuai组件导出类型.png

另外,很多开发者在写组件时都会习惯把鼠标悬停在组件标签上查看类型,但 Vue 与 Svelte 对这一体验的支持仍不理想:

svelte组件标签查看类型.png

vue组件标签查看类型.png

如果通过 TypeScript 语言服务的 TypeChecker 提取组件导出类型,并在标签悬停中返回该类型,落地并不复杂:

qingkuai组件标签查看类型.png

五、总结

本文从四个问题展开:组件内类型声明、泛型实参传递、插槽上下文类型推导,以及组件导出类型可读性。它们看似分散,本质上都指向同一个目标:让模板语言中的 TypeScript 体验尽可能接近“普通 TypeScript 文件”的直觉与效率。

从工程实践看,真正决定体验的往往不是语法表层,而是类型流是否连续。只要类型信息在“组件定义 -> 编译产物 -> 语言服务 -> IDE 交互”链路上断裂,开发者就会被迫用额外声明、注释和心智记忆去补洞。

围绕这一点,Qingkuai 采取了两项关键策略:

  1. 减少模板内额外语法负担:通过内置 Props / Refs 约定,将组件属性类型声明收敛到标准 TS 类型定义。
  2. 增强语言服务侧类型恢复能力:在编译期保留足够结构化标记,再由 TypeScript 语言服务提取并回填类型,用于补全、跳转与错误检查。

以插槽上下文为例,采用“编译期 IR 标记 + LSP TypeChecker 提取”路径后,类型推导不再依赖开发者逐处手工维护,IDE 也能把定义关系直接连接回真实源头。这不仅降低了类型维护成本,也显著改善了代码阅读与重构体验。

最终结论可以归纳为三点:

  1. 模板语言并不天然弱于 TS 体验:关键在于是否将类型系统纳入语言与工具链的一体化设计。
  2. 编译器与语言服务应协同设计:编译器负责可追踪标记,语言服务负责语义恢复与交互反馈。
  3. 高质量类型体验可以工程化落地:只要类型链路闭环,补全、跳转、诊断与可维护性就能同步提升。

这也意味着,这套思路并不局限于 Qingkuai。本质上,它为其他模板语言也提供了一条可行路线:在尽量保持模板语法简洁的前提下,通过编译器与语言服务协同设计,持续提升 TypeScript 体验。Qingkuai 的后续工作也可以沿着这条路径推进:补齐更多边界场景(复杂泛型、条件类型、跨文件符号映射),并以真实项目数据验证这套机制在大型代码库中的稳定性与性能表现。若你想进一步了解实现细节或直接上手验证,可以参考qingkuai文档在线体验qingkuai

告别 Vuex 的繁琐!Pinia 如何以更优雅的方式重塑 Vue 状态管理

作者 leafyyuki
2026年4月7日 12:55

引言:Vue状态管理的演进之路

在Vue生态系统中,状态管理一直是构建复杂应用的核心挑战。从早期的Vuex到如今的Pinia,Vue状态管理方案经历了显著的演进。随着Vue 3的发布和组合式API的普及,Pinia凭借其简洁性、类型安全性和卓越的开发体验,迅速成为Vue 3项目的首选状态管理库。

本文将深入探讨Pinia为何成为Vue 3状态管理的最佳实践,通过对比Vuex揭示其设计哲学的优势,并深入挖掘其底层实现机制。

一、Pinia的核心优势:为什么是Vue 3的最佳选择?

1.1 极简的API设计

Pinia摒弃了Vuex中复杂的mutations概念,允许开发者直接修改状态或通过actions进行修改。这种设计大幅减少了样板代码,使状态管理更加直观。

// Pinia的简洁写法
const store = useCounterStore()
store.count++ // 直接修改
// 或
store.increment() // 通过action修改

// 对比Vuex的繁琐流程
store.commit('INCREMENT') // 必须通过mutation

1.2 一流的TypeScript支持

Pinia在设计之初就充分考虑了TypeScript的支持,提供了完整的类型推断,无需额外的类型定义文件。

// 自动类型推断
const userStore = useUserStore()
userStore.name // string类型自动推断
userStore.login() // 参数和返回值类型自动推断

1.3 模块化的扁平结构

Pinia采用扁平化的store结构,每个store都是独立的,避免了Vuex中复杂的模块嵌套和命名空间问题。

// Vuex的嵌套模块结构
store/
├── index.js
├── modules/
│   ├── user.js
│   ├── cart.js
│   └── product.js

// Pinia的扁平结构
stores/
├── useUserStore.js
├── useCartStore.js
└── useProductStore.js

1.4 与组合式API的深度集成

Pinia完美契合Vue 3的组合式API哲学,提供了与refcomputed一致的使用体验。

二、Pinia vs Vuex:架构与设计哲学对比

2.1 架构对比图

传统Vuex架构 vs 现代Pinia架构
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│           Vuex Store                │ │            Pinia Ecosystem          │
│  ┌─────────────────────────────┐  │ │  ┌─────────────────────────────┐  │
│  │         Root State          │  │ │  │      Independent Store 1    │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │   Reactive State    │  │  │
│  │         Getters             │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │    Computed Getters │  │  │
│  │        Mutations            │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │      Actions        │  │  │
│  │         Actions             │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  └─────────────────────────────┘  │
│  ┌─────────────────────────────┐  │ │  ┌─────────────────────────────┐  │
│  │         Modules             │  │ │  │      Independent Store 2    │  │
│  │  ┌─────────────────────┐  │  │ │  │  ┌─────────────────────┐  │  │
│  │  │      Module A       │  │  │ │  │  │   Reactive State    │  │  │
│  │  └─────────────────────┘  │  │ │  │  └─────────────────────┘  │  │
│  │  ┌─────────────────────┐  │  │ │  │           ...             │  │
│  │  │      Module B       │  │  │ │  └─────────────────────────────┘  │
│  │  └─────────────────────┘  │  │ │  ┌─────────────────────────────┐  │
│  └─────────────────────────────┘  │ │  │      Independent Store N    │  │
└─────────────────────────────────────┘ └─────────────────────────────────────┘

2.2 设计哲学差异

Vuex的设计哲学:

  • 严格的状态变更流程(必须通过mutations)
  • 中心化的store管理
  • 强调可预测性和调试能力
  • 适合大型企业级应用

Pinia的设计哲学:

  • 灵活的状态管理(可直接修改)
  • 去中心化的store组织
  • 强调开发体验和简洁性
  • 适合现代Vue 3应用开发

2.3 详细特性对比表

特性维度 Vuex 4 Pinia 影响分析
学习曲线 陡峭(4个核心概念) 平缓(3个核心概念) Pinia上手更快
TypeScript支持 需要类型辅助 一流的自动推断 Pinia开发效率更高
包体积 ~10KB (gzipped) ~5KB (gzipped) Pinia更轻量
性能表现 良好 优秀(更少的包装层) Pinia略有优势
代码组织 模块嵌套,需要命名空间 扁平化store,天然隔离 Pinia更清晰
状态修改 必须通过mutations 可直接修改或通过actions Pinia更灵活
组合式API支持 兼容但不够自然 深度集成,体验一致 Pinia更现代
DevTools支持 完善 同等完善 两者都优秀
插件生态 丰富但复杂 简洁且易扩展 各有优势

三、Pinia底层实现深度解析

3.1 核心架构实现

Pinia的核心架构基于Vue 3的响应式系统和依赖注入机制,以下是其简化实现:

// 简化的Pinia核心实现
class Pinia {
  constructor() {
    this._s = new Map() // store注册表
    this._a = null // 当前活跃的pinia实例
    this._e = new Map() // 扩展插件
  }
  
  // store工厂函数
  defineStore(idOrOptions, setup) {
    return function useStore(pinia) {
      // 获取或创建store实例
      pinia = pinia || currentPinia
      
      if (!pinia._s.has(id)) {
        // 创建响应式store
        const store = createSetupStore(id, setup, pinia)
        pinia._s.set(id, store)
      }
      
      return pinia._s.get(id)
    }
  }
}

// store创建过程
function createSetupStore($id, setup, pinia) {
  let scope
  
  // 创建响应式上下文
  const partialStore = {
    _p: pinia,
    $id,
    // ... 其他属性和方法
  }
  
  // 使用effectScope管理响应式依赖
  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })
  
  // 合并store
  const store = reactive(
    Object.assign(partialStore, setupStore)
  )
  
  // 添加store方法
  store.$patch = function $patch(partialStateOrMutator) {
    // 实现状态批量更新
  }
  
  store.$subscribe = function $subscribe(callback, options = {}) {
    // 实现状态订阅
  }
  
  return store
}

3.2 响应式系统集成

Pinia深度集成Vue 3的响应式系统,其数据流如下图所示:

Pinia响应式数据流
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Component A   │    │   Pinia Store   │    │   Component B   │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │   State   │◄─┼────┼──│   State   │──┼────┼──│   State   │  │
│  │  (ref)    │  │    │  │  (ref)    │  │    │  │  (ref)    │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │  Getter   │◄─┼────┼──│  Getter   │──┼────┼──│  Getter   │  │
│  │(computed) │  │    │  │(computed) │  │    │  │(computed) │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │  Action   │──┼────┼─►│  Action   │◄─┼────┼──│  Action   │  │
│  │ (method)  │  │    │  │ (method)  │  │    │  │ (method)  │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
└─────────────────┘    └─────────────────┘    └─────────────────┘
        │                        │                        │
        │                        │                        │
        ▼                        ▼                        ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Vue Reactivity│    │  Effect Scope   │    │  DevTools Hook  │
│     System      │    │   Management    │    │    Integration  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

3.3 依赖注入机制

Pinia利用Vue 3的provide/inject API实现store的依赖注入:

// Pinia的依赖注入实现
const piniaSymbol = Symbol('pinia')

// 安装Pinia插件
function install(app, pinia) {
  // 提供pinia实例到整个应用
  app.provide(piniaSymbol, pinia)
  
  // 全局混入,方便Options API使用
  app.mixin({
    beforeCreate() {
      const options = this.$options
      if (options.pinia) {
        // 根组件设置pinia
        this._provided = {
          ...this._provided,
          [piniaSymbol]: options.pinia
        }
      }
    }
  })
}

// 获取store实例
function useStore(pinia) {
  // 从当前组件实例获取pinia
  const instance = getCurrentInstance()
  pinia = pinia || instance && inject(piniaSymbol)
  
  if (!pinia) {
    throw new Error('Pinia实例未找到')
  }
  
  return pinia._s.get(storeId)
}

3.4 插件系统架构

Pinia的插件系统基于中间件模式,允许在store生命周期中注入逻辑:

// 插件系统实现
function createPinia() {
  const pinia = {
    _s: new Map(),
    _p: [], // 插件列表
    use(plugin) {
      this._p.push(plugin)
      return this
    },
    _e: {
      // 插件执行上下文
      run(fn) {
        const runners = this._p.map(plugin => plugin({ pinia }))
        try {
          return fn()
        } finally {
          runners.forEach(cleanup => cleanup && cleanup())
        }
      }
    }
  }
  return pinia
}

// 持久化插件示例
const persistencePlugin = ({ store }) => {
  // 从localStorage恢复状态
  const stored = localStorage.getItem(store.$id)
  if (stored) {
    store.$patch(JSON.parse(stored))
  }
  
  // 订阅状态变化
  return store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

四、性能优化与最佳实践

4.1 性能优化策略

1. 响应式优化

// ❌ 避免:频繁解构store
computed(() => {
  const { items, filters } = useProductStore()
  return items.filter(/* ... */)
})

// ✅ 推荐:一次性解构
const productStore = useProductStore()
const { items, filters } = storeToRefs(productStore)

const filteredItems = computed(() => 
  items.value.filter(item => 
    filters.value.some(filter => item.tags.includes(filter))
  )
)

2. 计算属性缓存

// 使用computed进行缓存
const expensiveComputation = computed(() => {
  // 复杂计算逻辑
  return heavyCalculation(store.data)
})

// 避免在模板中直接计算
// ❌ <div>{{ heavyCalculation(store.data) }}</div>
// ✅ <div>{{ expensiveComputation }}</div>

4.2 代码组织最佳实践

src/
├── stores/
│   ├── index.ts              # 统一导出
│   ├── useUserStore.ts       # 用户相关状态
│   ├── useCartStore.ts       # 购物车状态
│   ├── useProductStore.ts    # 商品状态
│   ├── useUIStore.ts         # UI状态
│   └── types/               # 类型定义
│       ├── user.ts
│       ├── product.ts
│       └── index.ts
├── composables/             # 组合式函数
│   ├── useCartLogic.ts
│   └── useProductFilter.ts
└── plugins/                 # Pinia插件
    └── persistence.ts

五、迁移策略与升级指南

5.1 从Vuex迁移到Pinia

逐步迁移策略:

  1. 并行运行阶段:Vuex和Pinia共存
  2. 模块迁移:按功能模块逐个迁移
  3. 清理阶段:移除Vuex依赖

代码迁移示例:

// Vuex模块
// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({
    name: '',
    token: null
  }),
  mutations: {
    SET_USER(state, user) {
      state.name = user.name
      state.token = user.token
    }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  }
}

// 对应的Pinia Store
// stores/useUserStore.js
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    token: null
  }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      // 直接修改状态,无需mutation
      this.name = user.name
      this.token = user.token
    }
  }
})

5.2 兼容性处理

// 适配层:让Pinia兼容Vuex风格的代码
const createVuexCompatLayer = (piniaStore) => {
  return {
    state: piniaStore.$state,
    getters: new Proxy({}, {
      get(target, key) {
        return piniaStore[key]
      }
    }),
    commit: (mutation, payload) => {
      // 将mutation映射到action或直接修改
    },
    dispatch: (action, payload) => {
      return piniaStore[action](payload)
    }
  }
}

六、未来展望与社区生态

6.1 Pinia 2.0路线图

  • 更好的SSR支持
  • 性能优化(更小的包体积)
  • 增强的DevTools集成
  • 更多的官方插件

6.2 生态系统

  • pinia-plugin-persistedstate:状态持久化
  • pinia-plugin-debounce:action防抖
  • pinia-plugin-undo:状态撤销/重做
  • @pinia/testing:测试工具

结论

Pinia作为Vue 3的官方状态管理库,代表了Vue状态管理的新方向。它通过简洁的API设计、一流的TypeScript支持和现代化的架构,解决了Vuex在开发体验和类型安全方面的痛点。

核心价值总结:

  1. 开发效率:减少50%以上的样板代码
  2. 类型安全:完整的TypeScript支持,减少运行时错误
  3. 架构清晰:扁平化的store组织,易于维护和扩展
  4. 性能优异:更轻量的实现,更好的Tree-shaking支持
  5. 未来友好:深度集成Vue 3生态,持续活跃的社区

对于新项目,特别是基于Vue 3的项目,Pinia无疑是最佳选择。对于现有Vuex项目,建议制定渐进式迁移计划,逐步享受Pinia带来的开发体验提升。

在Vue状态管理的演进道路上,Pinia不仅是一个工具升级,更是开发理念的进步——它证明了简洁性、类型安全和开发体验可以完美共存,为Vue生态的未来发展奠定了坚实基础。

Cursor + Claude Code 组合使用心得:我为什么不只用一个 AI 编程工具

作者 清汤饺子
2026年4月7日 12:24

Hi~大家好呀,我是清汤饺子。

之前写了不少 Cursor 和 Claude Code 的单独教程,但最近被问得最多的一个问题其实是: "你平时到底用哪个?两个都装了吗?"

我的答案是:都用。

不是因为我钱多烧得慌,而是这两工具真的互补。用了一段时间组合拳之后,我的开发效率又上了一个台阶。今天就来聊聊我的真实使用心得。


一、我踩过的坑:只用其中一个的局限

1. 只用 Cursor?热情过头容易翻车

刚开始我是 Cursor 重度用户,毕竟 IDE 里直接写代码的感觉很顺。但用久了发现一个问题:Cursor 太"积极"了

有时候我只是想改个小样式,它能给你整出来一套组件抽象。我只是想加个简单的加载状态,它直接给你上了一整套状态管理方案。

不是说不好,而是有时候我真的就只想——快点搞定,别整那么多花活。

2. 只用 Claude Code?终端操作有局限

后来切到 Claude Code,发现上下文理解确实强,代码质量也更高。但问题是:它毕竟是 命令行工具 ,对 GUI 项目有点不太友好

比如我想在浏览器里调试一个 CSS 动画,想在某个特定的光标位置试试某个交互——这种事在终端里做起来就没有那么直接。

3. 结论:没有银弹

所以我的结论是:两个工具各有各的擅长领域,硬要二选一反而是给自己找麻烦。组合使用才是最优解。


二、场景分工:什么时候用哪个

经过几个月的磨合,我是这么分工的:

1. Cursor 更适合这些场景

1. 快速原型搭建

当我要验证一个新想法或者快速搭一个 demo,Cursor 的多文件编辑能力很强,能一次性给你生成一整套页面。这种场景 Claude Code 也可以,但 Cursor 更"短平快"。

2. 批量文件修改

比如我要把项目里 20 个组件的样式从 less 迁移到 styled-components,或者批量替换某个 API 调用——这种事 Cursor 的批量编辑效率很高。

3. GUI 调试

在浏览器里点点改改,看看效果,这种事 Cursor 集成得更好。Claude Code 的话你得靠终端里的预览,体验稍差。

2. Claude Code 更适合这些场景

1. 代码审查

把一堆代码丢给 Claude Code,让它帮我 review 一下质量、看看有没有潜在的 Bug——这种事 Claude Code 做得很漂亮。上下文理解能力强,能发现一些我自己可能忽略的问题。

2. 复杂重构

当我要对一个大文件或者多个模块做重构的时候,我会用 Claude Code 出方案,然后让它一步步来。Cursor 虽然也能做,但复杂场景下 Claude Code 的规划能力更强。

3. 长对话需求

比如我要和 AI 讨论一个架构设计,或者让它帮我分析一段老代码的逻辑——这种需要多轮对话的场景,Claude Code 的体验明显更好。


三、工作流串联:1+1>2 的组合拳

光说场景可能还是有点虚,来说说我每天是怎么组合用这两个工具的。

1. 新功能开发流

  1. 用 Cursor 快速搭架子:新功能的页面和基础组件,用 Cursor 快速生成第一版
  2. 用 Claude Code 审查优化:把代码丢给 Claude Code,让它帮忙看看有没有改进空间
  3. 回到 Cursor 执行:根据 Claude Code 的建议,在 Cursor 里做精细调整

2. Bug 修复流

  1. 用 Cursor 定位问题:在代码里直接搜索定位,Cursor 的跳转和搜索功能比较好用
  2. 用 Claude Code 分析根因:把相关代码丢给 Claude Code,让它帮忙分析可能的原因
  3. 回到 Cursor 修复:确认方案后在 Cursor 里执行修复

3. 代码重构流

  1. 用 Claude Code 出方案:让它先分析现有代码,设计重构方案
  2. 用 Cursor 执行:根据方案在 Cursor 里一步步改,Cursor 的修改精度更高

4. 我的每日模板

早上开工:
→ 用 Claude Code 过一遍昨天的代码,快速 review
→ 用 Cursor 开始今天的功能开发

下午:
→ 遇到复杂问题切 Claude Code 对话
→ 批量修改切 Cursor 执行

收工前:
→ Claude Code 总结今天改动,生成 commit message

四、实战心得:三个月磨合出来的经验

1. Rules 和提示词怎么差异化配置

Cursor 和 Claude Code 我配的 Rules 不太一样:

Cursor侧重于项目的技术规范——用什么组件库、用什么命名方式、样式写在哪个文件里。这种偏"执行层"的东西 Cursor 更容易遵守。

Claude Code侧重于架构和设计原则——为什么要这样设计、有什么权衡、哪种方案更合理。这种偏"思考层"的东西 Claude Code 更擅长。

2. 两个工具的上下文如何互补

我有个小技巧:用 Claude Code 建立项目记忆

每次项目有重大架构调整或者加了新模块,我会用 Claude Code 做一个详细的分析和总结,然后把这个结论记在项目文档里。下次 Cursor 接手这个项目的时候,就能通过读取文档快速了解上下文。

3. 避免"两个 AI 打起来"

有时候两个工具会给出一致的建议,那挺好;但有时候它们意见不一致,甚至互相否定对方的方案。

我的处理方式是:听更了解项目上下文那个的

比如这个模块是我用 Cursor 写的,Cursor 更清楚细节,那就以 Cursor 为主。反之亦然。


五、最后

说了这么多,其实就想说一点:工具是手段,不是目的

不管你用 Cursor 还是 Claude Code,还是两个组合用,最重要的还是解决实际问题。不要为了用工具而用工具,也不要因为某个工具火就跟风。

找到最适合你自己的工作流,让它为你服务——这才是正经事。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

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

作者 SmalBox
2026年4月7日 12:10

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

在 Unity URP Shader Graph 中,Reciprocal 节点是一个数学运算节点,用于计算输入值的倒数。这个节点在着色器编程中非常实用,特别是在需要进行除法运算优化或者处理颜色校正、光照计算等场景时。理解 Reciprocal 节点的原理和应用对于编写高效、性能优良的着色器至关重要。

Reciprocal 节点的核心功能是计算输入值的倒数,即输出值等于 1 除以输入值。在数学上,这表示为 f(x) = 1/x。这个简单的数学运算在图形编程中有着广泛的应用,从基本的颜色调整到复杂的光照模型都能见到它的身影。

在传统的 CPU 编程中,除法运算通常比乘法运算消耗更多的计算资源。在 GPU 编程中,这一差异更加明显,因为 GPU 被设计为并行处理大量数据,而除法运算可能会成为性能瓶颈。Reciprocal 节点通过专门的硬件指令或优化算法来解决这个问题,特别是在使用 Fast 方法时,能够显著提高着色器的执行效率。

Shader Graph 中的 Reciprocal 节点支持多种数据类型,包括 float、float2、float3 和 float4,这意味着它可以处理从标量值到四维向量的各种输入。这种灵活性使得节点可以适应各种不同的着色器需求,无论是处理单个颜色通道还是完整的 RGBA 颜色值。

描述

Reciprocal 节点的主要功能是返回 1 除以输入值 In 的结果。在数学上,这表示为 Out = 1/In。这个运算在图形编程中非常常见,特别是在需要归一化、颜色校正或者进行特定数学计算的场景中。

该节点的一个关键特性是提供了两种不同的计算方法:Default 和 Fast。这两种方法在精度和性能上有所不同,允许开发者根据具体需求进行选择。Default 方法使用标准的除法运算,确保最高的计算精度;而 Fast 方法则使用专门的 GPU 指令,在保持合理精度的同时提供更快的计算速度。

在着色器编程中,倒数运算有许多实际应用。例如,在实现镜面反射高光时,经常需要计算光线方向的倒数;在颜色校正中,可能需要计算颜色值的倒数来实现特定的视觉效果;在物理渲染中,倒数运算常用于计算衰减因子或其他物理量。

需要注意的是,当输入值为 0 时,倒数运算在数学上是未定义的,会导致除以零的错误。在实际的着色器执行中,这种情况通常会产生特殊值(如无穷大或 NaN),可能会影响渲染结果。因此,在使用 Reciprocal 节点时,应当确保输入值不会为零,或者添加适当的条件检查来处理这种情况。

数学原理

从数学角度来看,倒数函数 f(x) = 1/x 有一些重要的特性。它是一个反比例函数,图像为双曲线。当 x 趋近于正无穷时,f(x)趋近于 0;当 x 趋近于 0+ 时,f(x)趋近于正无穷;当 x 趋近于 0-时,f(x)趋近于负无穷。这些特性在图形编程中很重要,因为它们影响了函数在不同输入范围内的行为。

在着色器中,理解这些数学特性有助于预测和调试节点的行为。例如,当输入值非常接近零时,输出值会变得非常大,这可能会导致颜色值超出正常范围,产生不期望的视觉效果。因此,在实际应用中,通常需要对输入值进行限制或对输出值进行钳制。

精度考虑

在使用 Reciprocal 节点时,精度是一个重要的考虑因素。不同的计算方法可能会产生略有不同的结果,这取决于硬件的浮点数表示和具体的实现方式。在大多数情况下,这些差异很小,不会对视觉效果产生明显影响。但在某些对精度要求极高的应用中(如科学可视化或高质量的后期处理效果),可能需要仔细选择计算方法并进行测试。

Default 方法使用标准的除法运算,通常提供最高的精度,但计算成本较高。Fast 方法使用近似计算,精度略低但速度更快。选择哪种方法取决于应用的具体需求:如果精度是关键因素,应选择 Default 方法;如果性能是首要考虑,特别是在移动平台或需要处理大量像素的情况下,Fast 方法可能是更好的选择。

端口

Reciprocal 节点的端口设计体现了其功能的简洁性和灵活性。节点有一个输入端口和一个输出端口,两者都支持动态矢量类型,这意味着它们可以自动适应连接的数据类型,从简单的浮点数到四维向量。

输入端口

输入端口名为"In",是节点的唯一输入,接受动态矢量类型。这意味着它可以连接任何矢量类型的值,包括:

  • float:单精度浮点数,适用于单个数值或颜色通道
  • float2:二维向量,适用于 UV 坐标或二维位置
  • float3:三维向量,适用于颜色(RGB)或三维位置
  • float4:四维向量,适用于颜色(RGBA)或齐次坐标

输入值可以是常数、属性、其他节点的输出,或者任何可以计算为数值的表达式。在实际使用中,输入值通常来自纹理采样、顶点数据、数学运算或其他着色器图节点。

当输入值为 0 时,数学上会导致未定义的行为。在大多数 GPU 上,除以零会产生特殊值,如无穷大或 NaN(非数字)。这些值在着色器中传播可能会导致不可预测的视觉效果。因此,最佳实践是确保输入值不会为零,或者添加条件逻辑来处理这种情况。

输出端口

输出端口名为"Out",提供计算结果的动态矢量输出。输出的维度与输入相同:如果输入是 float,输出也是 float;如果输入是 float3,输出也是 float3,依此类推。

输出值的计算方式取决于选择的 Method 设置。使用 Default 方法时,输出通过标准的除法运算计算;使用 Fast 方法时,输出通过专门的倒数指令计算。

输出值可以直接连接到其他节点的输入,用于进一步的着色器计算。常见的使用场景包括:

  • 连接到颜色节点的输入,实现颜色调整
  • 连接到光照模型的参数,计算光照衰减
  • 连接到混合节点的输入,实现特定的混合效果
  • 作为其他数学运算的输入,构建更复杂的计算网络

理解输入和输出端口的行为对于有效使用 Reciprocal 节点至关重要。通过将 Reciprocal 节点与其他 Shader Graph 节点结合,可以创建复杂而高效的着色器效果。

控件

Reciprocal 节点提供了一个重要的控件:Method 下拉选单。这个控件允许用户在两种不同的计算方法之间进行选择,每种方法在精度和性能方面有不同的权衡。

Method 下拉选单

Method 控件是一个下拉选单,提供两个选项:Default 和 Fast。这个选择直接影响节点生成的代码和运行时的性能特征。

Default 选项使用标准的除法运算计算倒数。这种方法提供最高的精度,但计算成本较高。生成的代码使用标准的除法运算符("/"),如以下示例所示:

void Unity_Reciprocal_float4(float4 In, out float4 Out)
{
    Out = 1.0/In;
}

这种方法适用于需要最高精度的场景,如科学计算、高质量的颜色校正或其他对误差敏感的应用。在大多数现代 GPU 上,除法运算的性能已经相当不错,但在移动设备或需要处理大量像素的情况下,仍然可能成为性能瓶颈。

Fast 选项使用专门的 GPU 指令计算倒数。这种方法牺牲了一些精度以换取更好的性能。生成的代码使用 rcp 指令(倒数指令),如以下示例所示:

void Unity_Reciprocal_Fast_float4(float4 In, out float4 Out)
{
    Out = rcp(In);
}

rcp 指令是大多数现代 GPU 支持的特殊硬件指令,专门用于快速计算近似倒数。它的计算速度比标准的除法运算快得多,但精度略低。对于大多数图形应用,这种精度损失是可以接受的,因为人类视觉系统对小的数值误差不敏感。

选择指南

在选择使用 Default 还是 Fast 方法时,需要考虑几个因素:

  • 精度要求:如果应用对数值精度有严格要求,应选择 Default 方法。例如,在科学可视化、金融图表或其他需要精确计算的场景中,Default 方法是更好的选择。
  • 性能需求:如果着色器需要高效运行,特别是在移动设备或 VR 应用中,Fast 方法通常能提供更好的性能。性能提升的程度取决于具体的硬件和输入数据。
  • 目标平台:不同的 GPU 架构对除法和 rcp 指令的支持可能有所不同。需要了解目标平台的特性,并进行适当的测试。
  • 视觉效果:在某些情况下,两种方法产生的视觉差异可能可以忽略不计。可以通过并排比较或差异分析来评估精度损失是否会影响视觉效果。
  • Shader Model 要求:Fast 方法需要 Shader Model 5 或更高版本。如果目标平台不支持 Shader Model 5,则只能使用 Default 方法。

在实际项目中,通常建议先使用 Default 方法进行开发,确保视觉效果符合要求,然后在优化阶段尝试使用 Fast 方法,评估性能提升和可能的视觉差异。如果视觉差异可以接受,并且性能提升显著,则可以选择 Fast 方法。

生成的代码示例

理解 Reciprocal 节点生成的代码对于深入掌握其工作原理和进行高级优化非常重要。根据选择的 Method 不同,节点会生成不同的 HLSL 代码。

Default 方法代码

当 Method 设置为 Default 时,Reciprocal 节点生成使用标准除法运算的代码。以下是一个 float4 类型的示例:

void Unity_Reciprocal_float4(float4 In, out float4 Out)
{
    Out = 1.0/In;
}

这段代码定义了一个函数,接受一个 float4 类型的输入参数 In,并通过标准的除法运算符计算其倒数,将结果存储在输出参数 Out 中。

这种实现方式简单直接,使用了 HLSL 的基本除法运算符。在底层,GPU 可能会将这种除法转换为一系列微操作,具体取决于硬件架构。现代 GPU 通常有专门的除法单元,但除法仍然比乘法或其他简单运算更昂贵。

对于不同的数据类型,生成的代码结构类似,只是类型声明会相应改变。例如,对于 float 类型,生成的代码可能是:

void Unity_Reciprocal_float(float In, out float Out)
{
    Out = 1.0/In;
}

这种一致性使得节点在不同数据类型下的行为可预测,便于开发者理解和使用。

Fast 方法代码

当 Method 设置为 Fast 时,Reciprocal 节点生成使用 rcp 指令的代码。以下是一个 float4 类型的示例:

void Unity_Reciprocal_Fast_float4(float4 In, out float4 Out)
{
    Out = rcp(In);
}

这段代码使用了 HLSL 的内置函数 rcp,该函数利用 GPU 的专用硬件计算输入值的近似倒数。rcp 指令通常通过查找表和牛顿迭代法的组合实现,在保持合理精度的同时提供比标准除法更快的计算速度。

rcp 指令的精度因 GPU 架构而异,但通常足够满足大多数图形应用的需求。根据官方文档,rcp 指令的精度大约在 1.0e-5 左右,这意味着对于大多数视觉效果来说,误差是可以忽略的。

与 Default 方法类似,Fast 方法也支持不同的数据类型。例如,对于 float2 类型,生成的代码可能是:

void Unity_Reciprocal_Fast_float2(float2 In, out float2 Out)
{
    Out = rcp(In);
}

需要注意的是,Fast 方法需要 Shader Model 5 或更高版本的支持。如果目标平台不支持所需的 Shader Model,Unity 可能会回退到 Default 方法,或者编译失败,具体取决于项目设置。

代码优化考虑

理解生成的代码有助于进行着色器优化。以下是一些基于代码生成的优化建议:

  • 如果着色器需要多次计算相同值的倒数,考虑计算一次倒数并将结果存储在临时变量中,而不是多次调用 Reciprocal 节点。
  • 在向量运算中,如果只需要计算部分分量的倒数,考虑将向量分解为单个分量,只计算需要的倒数,然后再重新组合。这可以减少不必要的计算。
  • 当连续进行乘法和除法运算时,考虑使用代数变换将除法转换为乘法。例如,a/b * c 可以重写为 a * c / b,如果 b 是常数,甚至可以进一步优化为 a * (c / b)。
  • 在循环中使用 Reciprocal 节点时,特别注意性能影响。如果倒数值在循环迭代中不变,可以将其移出循环。

通过理解 Reciprocal 节点生成的代码,开发者可以做出更明智的决策,平衡精度和性能,创建更高效的着色器。

实际应用示例

Reciprocal 节点在 Shader Graph 中有多种实际应用。以下是一些常见的用例,展示了如何在不同场景中使用这个节点。

颜色校正和调整

在颜色处理中,Reciprocal 节点可以用于实现各种颜色校正效果。例如,可以创建一个简单的颜色反转效果:

  • 创建一个 Color 属性或从纹理采样获取颜色值
  • 将颜色值连接到 Reciprocal 节点的输入
  • 将 Reciprocal 节点的输出连接到主着色器的 Base Color 输入

这种技术会生成原始颜色的反色,因为对于颜色值 c(在 0-1 范围内),1/c 会产生反转效果。需要注意的是,当 c 为 0 时,1/c 会变得非常大,可能导致不期望的结果。为了避免这种情况,可以先对输入颜色进行偏移,例如使用(c + 0.001)而不是直接使用 c。

另一个颜色相关的应用是调整颜色的饱和度或亮度。通过计算颜色通道的倒数并与原始颜色混合,可以创建独特的颜色效果。

光照计算

在光照模型中,Reciprocal 节点常用于计算衰减因子。例如,在实现点光源衰减时,经常使用与距离平方成反比的衰减模型:

  • 计算表面点到光源的距离
  • 计算距离的平方
  • 使用 Reciprocal 节点计算距离平方的倒数
  • 将结果乘以光源强度,得到衰减后的光照贡献

这种衰减模型模拟了真实世界中光强随距离平方衰减的物理现象。使用 Reciprocal 节点可以高效地计算衰减因子,特别是在片段着色器中需要为每个像素计算时。

另一个光照相关的应用是计算反射向量。在镜面反射计算中,有时需要使用倒数运算来规范化向量或计算反射方向。

UV 变换和纹理操作

Reciprocal 节点可以用于 UV 坐标的变换和纹理操作。例如,可以创建一个平铺效果,其中纹理重复的频率与某个参数成反比:

  • 创建一个控制平铺频率的参数
  • 使用 Reciprocal 节点计算该参数的倒数
  • 将倒数值乘以 UV 坐标
  • 将结果用于纹理采样

这样,当参数值增大时,平铺频率会降低,纹理变得更大;当参数值减小时,平铺频率会增高,纹理变得更小。这种技术可以用于创建动态的纹理缩放效果。

另一个应用是创建径向渐变或其他基于距离的效果。通过计算中心点到当前点的距离的倒数,可以创建从中心向外衰减的效果。

特殊效果

Reciprocal 节点还可以用于创建各种特殊视觉效果。例如,可以模拟透镜效果或折射:

  • 计算从眼睛到表面点的向量
  • 计算该向量的长度
  • 使用 Reciprocal 节点计算长度的倒数
  • 将结果用于扭曲 UV 坐标或调整颜色值

这种技术可以创建类似水下或玻璃后的扭曲效果。通过调整计算方式和参数,可以实现各种不同的折射和变形效果。

另一个特殊效果应用是创建色差效果,即不同颜色通道的轻微偏移,模拟相机透镜的缺陷。通过为每个颜色通道计算不同的倒数并应用于 UV 偏移,可以实现这种效果。

这些示例展示了 Reciprocal 节点在着色器编程中的多样性和实用性。通过与其他 Shader Graph 节点结合,可以创建复杂而有趣的视觉效果。

性能优化建议

在使用 Reciprocal 节点时,考虑性能优化非常重要,特别是在目标平台包括移动设备或需要处理高分辨率的情况下。

方法选择优化

如前所述,Method 选择对性能有显著影响。以下是一些关于方法选择的优化建议:

  • 在开发初期使用 Default 方法,确保视觉效果符合要求
  • 在优化阶段尝试切换到 Fast 方法,评估性能提升和视觉差异
  • 如果视觉差异可以接受,优先使用 Fast 方法,特别是性能敏感的应用中
  • 对于需要最高精度的特定计算,保留使用 Default 方法

可以通过 Unity 的 Frame Debugger 或第三方性能分析工具来测量两种方法的性能差异。在实际设备上进行测试非常重要,因为模拟器或编辑器中的性能可能与实际设备不同。

计算优化技巧

除了方法选择外,还可以通过其他方式优化使用 Reciprocal 节点的着色器:

  • 避免在片段着色器中不必要的倒数计算。如果倒数值在顶点之间变化不大,考虑在顶点着色器中计算并通过插值传递给片段着色器。
  • 对于常量或 uniform 值,在 CPU 端计算倒数并通过着色器属性传递,而不是在着色器中计算。
  • 当需要计算多个相关值的倒数时,考虑使用向量化操作。例如,如果需要计算 RGB 三个通道的倒数,使用 float3 类型的 Reciprocal 节点比三个 float 类型的节点更高效。
  • 利用 GPU 的并行计算能力,确保倒数计算不会导致线程分歧。在条件语句中使用 Reciprocal 节点时要特别小心,因为不同的执行路径可能会导致性能下降。

移动平台特别考虑

在移动平台上,性能优化尤为重要。以下是一些针对移动平台的特别建议:

  • 在移动设备上,优先使用 Fast 方法,除非精度问题导致明显的视觉缺陷。
  • 减少使用 Reciprocal 节点的次数,特别是在片段着色器中。移动 GPU 的 ALU(算术逻辑单元)资源通常比桌面 GPU 更有限。
  • 考虑使用较低精度的浮点数格式(如 half 而不是 float),特别是在不需要高精度的计算中。这可以减少内存带宽和计算资源消耗。
  • 使用 Unity 的 Shader Variant Collection 功能,为不同平台编译不同的着色器变体,针对特定平台进行优化。

通过遵循这些优化建议,可以确保使用 Reciprocal 节点的着色器在各种平台上都能高效运行,提供流畅的用户体验。

常见问题与解决方案

在使用 Reciprocal 节点时,可能会遇到一些常见问题。了解这些问题及其解决方案有助于更有效地使用这个节点。

除以零问题

当输入值为零时,Reciprocal 节点会产生数学上未定义的结果。在实际执行中,这通常会导致特殊值(如无穷大或 NaN),可能会影响渲染结果。

解决方案包括:

  • 在输入值上添加一个小的偏移量,确保它永远不会为零。例如,使用(In + 1e-6)而不是直接使用 In。
  • 使用 Branch 节点或条件语句检查输入值是否接近零,并采取适当的行动,如使用默认值或钳制输出。
  • 在节点后面添加 Clamp 节点,将输出值限制在合理范围内,防止极端值影响后续计算。

精度不一致问题

当使用 Fast 方法时,可能会注意到不同硬件上的精度略有不同。这种不一致可能导致在不同设备上的视觉差异。

解决方案包括:

  • 对于对精度要求高的应用,使用 Default 方法确保一致性。
  • 如果必须使用 Fast 方法,进行充分的跨平台测试,确保视觉差异在可接受范围内。
  • 考虑使用自定义节点或代码函数实现特定精度的倒数计算,但这会增加复杂性,通常不推荐。

性能问题

在某些情况下,即使使用 Fast 方法,Reciprocal 节点仍可能导致性能问题,特别是在片段着色器中频繁使用或复杂计算图中。

解决方案包括:

  • 使用性能分析工具(如 Unity 的 Profiler 或 RenderDoc)识别性能瓶颈,确定是否确实由 Reciprocal 节点引起。
  • 优化着色器结构,减少不必要的倒数计算。
  • 考虑使用近似计算或查找表替代精确的倒数计算,但这需要权衡精度和性能。

平台兼容性问题

Fast 方法需要 Shader Model 5 支持,这可能不适用于所有目标平台,特别是较旧的移动设备或低端硬件。

解决方案包括:

  • 检查目标平台的最低 Shader Model 要求,确保兼容性。
  • 使用 Shader Graph 的 Platform Differences 功能,为不同平台设置不同的 Method 值。
  • 如果必须支持不支持 Shader Model 5 的平台

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

2026年最危险的,不是不会写代码,而是不会设计 Agent 工作流

2026年4月7日 11:53

上个月,我们给团队接了一个“代码评审 Agent”。

Demo 那天很惊艳:它能读 diff、能提重构建议、还能自动生成 review 评论。三天后,我们把它下线了。

不是模型突然变笨,而是它在最关键的一步不断翻车: 该谨慎时自信满满,该给证据时只给结论,该升级给人时硬着头皮继续执行。

那一刻我意识到,2026 年真正危险的能力断层,不是“你会不会写代码”,而是“你会不会设计 Agent 工作流”。

先说一个我看到的信号

这几天我刷 GitHub,有两个现象非常明显:

  • 框架在飞快迭代:LangChain langchain-core==1.2.26、LangGraph 1.1.6、LlamaIndex v0.14.20、vLLM v0.19.0、Transformers v5.5.0,发布时间几乎都挤在 4 月初。
  • 新项目的关注点在变化:比起“更会聊天的模型”,大家更关心“可执行、可回滚、可协作”的 Agent 工程能力。

这说明一件事: 模型能力正在变成基础设施,真正拉开差距的是流程设计能力。

为什么“会写代码”不够了

过去我们做软件,默认逻辑是确定性的:输入 A,得到 B。 现在接入 Agent 后,系统里多了一个概率节点:它有时正确,有时离谱,而且离谱时往往很像正确。

这意味着工程挑战变了:

你要设计的,不再只是函数调用链,而是一个“会犯错的执行体”的活动范围。

换句话说,代码能力解决“怎么做”; 工作流能力解决“做错了怎么办”。

Agent 工作流到底在设计什么

我现在把 Agent 工作流拆成四层:

  1. 任务状态层:每一步都有明确状态(待执行、执行中、成功、失败、需人工确认),不能只靠一段 prompt 一把梭。
  2. 决策路由层:不同任务走不同路径,简单任务直达,风险任务必须升级或二次验证。
  3. 工具约束层:Agent 不是“想调什么就调什么”,每个工具要有输入输出契约、超时、重试和幂等策略。
  4. 观测评估层:全链路日志、失败分类、回放样本、成本统计。没有观测,优化就是玄学。

很多团队不是模型不行,而是这四层有两层是空的。

那次“最后一步翻车”,我们怎么修

最开始我们的流程是这样的:

  • 把 PR diff 丢给 Agent
  • 让它输出 review 建议
  • 自动回写到代码平台

看起来顺,实际上风险极高。因为“输出”那一步没有证据门槛。

后来我们改成了五步:

  • 第一步:先做变更分类(语法改动、逻辑改动、依赖改动、接口改动)
  • 第二步:再做风险评分(低/中/高)
  • 第三步:只在需要时调用外部工具(测试日志、历史缺陷、相关文件)
  • 第四步:生成“带证据的建议”,每条建议必须附上定位依据
  • 第五步:高风险建议默认不自动提交,进入人工确认

改完之后,最明显的变化不是“它更聪明了”,而是“它更可控了”。

可控,才是工程系统里真正的智能。

我越来越确定的一件事

2026 年之后,团队会出现一个新分层:

  • 只会写 prompt 的人,在做“效果演示”;
  • 能设计工作流的人,在做“生产系统”。

前者能跑出一个漂亮 demo,后者能扛住凌晨两点的报警。

如果你今天就在做 AI 应用,我的建议不是“再调 20 版提示词”, 而是先问自己三个问题:

  • 你的 Agent 失败后会停在哪里?
  • 谁可以接管?接管时能看到什么上下文?
  • 这次失败会不会在一周后重复出现?

这三个问题,决定了你的系统是在“表演智能”,还是“交付智能”。

结尾

“不会写代码”会让你慢一点, “不会设计工作流”会让你在关键时刻直接失控。

下一个阶段,最值钱的工程能力,不是把模型接进来, 而是把模型关进一个可被治理的流程里。

这是我最近最深的体感。

你们团队现在的 Agent,属于“能跑”,还是“能扛事”?

深度解析:Vue3 为何弃用 defineProperty,Proxy 到底强在哪里?

作者 悟空瞎说
2026年4月7日 11:45

本文将从底层原理、语法缺陷、性能表现、实战场景全方位对比 ProxydefineProperty,用通俗易懂的语言 + 可运行代码,带你彻底理解 Vue3 响应式核心升级的底层逻辑,建议收藏细读。

前言

作为前端开发者,只要用过 Vue,就一定对响应式这个核心特性不陌生。Vue2 用 Object.defineProperty 实现响应式,而 Vue3 直接全面切换成了 Proxy,这不是简单的 API 替换,而是响应式原理的底层重构

很多同学会疑惑:defineProperty 明明能用,为什么非要换成 ProxyProxy 到底比 defineProperty 好在哪?难道只是因为「新」吗?

答案绝对不是。Proxy 解决了 defineProperty 天生无法弥补的语法缺陷、性能瓶颈、功能局限,是真正意义上的「响应式完美解决方案」。

本文将从基础用法、核心缺陷、性能对比、实战场景、源码原理五个维度,带你彻底吃透两者的区别,看完你会完全明白:Vue3 选择 Proxy,是技术演进的必然结果。


一、先搞懂:两者到底是什么?

在对比优劣之前,我们先回归本质:Object.definePropertyProxy 都是用来「监听对象属性变化」的 API,只是监听的方式、能力、范围天差地别。

1. Object.defineProperty(ES5)

定义:直接在一个对象上定义 / 修改一个属性,并可以监听该属性的 get(读取)和 set(赋值)行为。核心特点只能监听对象的「单个属性」,无法监听整个对象,更无法监听数组。

2. Proxy(ES6)

定义:创建一个对象的「代理器」,对目标对象的所有操作(读取、赋值、删除、调用、遍历等)进行拦截监听。核心特点监听整个对象,支持 13 种拦截操作,包括对象、数组、函数、Symbol 等所有引用类型。

简单一句话总结:

  • defineProperty给对象的属性「打补丁」
  • Proxy给对象套一层「防护罩」,所有操作都逃不过它的监听。

二、核心缺陷对比:defineProperty 天生硬伤

这是 Vue 弃用 defineProperty根本原因,也是 Proxy 最核心的优势。我们分 4 个维度,结合代码逐一拆解。

缺陷 1:无法监听「新增 / 删除」对象属性

defineProperty 必须在初始化时就明确监听对象的每一个属性,如果后续动态新增属性,或者删除已有属性,完全监听不到

1. defineProperty 实战演示

javascript

运行

// 1. 定义响应式函数(Vue2 底层简化版)
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log(`读取属性:${key}`);
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      console.log(`修改属性:${key},新值:${newVal}`);
      val = newVal;
    },
  });
}

// 2. 初始化对象
const user = { name: "张三" };

// 3. 手动监听已有属性
defineReactive(user, "name", user.name);

// 测试 1:读取/修改已有属性 ✅ 正常监听
user.name; // 输出:读取属性:name
user.name = "李四"; // 输出:修改属性:name,新值:李四

// 测试 2:动态新增属性 ❌ 无法监听
user.age = 18; // 无任何输出,响应式失效

// 测试 3:删除属性 ❌ 无法监听
delete user.name; // 无任何输出,响应式失效

2. Proxy 实战演示

Proxy 天生支持监听新增、删除对象属性,无需额外处理:

javascript

运行

// 1. 创建 Proxy 代理
const user = { name: "张三" };
const proxyUser = new Proxy(user, {
  get(target, key) {
    console.log(`读取属性:${key}`);
    return Reflect.get(target, key);
  },
  set(target, key, newVal) {
    console.log(`修改/新增属性:${key},新值:${newVal}`);
    return Reflect.set(target, key, newVal);
  },
  deleteProperty(target, key) {
    console.log(`删除属性:${key}`);
    return Reflect.deleteProperty(target, key);
  },
});

// 测试 1:读取/修改已有属性 ✅
proxyUser.name; // 读取属性:name
proxyUser.name = "李四"; // 修改/新增属性:name,新值:李四

// 测试 2:动态新增属性 ✅ 完美监听
proxyUser.age = 18; // 修改/新增属性:age,新值:18

// 测试 3:删除属性 ✅ 完美监听
delete proxyUser.name; // 删除属性:name

结论defineProperty 只能监听初始化时存在的属性,动态增删属性直接「失联」;Proxy 对对象所有属性(包括新增)天然监听,无需手动处理。


缺陷 2:无法原生监听数组(Vue2 hack 方案太笨重)

这是 defineProperty 最被诟病的问题:完全不支持监听数组

因为数组的 push/pop/shift/unshift/splice 等方法,以及通过索引修改数组修改数组长度defineProperty 都监听不到。

Vue2 为了解决这个问题,不得不重写数组的 7 个原型方法,做了一层 hack 兼容,但依然有两个致命盲区:

  1. 无法监听 arr[index] = val(通过索引赋值);
  2. 无法监听 arr.length = 0(修改长度)。

1. Vue2 数组 hack 演示(缺陷明显)

javascript

运行

// Vue2 重写数组方法的简化版
const arrayProto = Array.prototype;
const hackArrayProto = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
  (method) => {
    hackArrayProto[method] = function (...args) {
      console.log(`监听数组方法:${method}`);
      arrayProto[method].apply(this, args);
    };
  }
);

// 定义数组响应式
function defineArrayReactive(arr) {
  arr.__proto__ = hackArrayProto;
  // 依然无法监听索引赋值和长度修改
}

// 测试
const arr = [1, 2, 3];
defineArrayReactive(arr);

arr.push(4); // ✅ 监听:监听数组方法:push
arr[0] = 99; // ❌ 无响应(索引赋值失效)
arr.length = 0; // ❌ 无响应(修改长度失效)

2. Proxy 原生监听数组(零 hack、全覆盖)

Proxy 不需要重写任何数组方法,原生支持监听数组的所有操作

javascript

运行

const arr = [1, 2, 3];
const proxyArr = new Proxy(arr, {
  get(target, key) {
    console.log(`读取数组:${key}`);
    return Reflect.get(target, key);
  },
  set(target, key, newVal) {
    console.log(`修改数组:${key} = ${newVal}`);
    return Reflect.set(target, key, newVal);
  },
});

// 所有数组操作全能监听 ✅
proxyArr[0]; // 读取数组:0
proxyArr[0] = 99; // 修改数组:0 = 99
proxyArr.push(4); // 修改数组:3 = 4 + 读取数组:length
proxyArr.length = 0; // 修改数组:length = 0

结论

  • defineProperty 对数组是「残废状态」,必须 hack 修复,还有盲区;
  • Proxy 对数组是「完美支持」,原生监听所有操作,零兼容成本。

缺陷 3:深层对象必须「递归遍历」(性能灾难)

defineProperty 只能监听单层属性,如果对象是多层嵌套(比如 user.info.age),必须递归遍历整个对象,给每一个子属性都绑定 defineProperty

这会带来两个严重问题:

  1. 初始化性能差:数据越大,递归遍历越耗时;
  2. 内存占用高:所有属性都被提前监听,哪怕从未使用。

1. defineProperty 递归监听代码

javascript

运行

// 递归监听深层对象(Vue2 底层逻辑)
function defineReactive(obj) {
  if (typeof obj !== "object" || obj === null) return;
  // 遍历所有属性,递归绑定监听
  Object.keys(obj).forEach((key) => {
    const val = obj[key];
    Object.defineProperty(obj, key, {
      get() {
        console.log(`读取:${key}`);
        // 递归:如果属性是对象,继续监听
        defineReactive(val);
        return val;
      },
      set(newVal) {
        if (newVal === val) return;
        console.log(`修改:${key} = ${newVal}`);
        val = newVal;
        // 新值是对象,依然要递归监听
        defineReactive(newVal);
      },
    });
  });
}

// 测试深层对象
const user = { info: { address: { city: "北京" } } };
defineReactive(user); // 初始化就递归遍历所有层级

user.info.address.city; // 读取:info → address → city

问题:哪怕你永远用不到 user.info.address,初始化时也会被递归监听,纯纯浪费性能。

2. Proxy 惰性监听(按需递归,性能拉满)

Proxy 监听的是整个对象,不需要提前递归遍历。只有当真正读取到深层对象时,才会递归生成代理(惰性监听)。

这就是 Vue3 的响应式性能比 Vue2 快 1.3~2 倍的核心原因:

javascript

运行

// 惰性递归监听(Vue3 底层逻辑)
function createReactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      const val = Reflect.get(target, key);
      console.log(`读取:${key}`);
      // 按需递归:只有读取到深层对象,才创建代理
      if (typeof val === "object" && val !== null) {
        return createReactive(val);
      }
      return val;
    },
    set(target, key, newVal) {
      console.log(`修改:${key} = ${newVal}`);
      return Reflect.set(target, key, newVal);
    },
  });
}

// 测试深层对象
const user = { info: { address: { city: "北京" } } };
const proxyUser = createReactive(user); // 初始化不递归!

// 只有读取时,才逐层生成代理
proxyUser.info.address.city; 
// 输出:读取:info → 读取:address → 读取:city

优势:初始化零开销,用到哪一层,监听哪一层,性能极致优化。


缺陷 4:功能支持极度有限(仅支持 get/set)

Object.defineProperty 只支持监听两个操作get(读取)、set(赋值)。

Proxy 支持 13 种拦截操作,覆盖对象 / 数组 / 函数的所有行为:

  1. get:读取属性
  2. set:修改 / 新增属性
  3. deleteProperty:删除属性
  4. has:in 操作符
  5. apply:函数调用
  6. construct:new 调用
  7. ownKeys:遍历对象(Object.keys/for...in)
  8. 其他:definePropertygetOwnPropertyDescriptor

这意味着:Proxy 能实现 defineProperty 完全做不到的功能,比如监听对象遍历、函数调用、实例化等。

代码演示:Proxy 监听对象遍历

javascript

运行

const user = { name: "张三", age: 18 };
const proxyUser = new Proxy(user, {
  ownKeys(target) {
    console.log("遍历对象属性");
    return Reflect.ownKeys(target);
  },
});

Object.keys(proxyUser); // 输出:遍历对象属性
for (let key in proxyUser) {} // 输出:遍历对象属性

defineProperty 完全无法实现这个能力。


三、性能深度对比:Proxy 全面碾压

我们从初始化速度、内存占用、运行时开销三个核心指标对比:

1. 初始化速度

  • defineProperty:需要递归遍历整个对象,数据量越大,速度越慢;
  • Proxy直接代理整个对象,不遍历,初始化速度接近 O (1)。

测试结果:1000 个属性的嵌套对象,Proxy 初始化速度比 defineProperty5~10 倍

2. 内存占用

  • defineProperty:所有属性都绑定监听,内存占用随属性数量线性增长;
  • Proxy:只存一个代理对象,惰性递归,内存占用极低。

3. 运行时开销

  • defineProperty:属性访问直接调用 getter,运行时开销低;
  • Proxy:通过代理访问,有极微小的开销,但完全可以忽略。

总结:除了运行时极微小的差异,Proxy初始化、内存、扩展性上全面碾压 defineProperty


四、兼容性:唯一的小缺点(已完全解决)

很多同学会问:Proxy 是不是兼容性差?

答案:在 2026 年的今天,完全不是问题

  1. Proxy 支持 IE11+ 以外所有现代浏览器(Chrome、Firefox、Safari、Edge、移动端浏览器);
  2. Vue3 官方已经放弃 IE,主流业务系统也全面淘汰 IE;
  3. 无需 polyfill:Proxy 是底层 API,无法模拟实现,而现在已经不需要兼容 IE。

所以,兼容性已经不再是 Proxy 的短板。


五、Vue3 响应式:基于 Proxy 的完美实现

最后,我们用极简代码,实现一个 Vue3 响应式核心原理,你会直观感受到 Proxy 的强大:

javascript

运行

// 依赖收集容器
const targetMap = new WeakMap();
// 当前渲染的副作用函数
let activeEffect = null;

// 1. 依赖收集
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  dep.add(activeEffect);
}

// 2. 触发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (dep) dep.forEach((effect) => effect());
}

// 3. 响应式核心(Proxy 实现)
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      track(target, key); // 读取时收集依赖
      // 惰性递归:深层对象按需响应式
      if (typeof res === "object" && res !== null) {
        return reactive(res);
      }
      return res;
    },
    set(target, key, val, receiver) {
      const oldVal = Reflect.get(target, key, receiver);
      const res = Reflect.set(target, key, val, receiver);
      // 新值才触发更新
      if (oldVal !== val) trigger(target, key);
      return res;
    },
    deleteProperty(target, key) {
      const oldVal = Reflect.get(target, key);
      const res = Reflect.deleteProperty(target, key);
      if (oldVal !== undefined) trigger(target, key);
      return res;
    },
  });
}

// 4. 副作用函数(渲染函数)
function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

// ———— 实战测试 ————
const state = reactive({
  name: "Vue3",
  info: { age: 10 },
});

// 监听变化,自动执行
effect(() => {
  console.log("渲染:", state.name, state.info.age);
});

// 所有操作都能触发自动更新 ✅
state.name = "Proxy"; // 渲染:Proxy 10
state.info.age = 5; // 渲染:Proxy 5
state.city = "北京"; // 渲染:Proxy 5(新增属性)
delete state.name; // 渲染:undefined 5(删除属性)

这就是 Vue3 reactive API 的底层核心逻辑,代码简洁、功能强大、性能拉满。


六、终极总结:Proxy 完胜 defineProperty 的 5 大理由

  1. 支持监听对象新增 / 删除属性,defineProperty 完全不行;
  2. 原生监听数组所有操作,无需 hack 重写方法;
  3. 惰性递归监听,初始化性能碾压,内存占用更低;
  4. 支持 13 种拦截操作,功能覆盖所有引用类型行为;
  5. 代码更简洁、扩展性更强,是响应式的最优解。

defineProperty 是 ES5 的「妥协方案」,受限于语法,天生存在无法修复的缺陷;Proxy 是 ES6 专为「对象代理」设计的原生 API,从底层解决了响应式的所有痛点。

Vue3 选择 Proxy,不是跟风新技术,而是选择了更正确、更强大、更未来的技术方案


结尾

如果你是前端面试者,这篇文章可以直接作为 「Vue2 和 Vue3 响应式区别」 的标准答案;如果你是业务开发者,理解 Proxy 能帮你写出更健壮的响应式代码,避开 Vue2 的历史坑点。

最后问一句:现在你明白,为什么 Proxy 能彻底取代 defineProperty 了吗?

设计模式在前端的简易实现与作用

作者 LanceJiang
2026年4月7日 11:17

创建型模式(Creational Patterns)(5种)

1. 单例模式(Singleton) ***

  • 作用: 确保一个类只有一个实例,并提供一个全局访问点
  • 定义: 全局返回唯一实例
  • 场景: 全局状态管理、日志器、数据库连接池
const Singleton = (() => {
    let instance = null
    function create() {
        return {name: '单例模式'}
    }
    return {
        getInstance() {
        return instance || (instance = create())
        }
    }
})()

/*class Singleton {
    constructor() {
        if (!Singleton.instance) {
            Singleton.instance = this;
        }
        return Singleton.instance;
    }
    someMethod() { console.log('Doing something'); }
}
const s1 = new Singleton();
const s2 = new Singleton();
console.log(s1 === s2);*/

2. 工厂方法模式(Factory Method) ***

  • 作用: 定义创建对象的接口,由子类决定实例化哪个类
  • 定义: 将对象创建延迟到子类
  • 场景: 将对象的创建与使用分离,让系统在面对变化时能够更容易地扩展 (创建不同产品对象时,如 UI 组件库、文档生成器)
class Button {
    render() { console.log('渲染按钮'); }
}
class ButtonFactory {
    create() { return new Button(); }
}

const btnF = new ButtonFactory();
btnF.create().render()

3. 抽象工厂模式(Abstract Factory)

  • 作用: 提供一个创建一系列相关对象的接口,无需指定它们的具体类
  • 定义: 提供一个接口声明多个创建方法,具体工厂实现这些方法
  • 场景: 用于跨平台UI组件库
class Button { render() {} }
class WindowsButton extends Button { render() { return 'Windows Button'; } }
class MacButton extends Button { render() { return 'Mac Button'; } }
class GUIFactory {
    createButton() { throw new Error('Abstract method'); }
}
class WindowsFactory extends GUIFactory {
    createButton() { return new WindowsButton(); }
}
class MacFactory extends GUIFactory {
    createButton() { return new MacButton(); }
}
function app(factory) {
    const btn = factory.createButton();
    console.log(btn.render());
}
app(new WindowsFactory()); // Windows Button
app(new MacFactory());     // Mac Button

4. 建造者模式(Builder)

  • 作用: 将复杂对象的构建过程拆分为多个步骤,允许逐步构建
  • 定义: Director(指导者)控制构建流程,Builder提供逐步构建方法
  • 场景: 构造具有多个可选参数的复杂对象(复杂UI,配置对象)
class Pizza {
  constructor() {
    this.size = null;
    this.cheese = false;
  }
}
class PizzaBuilder {
  constructor() { this.pizza = new Pizza(); }
  setSize(size) { this.pizza.size = size; return this; }
  addCheese() { this.pizza.cheese = true; return this; }
  build() { return this.pizza; }
}

const pizza = new PizzaBuilder()
  .setSize('large')
  .addCheese()
  .build();
console.log(pizza)

5. 原型模式(Prototype)

  • 作用: 通过复制现有实例来创建新对象,而非通过构造函数避免重复初始化
  • 定义: 对象支持克隆(深拷贝或浅拷贝)
  • 场景: 对象初始化成本高、需要大量相似对象时(如克隆配置)
const prototype = {
  clone() {
    return Object.create(this);
  }
};
const obj1 = Object.create(prototype);
obj1.name = 'original';
const obj2 = obj1.clone();
console.log(obj2.name);

结构型模式(Structural Patterns)(7种)

6. 适配器模式(Adapter) ***

  • 作用: 将一个类的接口转换成另一个类期望的接口, 使不兼容的接口可以工作
  • 定义: 目标接口、适配者(Adaptee)、适配器(实现目标接口并持有适配者)
  • 场景: 将不兼容的接口适配起来,集成第三方库或老代码,统一接口
// 旧接口
class OldPrinter {
    print(text) { console.log(`打印: ${text}`); }
}
// 新接口期望的方法名
class NewPrinter {
    printDocument(doc) { console.log(`打印文档: ${doc}`); }
}
// 适配器
class Adapter extends NewPrinter {
    constructor(oldPrinter) {
        super();
        this.oldPrinter = oldPrinter;
    }
    printDocument(doc) {
        this.oldPrinter.print(doc);
    }
}

const old = new OldPrinter();
const adapted = new Adapter(old);
adapted.printDocument('hello'); // 打印: hello

7. 桥接模式(Bridge)

  • 作用: 将抽象部分与实现部分分离,使它们可以独立变化
  • 定义: 抽象类持有实现类接口,两者可独立扩展
  • 场景: 跨平台图形绘制、不同颜色和形状的组合
// 实现(Implementation)
class DrawingAPI {
  drawCircle(x, y, r) {}
}
class DrawingAPI1 extends DrawingAPI {
  drawCircle(x, y, r) { return `API1: (${x},${y}) r=${r}`; }
}
class DrawingAPI2 extends DrawingAPI {
  drawCircle(x, y, r) { return `API2: (${x},${y}) r=${r}`; }
}
// 抽象(Abstraction)
class Shape {
  constructor(drawingAPI) { this.drawingAPI = drawingAPI; }
  draw() {}
}
class Circle extends Shape {
  constructor(x, y, r, drawingAPI) {
    super(drawingAPI);
    this.x = x; this.y = y; this.r = r;
  }
  draw() { return this.drawingAPI.drawCircle(this.x, this.y, this.r); }
}
const circle1 = new Circle(1,2,3, new DrawingAPI1());
console.log(circle1.draw()); // API1: (1,2) r=3
const circle2 = new Circle(4,5,6, new DrawingAPI2());
console.log(circle2.draw()); // API2: (4,5) r=6

8. 组合模式(Composite)

  • 作用: 将对象组合成树形结构以表示“部分-整体”层次结构,使客户端统一对待单个对象和组合对象
  • 定义: 组件接口(包含add、remove、getChild等方法),叶子和容器实现该接口
  • 场景: 使用在 文件系统、UI组件树(如菜单、面板)、组织架构
class Component {
    constructor(name) {this.name = name; }
    display() { throw new Error('add display'); }
    // add() {}
    // remove() {}
}
class Leaf extends Component {
  constructor(name) { super(name); }
  display(depth) { console.log(`${'-'.repeat(depth)} ${this.name}`); }
}
class Composite extends Component {
  constructor(name) { super(name); this.children = []; }
  add(child) { this.children.push(child); }
  remove(child) {
      const index = this.children.indexOf(child)
      if(index > -1) this.children.splice(index, 1);
  }
  display(depth = 0) {
    console.log(`${'-'.repeat(depth)} ${this.name}`);
    this.children.forEach(c => c.display(depth+2));
  }
}

const root = new Composite('root');
const brach1 = new Composite('branch1');
brach1.add(new Leaf('left1-1'));
root.add(brach1);
root.add(new Leaf('left2'));
root.display();

9. 装饰器模式(Decorator) ***

  • 作用: 动态地给对象添加额外职责,比继承更灵活
  • 定义: 抽象组件、具体组件、抽象装饰(持有组件引用)、具体装饰
  • 场景: 扩展对象功能而不修改原代码,用于日志/权限校验/缓存/中间件等场景
function calculate(a, b) {
    return a + b;
}
function logDecorator(func) {
return function (...args) {
    console.log(`[LOG] 调用函数: ${fn.name || 'anonymous'}, 参数:`, args);
    const start = performance.now();
    const res = func.call(this, ...args)
    const end = performance.now();
    console.log(`[LOG] 函数执行结果: ${result}, 耗时: ${(end - start).toFixed(2)}ms`);
    return res;
    }
}
const loggedCalculate = logDecorator(calculate)
loggedCalculate(3, 5)

10. 外观模式(Facade)

  • 作用: 为子系统中的一组接口提供一个一致的界面,简化调用
  • 定义: 外观类封装复杂子系统,提供简单方法
  • 场景: 复杂系统封装(如启动/关闭流程)、简化复杂API调用、简化客户端调用
class CPU { start() { return 'CPU start'; } }
class Memory { load() { return 'Memory load'; } }
class HardDrive { read() { return 'HDD read'; } }
class ComputerFacade {
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.hdd = new HardDrive();
  }
  start() {
    return [this.cpu.start(), this.memory.load(), this.hdd.read()].join(', ');
  }
}
const computer = new ComputerFacade();
console.log(computer.start()); // CPU start, Memory load, HDD read

11. 享元模式(Flyweight)

  • 作用: 运用共享技术有效地支持大量细粒度的对象
  • 定义: 内部状态(共享)和外部状态(不共享)分离,享元工厂管理共享对象
  • 场景: 大量相似对象(如地图标记、文字字符)、对象池
class Character {
    constructor(char) { this.char = char; } // 内部状态
    display(fontSize) { console.log(`${this.char} with font ${fontSize}`); }
}
class CharacterFactory {
    constructor() { this.characters = {}; }
    getCharacter(char) {
        if (!this.characters[char]) this.characters[char] = new Character(char);
        return this.characters[char];
    }
}
const factory = new CharacterFactory();
const c1 = factory.getCharacter('A');
c1.display(12); // A with font 12
const c2 = factory.getCharacter('A');
c2.display(14); // A with font 14
console.log(c1 === c2); // true

12. 代理模式(Proxy) ***

  • 作用: 为其他对象提供一种代理以控制对这个对象的访问
  • 定义: 代理对象与真实对象实现相同接口,代理持有真实对象引用,可添加额外操作(延迟加载、访问控制、日志等)
  • 场景: 权限校验、日志记录、缓存、懒加载等场景
const target = {
    name: 'secret',
    getData() { return 'sensitive data'; }
};
const proxy = new Proxy(target, {
    get(obj, prop) {
        if (prop === 'getData') {
            return () => '无权访问';
        }
        return obj[prop];
    }
});

console.log(proxy.getData()); // 无权访问

// 代理 - 图片加载案例
class RealImage {
    constructor(filename) {
        this.filename = filename;
        this.loadFromDisk();
    }
    loadFromDisk() { console.log(`Loading ${this.filename}`); }
    display() { console.log(`Displaying ${this.filename}`); }
}
class ProxyImage {
    constructor(filename) {
        this.filename = filename;
        this.realImage = null;
    }
    display() {
        if (!this.realImage) this.realImage = new RealImage(this.filename);
        this.realImage.display();
    }
}
const image = new ProxyImage('photo.jpg');
image.display(); // Loading photo.jpg // Displaying photo.jpg // 加载并显示
image.display(); // Displaying photo.jpg // 只显示,不再加载

行为型模式(Behavioral Patterns)

13. 责任链模式(Chain of Responsibility)

  • 作用: 使多个对象都有机会处理请求,避免请求发送者与接收者耦合
  • 定义: 抽象处理者定义处理接口,具体处理者持有后继者引用,决定是否处理或传递
  • 场景: 审批流程、事件冒泡、中间件管道
class Handler {
  setNext(handler) { this.next = handler; return handler; }
  handle(request) {
    if (this.next) return this.next.handle(request);
    return null;
  }
}
class ConcreteHandlerA extends Handler {
  handle(request) {
    if (request === 'A') return 'Handled by A';
    return super.handle(request);
  }
}
class ConcreteHandlerB extends Handler {
  handle(request) {
    if (request === 'B') return 'Handled by B';
    return super.handle(request);
  }
}
class ConcreteHandlerC extends Handler {
  handle(request) {
    if (request === 'C') return 'Handled by C';
    return super.handle(request);
  }
}
const handlerA = new ConcreteHandlerA();
const handlerB = new ConcreteHandlerB();
const handlerC = new ConcreteHandlerC();
handlerA.setNext(handlerB).setNext(handlerC);
console.log(handlerA.handle('A')); // Handled by B
console.log(handlerA.handle('B')); // Handled by B
console.log(handlerA.handle('C')); // Handled by C
console.log(handlerA.handle('D')); // null

14. 命令模式(Command)

  • 作用: 将请求封装为对象,从而可用不同的请求对客户进行参数化
  • 定义: 命令接口(execute、undo),接收者(执行实际动作),调用者(invoker)触发命令
  • 场景: 撤销/重做、任务队列、宏命令、事务操作
// 接受者
class Receiver {
  action() { return 'Receiver action'; }
}
// 命令接口
class Command {
  execute() {}
}
// 具体命令
class ConcreteCommand extends Command {
  constructor(receiver) { super(); this.receiver = receiver; }
  execute() { return this.receiver.action(); }
}
// 调用者
class Invoker {
  setCommand(cmd) { this.command = cmd; }
  executeCommand() { return this.command.execute(); }
}
const receiver = new Receiver();
const command = new ConcreteCommand(receiver);
const invoker = new Invoker();
invoker.setCommand(command);
console.log(invoker.executeCommand()); // Receiver action

15. 解释器模式(Interpreter)

  • 作用: 给定一个语言,定义其文法表示,并定义一个解释器
  • 定义: 抽象表达式(Abstract Expression),终结符表达式(TerminalExpression),非终结符表达式(NonterminalExpression),上下文(context)
  • 场景: SQL解析、正则表达式、简单DSL(领域特定语言)
class Context { constructor(input) { this.input = input; } }
class Expression {
    interpret(context) { return context.input; }
}
class TerminalExpression extends Expression {
    interpret(context) { return context.input.toLowerCase(); }
}
class NonterminalExpression extends Expression {
    constructor(expr) { super(); this.expr = expr; }
    interpret(context) { return this.expr.interpret(context).toUpperCase(); }
}
const context = new Context('Hello');
const terminal = new TerminalExpression();
const nonterminal = new NonterminalExpression(terminal);
console.log(nonterminal.interpret(context)); // HELLO

16. 迭代器模式(Iterator)

  • 作用: 提供一种方法顺序访问聚合对象中的各个元素,而不暴露其内部表示
  • 定义: 迭代器接口(next、hasNext、current),聚合接口(createIterator)
  • 场景: 集合遍历(JavaScript原生迭代器即为此模式)、统一不同数据结构遍历方式
class Iterator {
  hasNext() {}
  next() {}
}
class ConcreteIterator extends Iterator {
  constructor(collection) {
    super();
    this.collection = collection;
    this.index = 0;
  }
  hasNext() { return this.index < this.collection.length; }
  next() { return this.collection[this.index++]; }
}
class Aggregate {
  createIterator() {}
}
class ConcreteAggregate extends Aggregate {
  constructor(items) { super(); this.items = items; }
  createIterator() { return new ConcreteIterator(this.items); }
}
const aggregate = new ConcreteAggregate([1,2,3]);
const iterator = aggregate.createIterator();
while (iterator.hasNext()) {
  console.log(iterator.next()); // 1,2,3
}


// ES6 迭代器模式
class MyCollection {
    constructor(items) { this.items = items; }
    [Symbol.iterator]() {
        let index = 0;
        const items = this.items;
        return {
            next() {
                if (index < items.length) {
                    return { value: items[index++], done: false };
                }
                return { done: true };
            }
        };
    }
}
const coll = new MyCollection([1,2,3]);
for (const val of coll) console.log(val); // 1 2 3

17. 中介者模式(Mediator)

  • 作用: 提供一种方法顺序访问聚合对象中的各个元素,而不暴露其内部表示
  • 定义: 中介者接口(通知方法),同事对象(Colleague)引用中介者
  • 场景: 聊天室、组件间通信(如MVC中的Controller)、飞机管制系统
class Mediator {
  notify(sender, event) {}
}
class ConcreteMediator extends Mediator {
  constructor(colleagueA, colleagueB) {
    super();
    this.colleagueA = colleagueA;
    this.colleagueB = colleagueB;
    this.colleagueA.setMediator(this);
    this.colleagueB.setMediator(this);
  }
  notify(sender, event) {
    if (event === 'A') return this.colleagueB.receive();
    if (event === 'B') return this.colleagueA.receive();
  }
}
class Colleague {
  setMediator(mediator) { this.mediator = mediator; }
  send(event) { return this.mediator.notify(this, event); }
}
class ColleagueA extends Colleague {
  receive() { return 'A received'; }
}
class ColleagueB extends Colleague {
  receive() { return 'B received'; }
}
const a = new ColleagueA();
const b = new ColleagueB();
const mediator = new ConcreteMediator(a, b);
console.log(a.send('A')); // B received (间接)

18. 备忘录模式(Memento)

  • 作用: 在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。
  • 定义: 发起人(Originator)创建备忘录,备忘录(Memento)存储状态,管理者(Caretaker)保存备忘录
  • 场景: 撤销/重做、保存游戏进度、事务回滚
// 备忘录
class Memento {
  constructor(state) { this.state = state; }
  getState() { return this.state; }
}
// 发起人
class Originator {
  setState(state) { this.state = state; }
  getState() { return this.state; }
  saveToMemento() { return new Memento(this.state); }
  restoreFromMemento(memento) { this.state = memento.getState(); }
}
// 管理者 History
class Caretaker {
  constructor() { this.mementos = []; }
  addMemento(m) { this.mementos.push(m); }
  getMemento(index) { return this.mementos[index]; }
}
const originator = new Originator();
const history = new Caretaker();
originator.setState('State1');
history.addMemento(originator.saveToMemento());
originator.setState('State2');
originator.restoreFromMemento(history.getMemento(0));
console.log(originator.getState()); // State1

19. 观察者模式(Observer)

  • 作用: 定义对象间的一种一对多依赖关系,当一个对象状态改变时,所有依赖于它的对象都得到通知并被自动更新
  • 定义: 被观察者(Subject 主题)维护观察者列表,提供注册、注销、通知方法;观察者(Observer)定义更新接口
  • 场景: 用于解耦发布者和订阅者, 事件监听、响应式编程(Vue), 发布-订阅
// 被观察者:维护观察者列表,状态变化时通知所有观察者
class Subject {
    constructor() {
        this.observers = [];
    }
    attach(observer) {
        this.observers.push(observer);
        return this
    }
    notify(data) {
        this.observers.forEach(obs => obs.update(data));
    }
}
// 观察者:接收通知并执行更新
class Observer {
    constructor(name) {
        this.name = name;
    }
    update(data) {
        console.log(`${this.name} 收到更新: ${data}`)
    }
}

const sub = new Subject();
const obs1 = new Observer('obs1');
const obs2 = new Observer('obs2');
sub.attach(obs1).attach(obs2);
sub.notify('hello'); // obs1 收到更新: hello   obs2 收到更新: hello

20. 状态模式(State)

  • 作用: 允许对象在内部状态改变时改变它的行为,好像修改了它的类 (避免了 if-else switch 的判断)
  • 定义: 上下文(Context)持有当前状态对象,状态接口定义行为,具体状态实现特定行为
  • 场景: 有限状态机(如订单状态、交通灯)、游戏角色状态
// 上下文
class Context {
  constructor(state) { this.setState(state); }
  setState(state) { this.state = state; this.state.setContext(this); }
  request() { return this.state.handle(); }
}
// 状态抽象
class State {
  setContext(context) { this.context = context; }
  handle() {}
}
// 具体状态
class ConcreteStateA extends State {
  handle() { return 'State A handling'; }
}
class ConcreteStateB extends State {
  handle() { return 'State B handling'; }
}
const context = new Context(new ConcreteStateA());
console.log(context.request()); // State A handling
context.setState(new ConcreteStateB());
console.log(context.request()); // State B handling


// 字面量示例
// 状态对象
const states = {
    A: {
        handle(context) {
            console.log('状态 A -> 切换到 B');
            context.state = states.B;
        }
    },
    B: {
        handle(context) {
            console.log('状态 B -> 切换到 A');
            context.state = states.A;
        }
    }
};

// 上下文
const context = {
    state: states.A,
    request() {
        this.state.handle(this);
    }
};

context.request(); // 状态 A -> 切换到 B
context.request(); // 状态 B -> 切换到 A

21. 策略模式(Strategy)

  • 作用: 定义一系列算法,把它们封装起来,并且使它们可以互相替换
  • 定义: 1.定义策略接口(algorithm) 2.具体策略类 3.上下文(Context)类 4.创建上下文,根据需求选择策略 执行方法
  • 场景: 表单验证算法、排序策略、支付方式(折扣计算)选择
class Context {
    constructor(strategy) { this.strategy = strategy; }
    setStrategy(strategy) { this.strategy = strategy; }
    pay(amount) { return this.strategy.pay(amount); }
}
// class Strategy {
//     pay(amount) { throw '请添加支付策略' }
// }
// class WechatPayStrategy extends Strategy {
class WechatPayStrategy {
    pay(amount) {
        console.log(`使用微信支付:${amount} 元`);
        // 实际调用微信支付API...
        return { success: true, method: 'wechat' };
    }
}
class AlipayStrategy {
    pay(amount) {
        console.log(`使用支付宝支付:${amount} 元`);
        return { success: true, method: 'alipay' };
    }
}
// 使用示例
const payment = new Context(new WechatPayStrategy())
payment.executePayment(100); // 使用微信支付:100 元
// 切换为支付宝
payment.setStrategy(new AlipayStrategy());
payment.executePayment(100); // 使用支付宝支付:200 元

// 函数版
// 策略函数
const regularMember = price => price;
const goldMember = price => price * 0.9;
const diamondMember = price => price * 0.8;

// 上下文类
class PriceCalculator {
    constructor(strategyFn) {
        this.strategyFn = strategyFn;
    }
    setStrategy(strategyFn) {
        this.strategyFn = strategyFn;
    }
    calculatePrice(originalPrice) {
        return this.strategyFn(originalPrice);
    }
}

// 使用方式与前面类似
const calculator = new PriceCalculator(regularMember);
console.log(calculator.calculatePrice(100)); // 100
calculator.setStrategy(goldMember);
console.log(calculator.calculatePrice(100)); // 90

22. 模板方法模式(Template Method)

  • 作用: 定义一个操作中的算法骨架,而将一些步骤延迟到子类中
  • 定义: 抽象类定义模板方法(final),并声明抽象步骤方法,子类实现抽象方法
  • 场景: 框架基类(如React组件生命周期)、工作流引擎、钩子函数
class AbstractClass {
  templateMethod() {
    return `${this.step1()} ${this.step2()}`;
  }
  step1() { return 'Abstract step1'; }
  step2() { return 'Abstract step2'; }
}
class ConcreteClass extends AbstractClass {
  step2() { return 'Concrete step2'; }
}
const obj = new ConcreteClass();
console.log(obj.templateMethod()); // Abstract step1 Concrete step2

23. 访问者模式(Visitor)

  • 作用: 表示一个作用于某对象结构中的各元素的操作,使可以在不改变各元素类的前提下定义作用于这些元素的新操作
  • 定义: 访问者接口(visit方法对应每种元素),元素接口(accept访问者),具体元素实现accept,具体访问者实现操作
  • 场景: 编译器语法树遍历(AST遍历)、报表生成、对象结构稳定但操作易变的场景
class Element {
  accept(visitor) {}
}
class ConcreteElementA extends Element {
  accept(visitor) { return visitor.visitConcreteElementA(this); }
  operationA() { return 'ElementA'; }
}
class ConcreteElementB extends Element {
  accept(visitor) { return visitor.visitConcreteElementB(this); }
  operationB() { return 'ElementB'; }
}

class Visitor {
  visitConcreteElementA(elem) {}
  visitConcreteElementB(elem) {}
}
class ConcreteVisitor extends Visitor {
  visitConcreteElementA(elem) { return `Visitor A ${elem.operationA()}`; }
  visitConcreteElementB(elem) { return `Visitor B ${elem.operationB()}`; }
}
const elements = [new ConcreteElementA(), new ConcreteElementB()];
const visitor = new ConcreteVisitor();
elements.forEach(e => console.log(e.accept(visitor)));
// Visitor A ElementA
// Visitor B ElementB

结语

其实我们前端在开发中有涉及到相关的设计模式,只是没有去总结。

你的 MR 超过 500 行了吗?——大型代码合并请求拆分实战指南

2026年4月7日 11:17

前言

前段时间,Node.js 社区一个引发热议的 PR 让我深有感触——Virtual File System for Node.js #61478,一个约 2 万行改动、130 个 commit 的超大 PR,从 1 月提交到 4 月仍未合入,期间经历了架构方向争论、多轮返工、被迫转回 Draft 重写 Review Guide。

这个案例让我重新思考了一个老生常谈但很多团队仍未做好的问题:MR(Merge Request,也叫 PR)到底多大算大?大了之后该怎么拆?

本文适合不同经验水平的工程师。无论你是第一次提交 MR 的新人,还是主导架构重构的资深工程师,希望都能从中有所收获。


一、为什么 MR 不能太大?

1.1 数据说话:超过 400 行,审查质量断崖式下降

SmartBear 基于 Cisco 2500 多次代码审查的数据研究(Best Practices for Code Review)得出了一个被业界广泛引用的结论:

  • 审查 200-400 行 代码时,缺陷发现率最高(约 70-90%)
  • 超过 400 行 后,发现率急剧下降
  • 审查速度超过 500 行/小时 时,几乎等于没看

Google 的工程实践文档(Google Engineering Practices - Small CLs)更是直接将「保持变更尽可能小」列为开发者的首要职责。

这不是审查者不认真,而是人类认知的固有限制。当一个 MR 包含 3000 行改动时,审查者的真实心理状态大概是这样的:

前 200 行:仔细看,提出有价值的反馈

200-800 行:开始疲劳,只关注明显问题

800 行以后:LGTM 🚢

说白了,一个 3 万行的 MR,本质上是一个没有被真正审查的 MR。

1.2 大 MR 的六宗罪

第一宗:审查沦为走过场

审查者打开一个 47 个文件变更的 MR,内心先产生畏惧。为了不阻塞团队进度,他们倾向于快速浏览后点击 Approve。

Google 的研究数据显示(Speed of Code Reviews),小型变更的审查响应中位数在数小时内,而大型变更常被推迟数天——因为没人愿意在工作间隙打开一个需要 2 小时才能看完的 MR。

第二宗:Bug 的天然藏身之处

改动越多,Bug 越容易躲在注意力盲区里。50 行的 MR,每一行都会被仔细审视;5000 行的 MR,一个关键的边界条件错误(比如循环少了一次、数组下标差一位)可能就藏在某个不起眼的角落。

真实案例:Node.js VFS PR(#61478)约 2 万行改动,审查者 ThanhDodeurOdoo 在审查数周后才发现一个设计层面的严重问题——fs.open() 在 VFS 路径下返回的是一个对象而非数字类型的文件描述符,这会破坏所有假定 fd 是数字的下游代码。如果 fs.open() 的集成是一个独立的小 MR,这个问题大概率在第一轮就能发现。

第三宗:合并冲突雪崩

一个存活 3 周的分支,主干上可能已经有了上百次提交。分支存活时间越长,冲突处理难度不是线性增长而是指数级上升——因为你改了 A 文件,别人也改了 A 文件,而你们的改动又各自依赖了 B、C、D 文件的不同版本。

Martin Fowler 在经典文章 Continuous Integration 中强调的核心理念就是「频繁地将代码集成到主干」。大 MR 与这一理念根本对立。

第四宗:回滚代价极高

线上出了问题需要回滚。如果问题来自 50 行的 MR,回滚影响清晰可控。如果来自 3000 行的 MR,回滚意味着同时撤销了其中 2950 行正确的代码——而这些代码可能已被后续 MR 依赖,导致连锁回滚。

第五宗:阻塞团队协作

你有一个 3 万行的 MR 在等审查。与此同时,3 个同事需要用到你写的某个工具函数。他们只能:等你合入(阻塞数天)、复制你的代码(产生重复)、或基于你的分支再开分支(嵌套依赖)。每一种都是坏选择。

第六宗:上下文鸿沟

你写这 3 万行代码用了 3 周,每个决策的来龙去脉记得清清楚楚。但审查者需要在几小时内重建你 3 周的心智模型——这几乎不可能。小 MR 让审查者只需理解一个小范围的上下文,反馈也更有针对性。

1.3 反直觉:拆成 10 个小 MR 反而更快

很多人不愿意拆分的理由是「拆成 10 个 MR 审查起来不是更慢吗?」

事实恰好相反:

方式 审查者要求 单次审查耗时 等待周期 总合入时间
1 个大 MR(3000 行) 需要 2-3 个资深审查者 2-4 小时(常被推迟) 3-7 天 1-2 周
10 个小 MR(300 行) 1 个审查者即可 15-30 分钟 当天或次日 3-5 天

小 MR 能利用审查者的碎片时间:等构建的 10 分钟、午饭前的 20 分钟、两个会议之间的 15 分钟。没有人愿意在碎片时间里打开一个 47 个文件的 MR。

以上数据趋势也与 LinearB 基于 610 万+ PR 的工程基准报告(2025 Engineering Benchmarks)一致——PR 越小,交付周期越短。


二、MR 到底多大合适?

2.1 经验法则

指标 建议范围 说明
改动行数(不含测试) 200-500 行 超过 800 行需要充分理由
改动文件数 1-10 个 超过 15 个文件很可能职责不单一
审查所需时间 15-45 分钟 超过 1 小时审查者容易走神
MR 描述长度 一段话能说清 如果描述需要写 2 页,MR 太大了

Google 的建议(Small CLs)是:一个变更应该是一个最小的、独立的、完整的改动。「独立」指可以单独被审查和理解;「完整」指不会让代码处于不可用的中间状态。

2.2 例外情况

以下场景允许更大的 MR,但需要在描述中说明原因:

  • 自动生成的代码(API 客户端、ORM 模型、图标文件等):标注哪些是自动生成的,审查者可以跳过
  • 批量重命名或移动文件:可能涉及几十个文件,但逻辑变更为零
  • 新增独立模块:全新的、不与现有代码耦合的模块,审查负担较低
  • 纯测试补充:为已有代码补充测试,运行时行为不变,风险较低

三、五种拆分策略(附实战案例)

策略一:按架构层次自底向上拆

适用场景:新增子系统、基础组件、底层库替换。

核心思路——按依赖方向拆分,先合入底层,再合入上层。就像建房子:先打地基,再砌墙,最后装屋顶。

PR 1: 类型定义 + 接口声明        零副作用,只定义"契约"
PR 2: 核心数据模型 / 工具函数     可独立编写单元测试
PR 3: 业务逻辑层                依赖 PR 2 的接口
PR 4: 集成层(与现有系统对接)    接入已有架构
PR 5: UI / 入口层               用户可见的变更

实际案例:Node.js VFS 的作者在 Review Guide 中自己划分了 10 个子系统——数据模型、文件系统、注入层、文件描述符、模块加载器、流与监听器、SEA 集成、Overlay 模式、Mock API、测试。每一个完全可以是一个独立 PR。

他后来不得不把 PR 转回 Draft,花额外时间写这份 Review Guide 帮助审查者理解——如果一开始就拆成 10 个 PR,每个 PR 本身就是自解释的,根本不需要 Guide。

策略二:Feature Flag 保护下增量合入

适用场景:大功能开发,需要长期迭代但又要频繁合入主干。

Feature Flag(功能开关)是一种通过配置控制功能是否对用户可见的技术。开关关闭时,新代码已在主干中,但用户完全无感知。

// PR 1: 引入 feature flag + 类型定义
const ENABLE_NEW_EDITOR = process.env.NEXT_PUBLIC_FF_NEW_EDITOR === 'true';

// PR 2: 新编辑器基础组件(flag 保护,用户看不到)
export const NewEditor = () => {
  if (!ENABLE_NEW_EDITOR) return null;
  return <EditorCore />;
};

// PR 3-8: 逐步实现子功能,每个 PR 独立可审查
// PR 9: 开启 flag,新功能上线
// PR 10: 清理 flag 相关代码

典型案例

  • React Fiber 架构重写React 16 发布博客):Facebook 用了约 2 年,在 feature flag 保护下通过数百个小 PR 完全重写了 React 的核心渲染引擎,期间 React 15 正常发布维护,用户零感知
  • Next.js App RouterNext.js 13 发布博客):Vercel 通过 appDir 实验性 flag,让新路由系统与旧路由系统并存超过一年

策略三:绞杀者模式(渐进式替换)

适用场景:框架迁移、大规模重构,新旧系统需要共存过渡。

这个名字来自热带雨林中的绞杀榕——它不砍倒旧树,而是缠绕在旧树上逐渐生长,最终完全替代。Martin Fowler 将这个比喻引入了软件工程

阶段 1 [1 个 PR]: 引入中间适配层
    旧代码 → 适配层 → 旧实现(行为完全不变)

阶段 2 [N 个 PR]: 新功能走新实现
    旧代码 → 适配层 → 旧实现
    新代码 → 适配层 → 新实现

阶段 3 [N 个 PR]: 逐个迁移旧模块
    所有代码 → 适配层 → 新实现

阶段 4 [1 个 PR]: 移除适配层
    所有代码 → 新实现

典型案例

  • React Class 组件 → Hooks 迁移:新组件用 Hooks,旧组件按优先级逐个迁移,两种写法长期共存
  • 数据库迁移(双写模式):先同时向新旧两个数据库写入,逐步切换读流量到新库,确认无误后停写旧库

策略四:先达成共识,再开始编码

适用场景:涉及架构决策、存在多种可能方案的改动。

PR 0: RFC / 设计文档(纯文档,不含代码)
       收集反馈,达成技术方案共识
       明确拆分计划和每个 PR 的范围

PR 1-N: 按共识方案逐步实现

RFC(Request for Comments,意见征集)是工程团队中常用的设计提案流程——先写方案文档,团队书面评审达成共识,再开始编码。Rust 语言所有重大特性都通过 RFC 流程推进。

反面案例:VFS PR 中,Qard 提出了完全不同的架构方向(依赖注入 vs 全局挂载),arcanis 则用 Yarn PnP 6 年的生产经验支持全局挂载。这个根本性的设计争论发生在 2 万行代码已经写完之后——如果事先有 RFC,可以避免大量可能被推翻的工作。

策略五:提取「零行为变更」的准备性 PR

适用场景:任何大改动都适合作为第一步。

在实现功能之前,先提交不改变任何运行行为的 PR:

PR 1: 纯重构——提取函数、拆分文件、调整目录
       运行前后行为完全不变,容易审查,大幅降低后续 PR 的差异噪音

PR 2: 类型定义和接口声明
       只定义"契约",审查者只需关注 API 设计

PR 3: 测试先行——为新功能写好测试用例(暂标记跳过)
       审查者通过测试用例就能理解需求和预期行为

PR 4-N: 逐步实现功能,每个 PR 解锁一批测试

这招最容易被忽视但效果最好。很多大 MR 里一半的差异来自文件移动和函数提取,这些噪音淹没了真正重要的逻辑变更。提前单独提交后,后续 PR 会干净很多。


四、「这个真的拆不了」——四种常见借口和破解方法

「必须一起改才能编译通过」

破解:引入中间过渡状态。

你的最终目标是 A → C,中间可以走 A → B → C,其中 B 是「新旧并存」的过渡态:

// PR 1: 新增新接口,保留旧接口
/** @deprecated 请使用 newMethod,将在下个迭代移除 */
function oldMethod() { /* ... */ }
function newMethod() { /* ... */ }

// PR 2: 迁移所有调用方到新接口
// PR 3: 移除旧接口

每个 PR 代码都能正常编译和运行。

「必须看到全貌才能验证架构」

破解:原型分支验证,小 PR 正式合入。

先做一个完整的原型分支(不需要达到上线标准),验证架构可行后,把原型作为参考蓝图,重新按小 PR 拆分合入主干。

看起来多做了一步,但总时间通常更短——因为小 PR 审查快、冲突少、合入快。

VFS PR 的作者实际上也走了类似的路——130 个 commit 的大分支写完后,转回 Draft 做重构。如果一开始就计划好「先原型验证,再拆分合入」,过程会顺畅得多。

「拆成小 PR 后每个都不算完整功能」

破解:区分「对用户有价值」和「对工程有价值」。

一个类型定义的 PR、一个被 feature flag 隐藏的半成品功能,对终端用户确实没有直接价值。但对工程过程有巨大价值——代码尽早进入主干、尽早被审查、尽早暴露问题。

只要每个 PR 合入后代码仍能正常编译运行(即使新功能还不可用),它就是一个合格的 PR。

「数据库结构变更必须和业务代码一起提交」

破解:向前兼容的分步迁移。

PR 1: 添加新字段(允许为空或有默认值),代码暂不使用
PR 2: 代码开始写入新字段,同时继续写入旧字段(双写)
PR 3: 运行脚本,将历史数据填充到新字段
PR 4: 代码切换到读取新字段
PR 5: 移除旧字段和双写逻辑

每一步都安全、可独立回滚。这也是 GitHub 等大型团队处理数据库变更的标准做法(参见 GitHub 工程博客:gh-ost: GitHub's Online Schema Migration Tool)。


五、提交前自检清单

每次提交 MR 前花 1 分钟对照检查:

  • 改动行数(不含生成代码和测试)在 500 行以内
  • 能用一句话说清这个 MR 做了什么?
  • 审查者能在 30 分钟内完成审查?
  • 出问题时能安全回滚这一个 MR 而不影响其他功能?
  • 没有同时包含重构和新功能?(应拆为两个 PR)
  • 没有包含多个不相关的改动?(各自独立提交)
  • 没有可以先独立合入的类型定义/接口/测试?
  • 大功能是否有 feature flag 保护?

有一项不达标,就优先考虑拆分。


六、给不同角色的建议

给 MR 提交者

  • 拆分是你的责任,不是审查者的。 不要等审查者来要求你拆
  • 写好描述:说清楚「为什么做」而不仅是「做了什么」
  • 系列 PR 标注关系:「X 功能第 3/7 个 PR,前置依赖 #123」
  • 换位思考:审查者应该先看哪个文件?改动核心在哪里?

给审查者

  • 大 MR 可以礼貌要求拆分:「范围较大,能否先拆出 X 部分?我可以更快给你反馈」
  • 无法拆分时,要求提供审查指引:先看哪个文件、核心决策在哪里
  • 审查不过来就坦诚说出来,比假装审查了更负责

给技术负责人

  • 把 MR 大小纳入团队代码审查文化,而不仅是个人习惯
  • 在 CI 中设置提醒:超过一定行数自动提示「建议拆分」
  • 关注 「MR 从提交到合入的平均周期」 这一指标,它与 MR 大小高度正相关(参见 DORA 研究:交付周期是衡量团队效能的四项关键指标之一)
  • 为复杂功能建立设计文档 / RFC 流程,编码前达成共识

总结

小 MR 不是额外的工作量,而是降低总工作量的手段。

拆分 MR 的能力是工程成熟度的体现:

  • 初级工程师——写出能运行的代码
  • 中级工程师——写出可维护的代码
  • 高级工程师——确保代码以最低风险、最高效率进入生产环境

MR 的组织方式,正是最后这一环中最被低估的技能。


参考资料

资源 说明
SmartBear - Best Practices for Code Review 基于 Cisco 2500+ 次审查的数据,200-400 行审查效率最高
Google Engineering Practices - Small CLs Google 内部代码审查指南,「变更应尽可能小」
Google Engineering Practices - Review Speed 审查响应速度与变更大小的关系
Martin Fowler - Continuous Integration 持续集成经典论述,强调频繁合入主干
Martin Fowler - Strangler Fig Application 绞杀者模式——渐进式替换旧系统
Node.js VFS PR #61478 2 万行超大 PR 的真实案例
Rust RFC 流程 「先共识再编码」的典范
DORA Research Google 支持的团队效能研究,交付周期是四项关键指标之一
LinearB - Reduce Cycle Time 数千个团队的数据:PR 越小,交付周期越短
GitHub Blog - gh-ost GitHub 数据库在线迁移的工程实践

如果这篇文章对你有帮助,欢迎点赞收藏。也欢迎在评论区分享你们团队在拆分大 MR 方面的经验和踩过的坑。

Vue3 虚拟列表实战 | 解决长列表性能问题(十万条数据流畅渲染,附原理)

作者 代码煮茶
2026年4月7日 10:44

一、长列表的性能困境

在企业级前端项目中,我们经常遇到这样的场景:

  • 后台管理系统:操作日志列表,一次加载几万条
  • 数据监控看板:实时数据流,持续追加
  • 聊天记录:几千条消息渲染
  • 商品评论:滚动加载无限列表

如果用传统的 v-for 直接渲染,浏览器会创建海量 DOM 节点。假设列表有 10 万条数据,每个 li 平均占用 300 字节(实际加上事件监听、样式计算等远不止),光是 DOM 节点就占用 30MB+  内存,滚动时浏览器需要重新计算布局和绘制,直接导致 掉帧、卡顿、甚至页面崩溃

<!-- ❌ 反面教材:直接渲染 10 万条数据 -->
<template>
  <div class="list">
    <div v-for="item in hugeList" :key="item.id">
      {{ item.text }}
    </div>
  </div>
</template>

打开 Chrome DevTools 的 Performance 面板,你会看到:

  • 首次渲染耗时 数秒
  • 滚动时帧率掉到 10fps 以下
  • 内存占用飙升,移动端直接闪退

二、虚拟列表原理:只渲染看得见的

核心思想:无论数据有多少,只渲染当前可视区域内的元素,其他元素用空白占位替代。当用户滚动时,动态计算需要显示的数据范围,替换掉离开可视区的 DOM 节点。

2.1 核心概念

┌─────────────────────────────┐
│       可视区域               │  ← 用户能看到的区域(固定高度)
│  ┌─────────────────────┐     │
│  │   item 10           │     │
│  │   item 11           │     │
│  │   item 12           │     │  ← 实际渲染的节点(只占3个)
│  │   item 13           │     │
│  └─────────────────────┘     │
├─────────────────────────────┤
│       缓冲区域               │  ← 上下额外多渲染几行,防止滚动白屏
└─────────────────────────────┘
         ↑
    占位元素(总高度 = 总行数 × 行高)

关键参数

  • total:总数据条数
  • itemHeight:每项的高度(固定高度场景)
  • containerHeight:可视区域高度
  • startIndex / endIndex:当前应该渲染的数据起始和结束索引
  • buffer:缓冲区大小(比如上下各多渲染 5 条)

2.2 计算公式

// 可视区域内最多能显示多少项
visibleCount = Math.ceil(containerHeight / itemHeight)

// 起始索引(根据滚动偏移量计算)
startIndex = Math.floor(scrollTop / itemHeight)

// 结束索引(加上缓冲区)
endIndex = Math.min(total - 1, startIndex + visibleCount + buffer)

// 实际需要渲染的数据
visibleData = data.slice(startIndex, endIndex + 1)

// 占位元素的总高度(用于撑开滚动条)
totalHeight = total * itemHeight

滚动时,只需要更新 startIndex 和 endIndex,Vue 会复用已有 DOM 节点,只更新数据内容,因此性能极高。

三、从 0 封装一个高性能虚拟列表组件

我们使用 Vue3 组合式 API + TypeScript 来实现一个通用的虚拟列表组件。

3.1 组件设计

<!-- components/VirtualList.vue -->
<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    :style="{ height: containerHeight + 'px' }"
    @scroll="handleScroll"
  >
    <!-- 占位元素:撑开滚动条高度 -->
    <div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
    
    <!-- 实际渲染的列表项,通过 transform 偏移到正确位置 -->
    <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleData"
        :key="getKey(item)"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <slot :item="item" :index="item.index"></slot>
      </div>
    </div>
  </div>
</template>

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

// Props 定义
interface Props<T = any> {
  // 数据源
  items: T[]
  // 每项高度(固定高度场景)
  itemHeight: number
  // 可视区域高度
  containerHeight?: number
  // 缓冲区大小(上下各多渲染多少条)
  buffer?: number
  // 唯一标识字段名或函数
  keyField?: string | ((item: T) => string | number)
}

const props = withDefaults(defineProps<Props>(), {
  containerHeight: 400,
  buffer: 5,
  keyField: 'id'
})

// 获取唯一 key
const getKey = (item: any): string | number => {
  if (typeof props.keyField === 'function') {
    return props.keyField(item)
  }
  return item[props.keyField] ?? item.id ?? Math.random()
}

// 滚动容器 DOM 引用
const containerRef = ref<HTMLDivElement | null>(null)
const scrollTop = ref(0)

// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 可视区域最多显示多少项
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight))

// 起始索引
const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
})

// 结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value + props.buffer * 2
  return Math.min(props.items.length - 1, end)
})

// 可见数据(带上原始索引)
const visibleData = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1).map((item, idx) => ({
    ...item,
    index: startIndex.value + idx
  }))
})

// 偏移量(让实际内容滚动到正确位置)
const offsetY = computed(() => startIndex.value * props.itemHeight)

// 滚动事件处理(节流优化)
let ticking = false
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement
  if (!ticking) {
    requestAnimationFrame(() => {
      scrollTop.value = target.scrollTop
      ticking = false
    })
    ticking = true
  }
}

// 监听 items 变化,如果数据变化导致总高度变化,可能需要重置滚动位置(可选)
watch(() => props.items.length, () => {
  // 可以增加重置逻辑,比如如果新数据为空,重置 scrollTop
})

// 暴露方法,供父组件调用
defineExpose({
  // 滚动到指定索引
  scrollToIndex(index: number) {
    if (containerRef.value) {
      containerRef.value.scrollTop = index * props.itemHeight
    }
  }
})
</script>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
  scroll-behavior: smooth; /* 平滑滚动,可选 */
}
.virtual-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.virtual-list-content {
  position: relative;
  z-index: 1;
}
.virtual-list-item {
  box-sizing: border-box;
  /* 可根据需要添加边框、内边距等,但注意要计入 itemHeight */
}
</style>

3.2 动态高度支持(进阶)

实际业务中,列表项高度往往不固定(例如评论区、富文本内容)。动态高度的实现更复杂,但原理相同:需要维护每项的高度缓存,动态计算总高度和偏移量。

// 动态高度版本的核心思路
const itemHeights = ref<number[]>([])          // 存储每一项的实际高度
const totalHeight = computed(() => itemHeights.value.reduce((a,b)=>a+b,0))

// 当某项渲染后,通过 ResizeObserver 或回调获取实际高度,更新缓存
function updateItemHeight(index: number, height: number) {
  if (itemHeights.value[index] !== height) {
    itemHeights.value[index] = height
    // 重新计算偏移量
  }
}

由于篇幅限制,这里不展开动态高度的完整代码,但原理与固定高度类似,只是需要额外维护高度数组。

四、性能对比:普通列表 vs 虚拟列表

我们模拟一个场景:渲染 10 万条 简单数据,每项高度 40px,可视区域高度 600px。

4.1 测试代码

<!-- 普通列表 -->
<template>
  <div class="normal-list" style="height:600px; overflow-y:auto">
    <div v-for="item in items" :key="item.id" style="height:40px; border-bottom:1px solid #eee">
      {{ item.text }}
    </div>
  </div>
</template>

<script setup>
const items = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `第 ${i} 条数据` }))
</script>
<!-- 虚拟列表 -->
<template>
  <VirtualList :items="items" :item-height="40" :container-height="600">
    <template #default="{ item }">
      <div style="height:40px; border-bottom:1px solid #eee">
        {{ item.text }}
      </div>
    </template>
  </VirtualList>
</template>

4.2 性能测试结果(使用 Chrome Performance + 内存快照)

指标 普通列表 虚拟列表
初始渲染时间 约 2800ms 约 45ms
DOM 节点数量 100,001 个 约 25 个(可视区+缓冲区)
内存占用 约 85 MB 约 8 MB
滚动帧率(fps) 平均 15-25 fps(卡顿明显) 稳定 60 fps
滚动时重排/重绘 每次滚动都大量触发 仅更新极少量节点

数据来源:Chrome 120,MacBook Pro 2021 实测。

4.3 为什么虚拟列表如此高效?

  • DOM 节点数量极少:只渲染可见区域内的 20-30 个节点,页面布局计算量极小。
  • 滚动时只修改 transform 偏移:不触发重排,只触发合成,GPU 加速。
  • 数据更新高效visibleData 变化时,Vue 仅更新现有节点的内容,不会创建/销毁大量 DOM。

五、项目中使用技巧与最佳实践

5.1 配合异步加载数据(无限滚动)

虚拟列表可以轻松与滚动触底加载结合:

<template>
  <VirtualList
    ref="virtualListRef"
    :items="displayItems"
    :item-height="50"
    @scroll-bottom="loadMore"
  />
</template>

<script setup>
import { ref, computed } from 'vue'

const allItems = ref([])
const page = ref(1)

const displayItems = computed(() => allItems.value)

const loadMore = async () => {
  const newData = await fetchData(page.value)
  allItems.value.push(...newData)
  page.value++
}
</script>

在 VirtualList 组件内增加 @scroll 监听,判断 scrollTop + clientHeight >= scrollHeight - threshold 时触发 scroll-bottom 事件即可。

5.2 与 Vue Router 缓存结合

如果列表页使用了 <keep-alive>,虚拟列表的状态(滚动位置)会被保留,需要手动恢复:

// 在组件内
import { onActivated } from 'vue'
const virtualListRef = ref()

onActivated(() => {
  // 恢复上次滚动位置
  const savedScrollTop = sessionStorage.getItem('listScrollTop')
  if (savedScrollTop) {
    virtualListRef.value?.$el.scrollTo(0, parseInt(savedScrollTop))
  }
})

5.3 处理不定高数据

对于评论区、动态内容等高度不固定的场景,推荐使用成熟库如 vue-virtual-scroller,或自行实现动态高度虚拟列表。核心步骤:

  1. 初始化时给每项一个预估高度,用于计算占位总高度
  2. 渲染后通过 ResizeObserver 获取真实高度
  3. 更新高度缓存,重新计算偏移量
  4. 使用二分查找快速定位滚动位置

5.4 性能监控与调优

  • 避免在 item 插槽内使用复杂计算属性或大型组件,保持列表项简单。
  • 如果列表项内有图片,使用懒加载(loading="lazy")或 IntersectionObserver
  • 使用 shallowRef 包裹大数据集,减少深度响应式开销。

六、总结与扩展

虚拟列表解决了什么:通过牺牲“全量渲染”来换取极致的滚动性能和低内存占用,是处理长列表的标准方案。

适用范围

  • ✅ 数据量极大(> 1000 条)
  • ✅ 列表项高度固定或可预估
  • ✅ 需要流畅滚动体验

不适用场景

  • ❌ 列表项高度频繁变化且不可预测(可改用动态高度虚拟列表)
  • ❌ 列表项需要复杂动画过渡
  • ❌ 数据量很小(< 200 条),直接用普通列表更简单

扩展阅读

  • 表格虚拟滚动(<el-table> 开启 virtual-scroll
  • 树形控件虚拟滚动
  • 基于 IntersectionObserver 的无限滚动懒加载

通过本篇文章,你不仅理解了虚拟列表的核心原理,还能亲手实现一个企业级可复用的组件。下次面试官问“如何渲染 10 万条数据”,你就可以自信地亮出代码,并解释背后的性能优化哲学。🚀

附:完整组件源码仓库(示例链接,可根据实际提供)


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发,让更多人告别长列表性能焦虑!

基于 Cursor Agent 的流水线 AI CR 实践|得物技术

作者 得物技术
2026年4月7日 11:13

一、背景

在实际迭代开发中,不同需求的代码规模差异很大,有些需求涉及上千行代码,有些则只有一两行。且对于前端的代码验收,主要侧重在界面功能,通过功能验收,没法确保每一行代码都测试到的,以及功能的代码逻辑是否合理,是否健壮、是否规范等问题,都需要通过人工代码 CR 来进一步兜底验收代码的质量,尽量降低业务线上出错的可能。但当面对上千行的代码变更时,人工 CR 也是心有余而力不足。

传统的代码审查依赖人工,面对大规模代码变更时效率有限,而 AI 代码审查能够实现自动化、标准化的质量检查,有效补充人工审查的不足。

二、前端研发 CR 现状与可优化点

CR 现状

目前前端研发同学主要使用的代码质量保障工具有前端 Apex 插件智能体、Uraya 质量分检测。其中 Apex 插件智能体是通过前端研发自助点击或 git hook 自动触发 CR 智能体执行,智能体内定制了 CR 规则以及与 MCP 的结合,利用 Cursor IDE 的 Agent 能力进行本地 AI CR ,找出代码问题、本地解决问题。Uraya 质量分检测是在创建 MR 后,通过流水线自动触发,Uraya 质量分检测代码变更的质量分浮动,产出具体问题的记录,引导研发优化代码。

可优化点

  1. 本地触发 CR 需要研发同学主动点击触发或者通过 Apex git hook 执行 CR 智能体,当开发的需求多、分支多、提交次数多的时候,时长容易漏触发、忘记点。
  2. 对于 MR 评审人员,如果希望通过 Cursor CR 时,需要在本地通过调用 CR 智能体再执行一遍,获取 CR 结果,在目前 Cursor 按量计费的背景下,重复执行 CR 智能体的成本需要及时关注。
  3. 当前流水线 Diff + 大模型 API 的 AI CR 方式,误报率较高,研发使用意愿较低。

三、AI CR 方案对比分析

基于以上现状分析,我们对不同 AI CR 方案进行了深入对比。

Cursor Agent CR 主要优势

流水线集成 CR 与本地 AI CR 差异

四、技术方案设计

结合目前现状与可优化点,我们期望能像 Uraya 质量分检测一样,在 MR 过程中通过流水线自动触发,中途每次代码提交也能自动触发,对于流水线中的 CR 不满意时,可以结合 Apex CR 智能体进行本地 CR 调整代码。

为此我们考虑结合 Cursor Agent CLI 在流水线中增加一个 AI CR 的任务,自动触发 Cursor Agent 代码 CR,并记录 CR 结论,及时展示给研发或者代码评审的同学,辅助代码质量优化。

整体链路设计如下:

  1. 当研发创建 MR 后,流水线配置了 AI CR 检测流水线后,将会自动触发 Cursor Agent CR 任务。
  2. 接收到检测任务后,将会前置将该仓库准备好,并将 MR 的信息以及制定的 CR 规则,一并交给 Cursor Agent CLI 执行,待执行完成,会得到一份 CR 报告。
  3. 接收到检测任务完成后,目前会通过 MR 评论的方式添加到对应的 MR 中,引导用户查看。
  4. 对于开发者视角,打开审查报告,可以根据审查出的问题,进行修改。
  5. 对于 CR 人员视角,打开审查报告,可以根据审查出的问题,一键添加到评论,引导开发者修改。

五、MR 流水线接入与 AI CR 报告

自动触发

以下图 MR 为例,在 MR 流水线中,添加了仓库流水线 AI 检测的检测任务,当创建 MR 时,会自动触发执行一次,在 MR 未合入的过程中,每次代码变更也会自动触发。

添加审查报告评论

检测完成后会自动添加一条 MR 评论,通知研发已完成检测,可以点击查看 CR 报告。评论概览中有审查摘要,显示聚类问题的数量;还有审查总结,即对所有反馈的总结,概览问题。

AI CR 报告

以下为实际 MR 生成的 CR 报告,可以看到,报告主要包括:MR 的基础信息、问题的分类 Tab、问题的具体描述、问题的操作。

具体问题列表

首先报告列表会对问题进行聚类,分为严重问题、警告、建议三类,切换对应 Tab 可以看到问题列表。具体的问题信息,主要有类型、问题代码、修复后代码、描述、文件路径、行号、操作等列。

添加到评论

点击操作列的添加到评论,将会一键将相关问题的信息,生成格式化描述,添加到 MR 的评论中,提醒开发者关注问题、解决问题。

AI 智能解决

点击操作列的 Cursor 解决,将会一键将相关问题的信息,生成解决问题 Prompt,一键打开本地 Cursor ,创建 Agent 对话去解决问题。打开链接后,Cursor 会先接收 Prompt ,你可以简单浏览下,点击 Create Chat ,即可一键创建 Chat,回车执行修复。

Cursor Prompt 预览

Cursor Prompt 预览 确认填入 Chat 执行

复制 Prompt

点击复制 Prompt,支持一键复制修复问题 Prompt,可以放到期望的 IDE 里使用。如下图,就是复制的 Prompt 示例。

六、推荐研发流程实践

尽早创建 MR

当需求分支第一次提交后,就可以创建到 release 或 test 目标分支的 MR 了,后续每次提交代码都将会自动触发检测,产出 AI CR 报告。

研发自主查看与解决

研发收到 AI CR 报告的通知后,可以及时打开 CR 报告查看,确认反馈的疑问点是否需要调整,如果需要调整可以通过 Cursor 一键解决,将问题解决前置到提测以前,这样所有的改动可以尽可能的被测试同学验证到。

人工 CR

发布前最后的人工 CR 可以通过前置的 AI CR 发现与问题前置解决,大幅提升靠最后人工 CR 的反馈、修改等环节效率。特别是当业务需求代码量较大时,人工 CR 浏览的效率和质量也是无法保证的。

七、内置提示词工程

AI CR 其实就像给 AI 一个详细的检查清单。这个清单分两部分:一部分是基本规则,比如"你要扮演什么样的角色"、"按什么流程检查";另一部分是具体的技术要点,比如"注意空指针问题"、"检查React用法是否正确"等。有了这个清单,AI 就能像有经验的程序员一样,系统地检查代码,发现各种潜在问题,让代码质量得到保障。

具体这个规则体系的结构如下:

.cursor/rules
├── 00-role-and-constraints.mdc          # 角色与约束 - 定义AI代码审查助手的角色和基本约束条件
├── 01-workflow-steps.mdc                # 工作流程步骤 - 描述代码审查的工作流程和步骤
├── 02-detection-standards.mdc           # 检测标准 - 定义代码问题的检测标准和准则
├── 03-output-format.mdc                 # 输出格式 - 规定代码审查结果的输出格式和规范
├── 04-best-practices.mdc                # 最佳实践 - 提供代码审查中的最佳实践建议
├── common                               # 通用规则目录 - 包含各种常见的代码问题检测规则
│   ├── 01-null-pointer-defense.md       # 空指针防御 - 防止空指针异常的最佳实践
│   ├── 02-react-hooks-usage.md          # React Hooks 使用 - React Hooks 的正确使用方式
│   ├── 03-data-merge-state.md           # 数据合并状态 - 处理数据合并时的状态管理问题
│   ├── 04-async-programming.md          # 异步编程 - 异步编程模式和常见陷阱
│   ├── 05-memory-leak-performance.md    # 内存泄漏性能 - 检测和防止内存泄漏问题
│   ├── 06-security-coding.md            # 安全编码 - 安全编程实践和漏洞防范
│   ├── 07-compatibility.md              # 兼容性 - 确保代码兼容性的检查点
│   ├── 08-git-conflict-detection.md     # Git 冲突检测 - 检测并解决 Git 合并冲突
│   ├── 09-code-quality.md               # 代码质量 - 代码质量评估和改进规则
│   ├── 10-resource-handling.md          # 资源处理 - 正确处理系统资源的规则
│   ├── 11-url-params.md                 # URL 参数 - URL 参数处理的安全和有效性检查
│   ├── 12-business-logic-consistency.md # 业务逻辑一致性 - 确保业务逻辑一致性的规则
│   └── 13-monorepo-dependency.md        # 大仓依赖 - Monorepo 架构中的依赖管理规则
└── README.md                            # 说明文档 - 规则系统的介绍和使用说明

八、模型选择

在 AI CR 环节,模型的选择需要考虑模型对于代码理解的复杂性、上下文长度需求以及推理准确性、模型的速度、模型的使用成本等考量。在 Cursor 的模型列表中,我们优先使用 Compose 1.5,当额度不足时,我们也会降级使用 Auto 模型。

以下为 Cursor auto 模型与 Composer 1.5 模型对比,可以看出,两个模型都找出了 4 个问题,但在时间上,Composer 1.5 进行需 44 秒即可完成,而 auto 模型需要 91 秒。

九、总结与规划

通过多个迭代实践与数据统计,Cursor Agent CR 挖掘的有效问题数可以达到 50% 左右,研发使用的意愿也相比原来有不少提升。当前我们也在将 AI CR 报告融合到 Cursor IDE 插件中,进一步融合到研发流程里。

随着 AI 生成代码在开发流程中越来越普遍,AI CR 的重要性将进一步凸显。相比传统的人工审查,AI 审查能够自动发现 AI 生成代码中可能存在的逻辑错误、安全性问题和规范性缺陷,提前在开发过程中消除隐患。同时,AI CR 还能确保 AI 生成的代码符合团队的技术规范和最佳实践,保持代码风格的一致性。为 AI 时代的开发流程提供了可靠的质保机制,让开发流程更加顺畅,是现代软件开发的重要保障。

往期回顾

1.从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

2.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

3.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

4.得物社区搜推公式融合调参框架-加乘树3.0实战

5.深入剖析Spark UI界面:参数与界面详解|得物技术

文 /大圣

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

从 0 到 1:用 Node 打通 OpenClaw WebSocket 通信全流程

作者 墨渊君
2026年4月7日 10:29

引言

书接上回, 我们在 OpenClaw 上手实践: 使用 Docker 从构建到可用全流程指南 介绍了, 如果通过 Docker 来快速部署 OpenClaw

其实呢, 这边想要借助 OpenClaw昆仑虚 搭一个个人的 AI 应用, 这里希望整体架构如下:

image

这边 Node 服务端就是做了中间层的转发, 但是这么做有什么好处呢?

  • 权限: 可以进行很好的权限管理, OpenClaw 仅运行 Node 服务进行访问, 不对外开放
  • 多用户: 可以将 sessionagentmessage 等内容按用户进行隔离, 甚至可以一个用户分配一个独立隔离的 OpenClaw(容器)
  • 定制化: 要想做应用, 必然会有很对定制信息, 比如设置 Agent 的头像等。这边我们只需要 OpenClaw 调度大模型的能力, 其他的就希望完全定制。

所以接下来最重要的就是, 在 Node 服务端要如何和 OpenClaw 进行协作(通信), 这也正是接下来我们要聊的....

一、OpenClaw 架构

如图, 是 OpenClaw 的整体架构

image

1.1 智能体运行时环境

这里是整个核心, 是真正干活的核心引擎, 也是我想要的核心能力, 这边主要就是:

  1. 负责拼装 promptcontext
  2. 调度各种大模型
  3. 协调各种 AgentSkillTools 的执行
  4. 保存各种配置、回话记录

当然这边其实没这么简单, 只是想说明这边主要就是核心干活的地方

1.2 网关层

外界各个应用、服务、IM 如何通知引擎部分让 Agent 开始干活? 而引擎部分又如何告知外界 Agent 处理的结果? 而它们之间又是怎么鉴权的? 怎么通信的? 这都是网关层进行控制的。

image

OpenClaw 通过 WebSocket 并定义了一套协议, 来链接 "外界" 和 "引擎"

如下所示, 是外界通过 ws 连接到 OpenClaw 网关, 并约定好的参数(协议)来调用 "引擎" 干活:

import WebSocket from 'ws';

const ws = new WebSocket('ws://127.0.0.1:18789');

ws.send(JSON.stringify({
  type: 'req',  // 请求类型,固定为 req
  id: '任意唯一ID', // 请求 ID
  method: 'chat.send', // 请求内容
  params: {}, // 请求参数,根据不同 method 定义不同的参数结构
}));

同时, 外界也是通过 ws 来监听网关发来的消息, 来获取 "引擎" 广播的消息:

// 监听 OpenClaw 广播的消息
ws.on('message', (data) => {
  
});

// 可能数据如下
{
  "type": "event",
  "event": "chat",
  "payload": {
    "runId": "同一个 runId",
    "sessionKey": "main",
    "seq": 1,
    "state": "delta",
    "message": {
      "role": "assistant",
      "content": [
        { "type": "text", "text": "正在生成中的文本" }
      ],
      "timestamp": 1710000000000
    }
  }
}

我们可能习惯性通过 REST API 来调用第三方服务提供的接口来获取数据、修改数据, 但这边则全部走 WebSocket 并通过约定好的协议来完成所有事情

// 拉历史
ws.send(JSON.stringify({
  type: "req",
  id: "history-1", // 自定义请求 AI
  method: "chat.history", // 具体请求方法
  params: {} // 参数
}));

// 拉 agent 列表
ws.send(JSON.stringify({
  type: "req",
  id: "agents-1", // 自定义请求 AI
  method: "agents.list", // 具体请求方法
  params: {} // 参数
}));

1.3 其他层

  1. 工具与能力层: 本质上大模型是不具备各种调用工具的能力的, 所有工具的调用都是在本地完成, 并将调用结果告诉大模型。大模型再进行决策, 而这边工具与能力层就是提供各种工具能力, 来供 OpenClaw 来调度, 在需要时 OpenClaw 会调用相关工具来完成各类工作, 并将工具调用结果返回给大模型

  2. 接口控制层、消息通讯渠道: 这边其实就是针对各种场景、IM, 来做一些兼容处理, 使得能够顺利接入网关。

二、握手流程

参考文档: Gateway 网关协议 - 握手

如下图所示:

  1. 当客户端与 OpenClaw 网关连接建立后
  2. 网关会立刻发送 connect.challenge 事件(消息)
  3. 客户端需要紧接着发送 connect 请求(含鉴权信息)
  4. 网关层鉴权成功则返回 hello-ok 响应, 否则则关闭连接

image

如下代码所示, 是一个最简化的 DEMO:

import WebSocket from 'ws';
// 1. 建立连接
const ws = new WebSocket('ws://127.0.0.1:18789'); 

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 2. 网关发送 connect.challenge 事件(消息)
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge');

    // 3. 客户端紧接着发送 connect 请求(含鉴权信息)
    ws.send(JSON.stringify({
      id: '1', // 唯一 ID 客户端自己随便写即可
      type: 'req', 
      method: 'connect',
      params: {
        minProtocol: 3,
        maxProtocol: 3,
        client: {
          id: 'cli', 
          version: '1.0.0',
          platform: 'node',
          mode: 'node',
        },
        role: 'operator',
        scopes: [
          'operator.read',
          'operator.write',
          'operator.admin',
          'operator.approvals',
          'operator.pairing',
        ],
        auth: { token: '9e1a21f5555asdsads555666666666df3f81' }, // 换成你自己的 OpenClaw 登陆 Token
      },
    }));
  }

  // 4. 网关层鉴权成功则返回 hello-ok 响应
  if msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }
});

// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

使用 Node 运行结果如下:

image

三、简单通信

上面我们简单演示了和 OpenClaw 网关建立握手连接, 但是实际上还缺了设备鉴权、授权这部分内容, 如果想要调用一些操作就需要把这部分补全...

3.1 设备身份鉴权

这边其实就是:

  • 根据 OpenClaw 自己的一套加密方式, 在客户端生成唯一设备 ID、公钥、私钥
  • 在握手阶段认证阶段, 需要按 OpenClaw 定义的规则, 生成相关的签名、设备信息, 一同传给网关层
  • 并且在首次设备连接时, 需要在 OpenClaw 进行设备的授权
  • 需要注意的是: 我们生成的设备 ID、公钥、私钥, 应该是固定不变的, 不应该每次都动态生成(实际场景中, 我们需要进行缓存, 或者加到服务配置中)

下面是一份完整的设备信息、签名生成代码:

import crypto from 'node:crypto';
import fs from 'fs';

const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

// base64url 编码
const base64UrlEncode = (buf) => buf.toString('base64').replaceAll('+', '-')
  .replaceAll('/', '_')
  .replace(/=+$/g, '');

// 从 PEM 格式的公钥中提取原始公钥数据,并进行 base64url 编码
const derivePublicKeyRaw = (publicKeyPem) => {
  const key = crypto.createPublicKey(publicKeyPem);
  const spki = key.export({ type: 'spki', format: 'der' });

  if (
    spki.length === ED25519_SPKI_PREFIX.length + 32 &&
    spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
  ) {
    return base64UrlEncode(spki.subarray(ED25519_SPKI_PREFIX.length));
  }

  return base64UrlEncode(spki);
};

// 从原始公钥数据派生设备 ID,通常是公钥的 SHA-256 哈希值
const deriveDeviceIdFromPublicKey = (publicKeyRawBase64Url) => crypto
  .createHash('sha256')
  .update(Buffer.from(publicKeyRawBase64Url, 'base64url'))
  .digest('hex');

// 创建网关设备身份,包括生成密钥对和设备 ID
const createGatewayDeviceIdentity = () => {
  // 如果已经存在设备身份文件,则直接读取并返回
  if (fs.existsSync('./device_identity.json')) {
    const content = fs.readFileSync('./device_identity.json', 'utf-8');
    return JSON.parse(content);
  }

  const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');

  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
  const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
  const publicKeyRaw = derivePublicKeyRaw(publicKeyPem);
  const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);

  const identity = {
    deviceId,
    privateKeyPem,
    publicKeyPem,
    publicKeyRaw,
  };

  // 将生成的设备身份信息保存到文件中,供后续使用
  fs.writeFileSync('./device_identity.json', JSON.stringify(identity, null, 2), 'utf-8');

  return identity;
};

// 构建设备认证信息,包括生成签名等
export const buildDeviceAuthPayloadV3 = (params) => [
  'v3',
  params.deviceId,
  params.clientId,
  params.clientMode,
  params.role,
  params.scopes.join(','),
  String(params.signedAtMs),
  params.token ?? '',
  params.nonce,
  params.platform ?? '',
  params.deviceFamily ?? '',
].join('|');

// 使用设备的私钥对认证负载进行签名,生成 base64url 编码的签名字符串
export const signDevicePayload = (privateKeyPem, payload) => crypto.sign(null, Buffer.from(payload, 'utf8'), privateKeyPem).toString('base64url');

// 构建网关设备认证信息,供连接网关时使用
export const buildGatewayDeviceAuth = (params) => {
  const signedAt = Date.now();
  const identity = createGatewayDeviceIdentity();

  const payload = buildDeviceAuthPayloadV3({
    deviceId: identity.deviceId,
    clientId: params.clientId,
    clientMode: params.clientMode,
    role: params.role,
    scopes: params.scopes,
    signedAtMs: signedAt,
    token: params.token,
    nonce: params.nonce,
    platform: params.platform,
    deviceFamily: params.deviceFamily,
  });

  const signature = signDevicePayload(identity.privateKeyPem, payload);

  return {
    signedAt,
    signature,
    nonce: params.nonce,
    id: identity.deviceId,
    publicKey: identity.publicKeyRaw,
  };
};

下面是完整连接 OpenClaw 网关代码, 这边调用 buildGatewayDeviceAuth 来生成设备签名等信息:

import WebSocket from 'ws';
import { buildGatewayDeviceAuth } from './device.mjs';

const REQUESTED_SCOPES = ['operator.admin'];
const CLIENT = {
  id: 'cli',
  version: '1.0.0',
  platform: 'node',
  mode: 'node',
  deviceFamily: 'desktop',
};

const GATEWAY_TOKEN = 'your-real-token';
const ws = new WebSocket('ws://127.0.0.1:18789');

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // 1️⃣ 先接 challenge
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
    console.log('🔐 receive challenge', msg.payload);
    const device = buildGatewayDeviceAuth({
      role: 'operator',
      nonce: msg.payload?.nonce ?? '',
      token: GATEWAY_TOKEN,
      clientId: CLIENT.id,
      clientMode: CLIENT.mode,
      scopes: REQUESTED_SCOPES,
      platform: CLIENT.platform,
      deviceFamily: CLIENT.deviceFamily,
    });

    ws.send(JSON.stringify({
      type: 'req',
      id: '1',
      method: 'connect',
      params: {
        device,
        minProtocol: 3,
        maxProtocol: 3,
        client: CLIENT,
        role: 'operator',
        scopes: REQUESTED_SCOPES,
        auth: { token: GATEWAY_TOKEN },
      },
    }));
  }

  // 2️⃣ connect 成功
  if (msg.payload?.type === 'hello-ok') {
    console.log('🎉 connected success');
  }

  console.log('👀 receive message', msg);
});


// 其他事件
ws.on('open', () => console.log('✅ connected'));
ws.on('close', () => console.log('✅ connected'));

执行上面连接 OpenClaw 脚本, 连接能够成功, 同时还会提示需要配对:

image

进入 OpenClaw 容器内部, 进行设备授权:

docker exec -it openclaw bash # 进入 openclaw 容器
openclaw devices list # 查看当前设备连接情况
openclaw devices approve b5950461-e541-4114-9165-413fb3e7afe2 # 授权设备 b5950461-e541-4114-9165-413fb3e7afe2

image

3.1 发起对话

如下代码所示:

  • 在连接 OpenClaw 网关成功之后, 1秒 后我们立马发起一轮对话
  • 发送对话本质上其实就是调用 webSocket.send 方法, 并定义合适的 methodparams 等参数
  • 最后我们再通过监听 message 类型, 来获取大模型输出内容
// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    ws.send(JSON.stringify({
      type: 'req',
      id: Math.random().toString(16),
      method: 'chat.send',
      params: {
        sessionKey: 'agent:main:main',
        message: '你好,世界!',
        idempotencyKey: Math.random().toString(16), // 确保消息幂等, 避免重复发送
      },
    }));
  }, 1000);
}

// 接收消息
if (msg.type === 'event' && msg.event === 'agent') {
  console.log('💬 receive message', msg.payload);
}

最终执行代码结果如下:

image

3.2 查询可用模型列表

开始前我们写一个通用的工具函数 sendRpc, 在 OpenClaw 都是同 webSocket 来发起各种请求, 那么要如何去监听到每次请求的响应呢? 如下代码所示, 其实我们在使用 send 来模拟发起一个请求时会给一个唯一的请求 ID, OpenClaw 处理完请求后, 将响应接口加请求 ID 一起推送给我们, 通过该唯一请求 ID 我们就可以精准获取到我们需要的响应结果。

const sendRpc  = (ws, method, params = {}) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
};

上面方法调用也很简单,

sendRpc(ws, 'models.list', {});

如果需要我们也可以将工具函数改为 Promise 形式

const sendRpc  = (ws, method, params = {}) => new Promise((resolve) => {
  // 每次发送请求都生成一个唯一的 ID,方便后续匹配响应
  const id = crypto.randomUUID();
  console.log('📤 send request', { id, method, params });

  ws.send(
    JSON.stringify({
      type: 'req',
      id,
      method,
      params,
    }),
  );

  const handleResponse = (data) => {
    const msg = JSON.parse(data.toString());

    // 匹配指定请求 ID 的响应
    if (msg.type === 'res' && msg.id === id) {
      console.log('📩 receive response', JSON.stringify(msg, null, 4));
      ws.off('message', handleResponse); // 收到对应 ID 的响应后取消监听
      resolve(msg); // 将响应结果通过 Promise 返回
    }
  };

  ws.on('message', handleResponse); // 监听响应消息, 收到对应 ID 的响应后会取消监听
});

这样就可以使用 await 来等待每次请求响应结果:

await sendRpc(ws, 'models.list', {});

最后在上文的 Demo 基础上, 在连接 OpenClaw 网关后 1秒 尝试调用 sendRpc 来查询下当前可用模型列表:

// connect 成功
if (msg.payload?.type === 'hello-ok') {
  console.log('🎉 connected success');

  // 发 chat
  setTimeout(() => {
    sendRpc(ws, 'models.list', {});
  }, 1000);
}

最后执行结果:

node demo/index.mjs
✅ connected
🔐 receive challenge { nonce: '7f083827-7e61-4b97-84d5-7c075fd191b2', ts: 1775445486797 }
🎉 connected success
📩 receive response {
    "type": "res",
    "id": "b829d02e-fcfb-45ee-b70c-25e2808bed29",
    "ok": true,
    "payload": {
        "models": [
            {
                "id": "gpt-5.1",
                "name": "GPT-5.1",
                "provider": "openai-codex",
                "contextWindow": 272000,
                "reasoning": true,
                "input": [
                    "text",
                    "image"
                ]
            }
        ]
    }
}

四、OpenClaw 所有协议

所有 WebSocket 消息定义在 src/gateway/protocol/schema/frames.ts 中, 总的来说有三个大类:

类型 type 用途
Request "req" 客户端发起请求(含 id, method, params)
Response "res" 服务端对请求的响应(含 id, ok, payload/error)
Event "event" 服务端主动推送事件(含 event, payload, seq)

4.1 常见 RPC 方法

OpenClaw 通过 WebSocketextensions/whatsapp/src/shared.ts 实现了 100 多个可用的 RPC 方法:

# 方法名 分类 说明
1 health 系统 获取网关健康状态
2 doctor.memory.status 系统 内存诊断状态
3 logs.tail 系统 获取日志尾部
4 channels.status 频道 获取所有频道状态
5 channels.logout 频道 登出频道
6 status 系统 获取完整网关状态
7 usage.status 用量 获取使用状态
8 usage.cost 用量 获取使用费用
9 tts.status TTS TTS 状态
10 tts.providers TTS 列出 TTS 提供商
11 tts.enable TTS 启用 TTS
12 tts.disable TTS 禁用 TTS
13 tts.convert TTS 文本转语音
14 tts.setProvider TTS 设置 TTS 提供商
15 config.get 配置 获取配置
16 config.set 配置 设置配置
17 config.apply 配置 应用配置
18 config.patch 配置 补丁更新配置
19 config.schema 配置 获取配置 Schema
20 config.schema.lookup 配置 查找配置 Schema
21 exec.approvals.get 执行批准 获取执行批准列表
22 exec.approvals.set 执行批准 设置执行批准列表
23 exec.approvals.node.get 执行批准 获取节点执行批准
24 exec.approvals.node.set 执行批准 设置节点执行批准
25 exec.approval.request 执行批准 请求执行批准
26 exec.approval.waitDecision 执行批准 等待批准决定
27 exec.approval.resolve 执行批准 解决执行批准
28 plugin.approval.request 插件批准 请求插件批准
29 plugin.approval.waitDecision 插件批准 等待插件批准决定
30 plugin.approval.resolve 插件批准 解决插件批准
31 wizard.start 向导 启动配置向导
32 wizard.next 向导 向导下一步
33 wizard.cancel 向导 取消向导
34 wizard.status 向导 获取向导状态
35 talk.config Talk 获取 Talk 配置
36 talk.speak Talk Talk 说话
37 talk.mode Talk 设置 Talk 模式
38 models.list 模型 列出可用模型
39 tools.catalog 工具 获取工具目录
40 tools.effective 工具 获取有效工具
41 agents.list 代理 列出代理
42 agents.create 代理 创建代理
43 agents.update 代理 更新代理
44 agents.delete 代理 删除代理
45 agents.files.list 代理 列出代理文件
46 agents.files.get 代理 获取代理文件
47 agents.files.set 代理 设置代理文件
48 skills.status 技能 获取技能状态
49 skills.bins 技能 获取技能二进制
50 skills.install 技能 安装技能
51 skills.update 技能 更新技能
52 update.run 更新 运行网关更新
53 voicewake.get 语音唤醒 获取唤醒配置
54 voicewake.set 语音唤醒 设置唤醒配置
55 secrets.reload 密钥 重新加载密钥
56 secrets.resolve 密钥 解析密钥引用
57 sessions.list 会话 列出会话
58 sessions.subscribe 会话 订阅会话变化
59 sessions.unsubscribe 会话 取消订阅会话变化
60 sessions.messages.subscribe 会话 订阅会话消息
61 sessions.messages.unsubscribe 会话 取消订阅会话消息
62 sessions.preview 会话 预览会话
63 sessions.create 会话 创建会话
64 sessions.send 会话 发送消息到会话
65 sessions.abort 会话 中止会话
66 sessions.patch 会话 修补会话
67 sessions.reset 会话 重置会话
68 sessions.delete 会话 删除会话
69 sessions.compact 会话 压缩会话
70 last-heartbeat 心跳 获取最后心跳
71 set-heartbeats 心跳 设置心跳
72 wake 系统 唤醒网关
73 node.pair.request 节点配对 请求节点配对
74 node.pair.list 节点配对 列出配对请求
75 node.pair.approve 节点配对 批准配对
76 node.pair.reject 节点配对 拒绝配对
77 node.pair.verify 节点配对 验证配对
78 device.pair.list 设备配对 列出设备配对
79 device.pair.approve 设备配对 批准设备配对
80 device.pair.reject 设备配对 拒绝设备配对
81 device.pair.remove 设备配对 移除设备配对
82 device.token.rotate 设备令牌 轮换设备令牌
83 device.token.revoke 设备令牌 撤销设备令牌
84 node.rename 节点 重命名节点
85 node.list 节点 列出节点
86 node.describe 节点 描述节点信息
87 node.pending.drain 节点队列 排空待处理队列
88 node.pending.enqueue 节点队列 入队待处理工作
89 node.invoke 节点 调用节点命令
90 node.pending.pull 节点队列 拉取待处理工作
91 node.pending.ack 节点队列 确认待处理工作
92 node.invoke.result 节点 节点调用结果
93 node.event 节点 节点事件
94 node.canvas.capability.refresh 节点 刷新画布能力
95 cron.list Cron 列出定时任务
96 cron.status Cron 获取定时任务状态
97 cron.add Cron 添加定时任务
98 cron.update Cron 更新定时任务
99 cron.remove Cron 移除定时任务
100 cron.run Cron 立即运行定时任务
101 cron.runs Cron 获取运行历史
102 gateway.identity.get 网关 获取网关身份
103 system-presence 系统 获取系统存在
104 system-event 系统 系统事件
105 send 消息 发送消息到频道
106 agent 代理 调用代理
107 agent.identity.get 代理 获取代理身份
108 agent.wait 代理 等待代理完成
109 chat.history WebChat 获取聊天历史
110 chat.abort WebChat 中止聊天
111 chat.send WebChat 发送聊天消息
112 web.login.start WhatsApp 启动 Web 登录流程
113 web.login.wait WhatsApp 等待 Web 登录完成

4.2 常见推送事件类型

OpenClaw 通过 WebSocketsrc/gateway/server-broadcast.ts 定义了 20 多个可用的事件推送类型:

事件名 说明 权限范围
connect.challenge 连接握手挑战 -
tick 心跳 (含时间戳) -
heartbeat 保活 -
shutdown 网关关闭 (含 reason) -
health 健康状态更新 -
presence 系统存在更新 -
session.message 会话消息推送 operator.read
session.tool 工具调用事件 operator.read
sessions.changed 会话列表变化 operator.read
chat 聊天流式响应 (含 state: delta/final/aborted/error) -
chat.side_result 聊天副作用结果 -
agent 代理流式输出 -
node.pair.requested 节点配对请求 operator.pairing
node.pair.resolved 节点配对已解决 operator.pairing
node.invoke.request 节点调用请求 -
device.pair.requested 设备配对请求 operator.pairing
device.pair.resolved 设备配对已解决 operator.pairing
exec.approval.requested 执行批准请求 operator.approvals
exec.approval.resolved 执行批准已解决 operator.approvals
plugin.approval.requested 插件批准请求 operator.approvals
plugin.approval.resolved 插件批准已解决 operator.approvals
voicewake.changed 语音唤醒变化 -
talk.mode Talk 模式变化 -
cron Cron 任务事件 -
update.available 更新可用通知 -

五、参考

一个油猴脚本,解决掘金编辑器「转存失败」的烦恼

作者 Novlan1
2026年4月7日 10:20

痛点

经常在掘金发文章的同学应该都遇到过这个问题:从其他平台复制 Markdown 内容粘贴到掘金编辑器时,图片会变成这样:

<img src="转存失败,建议直接上传图片文件
https://cdn.example.com/image.png" alt="转存失败,建议直接上传图片文件">

图片无法正常显示,每张图都要手动删除「转存失败,建议直接上传图片文件」这段文字,文章图片一多,简直崩溃。

解决方案

写了一个 油猴脚本,在掘金编辑器页面添加一个悬浮按钮,一键清理所有「转存失败」文本。

效果

  • 页面右下角出现 🧹 清理转存失败 按钮
  • 点击后自动扫描 Markdown 源码,清理所有「转存失败」文本
  • 清理完成后 toast 提示处理了多少处

支持的清理模式

模式 示例
<img> 标签 src 属性 src="转存失败...https://xxx"src="https://xxx"
<img> 标签 alt 属性 alt="转存失败..."alt=""
Markdown 图片语法 ![转存失败...](url)![](url)
单独文本行 整行 转存失败,建议直接上传图片文件 直接移除

使用方法

1. 安装 Tampermonkey

在浏览器扩展商店搜索 Tampermonkey 并安装,支持 Chrome / Firefox / Edge。

2. 添加脚本

点击 Tampermonkey 图标 → 添加新脚本 → 粘贴以下代码 → Ctrl + S 保存:

// ==UserScript==
// @name         掘金编辑器 - 清理转存失败图片文本
// @namespace    https://juejin.cn/
// @version      1.0.0
// @description  一键清理掘金编辑器中复制 Markdown 时产生的"转存失败,建议直接上传图片文件"文本
// @author       novlan1
// @match        https://juejin.cn/editor/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const FAIL_TEXT = '转存失败,建议直接上传图片文件';

  function createCleanButton() {
    const btn = document.createElement('button');
    btn.id = 'juejin-clean-img-btn';
    btn.textContent = '🧹 清理转存失败';
    Object.assign(btn.style, {
      position: 'fixed',
      right: '20px',
      bottom: '80px',
      zIndex: '99999',
      padding: '10px 16px',
      fontSize: '14px',
      fontWeight: 'bold',
      color: '#fff',
      backgroundColor: '#1e80ff',
      border: 'none',
      borderRadius: '8px',
      cursor: 'pointer',
      boxShadow: '0 2px 12px rgba(30, 128, 255, 0.4)',
      transition: 'all 0.2s ease',
    });
    btn.addEventListener('mouseenter', () => {
      btn.style.backgroundColor = '#1171e6';
      btn.style.transform = 'scale(1.05)';
    });
    btn.addEventListener('mouseleave', () => {
      btn.style.backgroundColor = '#1e80ff';
      btn.style.transform = 'scale(1)';
    });
    btn.addEventListener('click', handleClean);
    document.body.appendChild(btn);
  }

  function showToast(message, type = 'success') {
    const toast = document.createElement('div');
    toast.textContent = message;
    Object.assign(toast.style, {
      position: 'fixed',
      right: '20px',
      bottom: '130px',
      zIndex: '999999',
      padding: '8px 16px',
      fontSize: '13px',
      color: '#fff',
      backgroundColor: type === 'success' ? '#52c41a' : '#faad14',
      borderRadius: '6px',
      boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
      transition: 'opacity 0.3s ease',
      opacity: '1',
    });
    document.body.appendChild(toast);
    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    }, 2500);
  }

  function handleClean() {
    // 方式1:CodeMirror API
    const cmElement = document.querySelector('.CodeMirror');
    if (cmElement && cmElement.CodeMirror) {
      const cm = cmElement.CodeMirror;
      const content = cm.getValue();
      const cleaned = cleanContent(content);
      if (content !== cleaned) {
        cm.setValue(cleaned);
        showToast(`✅ 已清理 ${countDiff(content, cleaned)} 处`);
      } else {
        showToast('👍 没有需要清理的内容', 'warn');
      }
      return;
    }

    // 方式2:textarea
    const textarea = document.querySelector('.bytemd-editor textarea');
    if (textarea) {
      const setter = Object.getOwnPropertyDescriptor(
        HTMLTextAreaElement.prototype, 'value'
      ).set;
      const content = textarea.value;
      const cleaned = cleanContent(content);
      if (content !== cleaned) {
        setter.call(textarea, cleaned);
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
        textarea.dispatchEvent(new Event('change', { bubbles: true }));
        showToast(`✅ 已清理 ${countDiff(content, cleaned)} 处`);
      } else {
        showToast('👍 没有需要清理的内容', 'warn');
      }
      return;
    }

    showToast('⚠️ 未找到编辑器', 'warn');
  }

  function cleanContent(content) {
    return content
      // <img> src 属性中的前缀
      .replace(/(<img\s[^>]*src\s*=\s*["'])转存失败,建议直接上传图片文件\s*/gi, '$1')
      // <img> alt 属性
      .replace(/(<img\s[^>]*alt\s*=\s*["'])转存失败,建议直接上传图片文件(["'])/gi, '$1$2')
      // Markdown 图片语法
      .replace(/!\[转存失败,建议直接上传图片文件\s*\]/g, '![]')
      // 单独一行
      .replace(/^转存失败,建议直接上传图片文件\s*$/gm, '')
      // src 中残留换行
      .replace(/(src\s*=\s*["'])\n(https?:\/\/)/gi, '$1$2');
  }

  function countDiff(a, b) {
    return (a.match(/转存失败,建议直接上传图片文件/g) || []).length -
           (b.match(/转存失败,建议直接上传图片文件/g) || []).length;
  }

  function init() {
    if (!location.href.includes('juejin.cn/editor')) return;
    const timer = setInterval(() => {
      if (document.querySelector('.CodeMirror') || document.querySelector('.bytemd')) {
        clearInterval(timer);
        createCleanButton();
        console.log('[掘金清理脚本] ✅ 已加载');
      }
    }, 1000);
    setTimeout(() => clearInterval(timer), 30000);
  }

  init();
})();

3. 使用

打开掘金编辑器 → 粘贴 Markdown 内容 → 点击右下角 🧹 清理转存失败 按钮 → 完成 ✅

原理简述

脚本通过以下优先级获取编辑器内容:

CodeMirror API → textarea → 兜底 DOM 操作

然后用正则匹配清理 <img> 标签的 srcalt 属性以及 Markdown ![]() 语法中的「转存失败」文本,最后将清理后的内容写回编辑器。

最后

脚本很轻量,只在 juejin.cn/editor/* 页面生效,不影响其他网站。

如果对你有帮助,欢迎点个赞 👍

从"会用 AI"到"架构 AI":高级前端的认知升级

作者 DanCheOo
2026年4月7日 10:06

从"会用 AI"到"架构 AI":高级前端的认知升级

本文是【高级前端的 AI 架构升级之路】系列第 02 篇。 上一篇:高级前端的 AI 焦虑:你的经验到底还值不值钱 | 下一篇:AI 网关层设计:多模型路由、降级、限流、成本控制


这篇文章要解决什么

上一篇我们分析了 5 年前端的经验在 AI 时代的价值。结论是:你的架构能力和工程化思维比以前更值钱了。

但有一个前提——你需要完成一次认知升级

大部分前端接触 AI 后,做的第一件事是:调一下 API、做个聊天界面、用 Cursor 写代码提效。这没什么问题,但这只是"用 AI"的层面。

高级前端需要到达另一个层面——"架构 AI":AI 功能在我的系统里应该处于什么位置?怎么设计才能稳定、可控、可演进?

这两个层面的差距,不在技术细节上,而在思维模型上。

今天这篇就来聊:从前端架构师的视角看 AI 系统,需要建立哪些新的认知。


AI 不是一个"功能模块",是一种新的系统交互范式

很多团队第一次做 AI 功能时,会把它当成一个普通功能模块来处理:

产品页面 → 调一个 AI 接口 → 把结果展示出来

就像调一个翻译 API、一个地图 API 一样。写个 service 函数、做个错误处理、加个 loading 状态——前端的标准流程。

但真正做下去你会发现,AI 和传统 API 有根本性的不同:

传统后端 API AI API
确定性输出——相同输入 = 相同输出 概率性输出——相同输入可能得到不同结果
响应快——通常 < 200ms 响应慢——通常 2-15 秒
成本可忽略——服务器资源 按 token 计费——每次调用都有真实成本
失败即失败——报错 or 成功 部分正确——可能回答了但答非所问
固定格式——JSON Schema 约束 格式不稳定——AI 可能输出意料之外的格式
无状态——每次请求独立 上下文依赖——对话历史影响输出质量

这些差异不是小细节——它们改变了你设计系统时的基本假设。

举个具体例子

假设你在做一个"AI 自动生成商品描述"的功能。

如果按传统思路:前端发商品信息 → 后端调 AI → 返回描述 → 前端展示。

上线后你会遇到这些问题:

  1. AI 有时候输出质量很差——怎么办?不能直接展示给用户吧?需要一个质量检测层
  2. 同一个商品调两次,描述完全不一样——PM 说"每次点都不一样,用户会困惑"。需要缓存策略
  3. 高峰期每秒几百个请求,Token 费用飙升——需要成本控制限流
  4. AI 偶尔输出敏感内容——需要内容审核层
  5. 用户觉得等 5 秒太久——需要流式输出预生成

你看,一个"调 API 展示结果"的功能,在生产环境中展开后,变成了一个需要缓存、审核、限流、降级、监控的完整系统。

这就是 AI 和传统功能模块的根本区别——它引入了太多不确定性,需要从系统层面去应对。


四个需要重新理解的概念

从前端架构师转向 AI 架构,有四个概念需要你重新建立认知。

1. 非确定性系统

前端和后端的系统都是确定性的:点击按钮 → 发请求 → 得到固定结果。你的整个架构思维都建立在这个基础上——组件是确定性的(给定 props 渲染确定 UI)、状态管理是确定性的(dispatch action → 确定的新 state)。

AI 打破了这个基础。同一个输入,每次输出可能不同。

这意味着什么?

  • 不能用传统快照测试——每次输出不同,测试怎么写?需要用"评估"(Evaluation)代替"断言"(Assertion)。
  • UI 需要弹性设计——AI 输出长度不可预测,可能 10 个字也可能 2000 个字。你的界面布局必须能 handle 这种不确定性。
  • 缓存策略不同——传统 API 相同入参可以直接用缓存。AI 接口是否该缓存?缓存多久?取决于场景。

2. 延迟敏感

前端开发者对延迟是有感知的——你知道 API 响应超过 300ms 用户就开始不耐烦。但 AI API 的延迟不是 300ms,是 3-15 秒

这不是"优化一下就能解决"的问题,而是一个架构级的约束。你需要从一开始就围绕"高延迟"来设计:

  • 流式输出是标配,不是可选项
  • 乐观更新——能预测的部分先展示,AI 结果后补
  • 预生成——用户可能需要的内容,提前异步生成好
  • 渐进增强——先给快速结果(规则/缓存),后台跑 AI 慢慢替换

这些策略你在做前端性能优化时都用过(骨架屏、SSR、预加载),只是要用在新场景里。

3. 成本弹性

前端以前不太需要关心"调一次接口多少钱"。但 AI 时代,每一次 API 调用都在花真金白银

GPT-4o 一次复杂对话的成本约 0.010.05。看似不多,但如果你的产品每天有10万次AI交互,那就是0.01-0.05。看似不多,但如果你的产品每天有 10 万次 AI 交互,那就是 1,000-5,000/天,一年百万美金级别的 AI API 费用。

这意味着架构设计时必须考虑:

  • 模型分级——简单任务用便宜模型,复杂任务才上贵的
  • Token 预算——每个用户/每个功能/每天的 Token 上限
  • 缓存复用——相似请求是否可以复用之前的结果
  • 批量处理——非实时需求攒一攒批量调用更便宜

前端要做的事也不少:显示用量统计、实现付费引导、做功能门控(免费用户限制 AI 次数)。

4. 输出质量不可控

调传统 API,你可以信任后端返回的数据是对的(如果不对,那是 Bug)。但 AI 的输出,你不能完全信任

AI 可能:

  • 答非所问——你问 A 它聊 B
  • 编造事实——即"幻觉"(Hallucination)
  • 格式错误——你要 JSON 它给你 Markdown
  • 输出敏感内容——政治、色情、暴力等

这要求你在架构中加入一个**"AI 输出不一定对"的假设**,并设计相应的防护层:

  • 输出格式校验(JSON Schema 验证)
  • 内容审核(关键词过滤 + AI 审核)
  • 人工审批(高风险操作需要人确认)
  • 兜底方案(AI 失败时的降级策略)

从前端架构师视角看 AI 系统

既然你是前端架构师,我们用你熟悉的概念来类比,看看 AI 架构和你以前做的事有什么相似和不同。

像组件化,但更不确定

组件化的核心是封装和抽象:给定 props → 确定的 UI。你把复杂度封装在组件内部,外部通过接口交互。

AI 模块的封装思路类似:把 AI 调用封装成内部的黑盒,对外暴露确定的接口。但区别在于——盒子里面是不确定的

传统组件: Input(props) → 确定的 Output
AI 模块:  Input(prompt + context) → 不确定的 Output → 后处理 → 相对确定的 Output

你需要在 AI 模块外面包一层"稳定层"——格式校验、重试、降级、默认值——让外部调用者感知到的是一个相对稳定的接口。

像微前端,但是资源共享的

微前端的核心问题是隔离和集成——多个子应用如何独立开发、统一部署。

AI 架构面临类似的问题:一个产品里可能有 5 个不同的 AI 功能(聊天、搜索、推荐、内容生成、代码辅助),它们:

  • 共享 AI 网关(统一的模型调用入口)
  • 共享 Prompt 配置中心(集中管理所有 Prompt)
  • 共享成本预算(全公司的 Token 额度)
  • 但各自有独立的业务逻辑和质量要求

这很像微前端里"共享基础设施 + 独立业务域"的架构模式。

像状态管理,但复杂 10 倍

前端的状态管理已经够复杂了——异步请求、乐观更新、缓存失效、跨组件通信。

AI 场景下的状态更复杂:

  • 流式状态——回复还在生成中,状态在持续变化
  • 分支对话——用户在第 3 轮修改了问题,后续回复全部失效
  • 多 Agent 状态——3 个 AI 同时工作,各自有独立进度
  • 可中断——用户随时可以"停止生成"
  • 上下文窗口——对话太长时要压缩/裁剪历史

传统的 Redux/Pinia 方案 handle 不了这些。你需要设计 AI 专用的状态管理方案——这也是后面几篇会详细展开的内容。


技术选型心智模型:什么该用 AI、什么不该用

高级前端的一个核心能力是"知道什么不做"。在 AI 场景中,这个能力更加重要。

我用一个 2x2 矩阵来帮你判断:

                    AI 效果好
                        │
        ┌───────────────┼───────────────┐
        │               │               │
        │   ③ 谨慎用    │   ① 优先用    │
        │   效果好但贵   │   效果好且值   │
  成本高 ├───────────────┼───────────────┤ 成本低
        │               │               │
        │   ④ 不要用    │   ② 可以用    │
        │   又贵效果差   │   便宜但效果一般│
        │               │               │
        └───────────────┼───────────────┘
                        │
                    AI 效果差

① 优先用 AI(效果好 + 成本可控):

  • 内容生成(文案、摘要、翻译)
  • 代码辅助(Review、补全、解释)
  • 智能搜索(语义搜索 + 结果整理)

② 可以用 AI(成本低但效果一般,做为辅助):

  • 意图识别(用户想做什么?分类一下)
  • 简单问答(FAQ 类,结合知识库)

③ 谨慎用 AI(效果好但成本高,需要 ROI 分析):

  • 复杂推理(多步骤分析、方案生成)
  • 多模态处理(图片理解、文档解析)

④ 不要用 AI(效果差且成本高,用规则更靠谱):

  • 精确计算(数学运算、财务计算)
  • 确定性流程(审批流、状态机)
  • 实时性要求极高的场景(< 100ms 响应)

每一个 AI 功能上线前,都应该过一遍这个矩阵。


一个实际案例:我的架构决策过程

分享一个我在实际项目中的决策过程,帮你感受一下"架构 AI"和"用 AI"的区别。

需求:在内部管理系统中加入 AI 聊天助手,帮员工查询业务数据和操作流程。

"用 AI"的思路: 接一个 OpenAI API → 做个聊天界面 → 把系统文档丢给 AI 做 RAG → 上线。

"架构 AI"的思路

  1. 模型选型:日常问答用 DeepSeek(便宜快速),涉及复杂数据分析时自动升级到 GPT-4o。
  2. 知识来源设计:静态文档 → 向量化 RAG;实时业务数据 → Tool Calling 对接数据库 API。
  3. 安全边界:AI 只能查数据,不能改数据。涉及敏感数据(薪资、客户信息)需要权限校验。
  4. 成本控制:每人每天 50 次对话上限,超限提示"请联系管理员"。
  5. 降级策略:AI 服务不可用时,自动降级为关键词搜索 + FAQ 匹配。
  6. 可观测性:记录每次对话的 Token 消耗、响应时间、用户满意度,用于持续优化。
  7. 前端体验:流式输出、打字机效果、Thinking UI 展示 AI 正在查询哪些数据源、支持"停止生成"。

两种思路的产出物完全不同。第一种是一个 Demo 级的聊天工具,第二种是一个可以在生产环境长期运行的 AI 系统。


总结

  1. AI 不是一个普通功能模块,它引入了非确定性、高延迟、按次计费、输出不可信等新约束,需要从系统层面设计。
  2. 四个新认知:非确定性系统(测试变评估)、延迟敏感(流式为标配)、成本弹性(Token 预算)、输出质量不可控(防护层必备)。
  3. 前端架构经验可以迁移:组件化 → AI 模块封装、微前端 → AI 功能集成、状态管理 → AI 状态方案。
  4. 用 2x2 矩阵判断什么该用 AI、什么不该——效果 × 成本。
  5. "用 AI"和"架构 AI"的差距不在技术细节,而在是否考虑了降级、成本、安全、可观测性等生产级需求。

从下一篇开始,我们进入实战——第一个主题是每个 AI 系统都需要的核心基础设施:AI 网关层


下一篇预告03 | AI 网关层设计:多模型路由、降级、限流、成本控制


讨论话题:你在项目中做过 AI 功能吗?是"调 API 展示结果"的模式,还是有完整的架构设计?遇到过哪些"用着用着才发现"的坑?评论区分享一下。

🚀 Vue 一键转 React!企业后台 VuReact 混写迁移实战

作者 Ruihong
2026年4月7日 10:02

在前端工程化落地过程中,Vue 与 React 生态的混合开发、存量项目迁移是很多团队都会遇到的痛点——既要复用历史业务代码,又想接入 React 生态的新能力,常规的“重写”方案成本高、风险大。

本文以客户支持协同后台为真实案例,基于 Vue 3 + Vue Router + Ant Design (React) + Zustand (React) 技术栈,手把手拆解 VuReact 实现 Vue 到 React “可控混写迁移”的全流程,从环境配置到业务验收一步到位,帮你低成本完成跨框架迁移。

核心差异:VuReact 并非 Veaury/Vuera 这类运行时“套壳”方案,而是通过语义级编译将 Vue DSL 转化为纯净的 React 代码,无运行时冗余、无框架耦合,最终产出可独立维护的 React 工程。

🎥 效果预览

  • 在线体验:skx7pn-5173.csb.app/
  • 源码仓库:github.com/vureact-js/…
  • 核心能力:编译后保留所有业务逻辑、路由守卫、状态联动,支持热更新,Vue 源码修改可同步更新 React 产物。

vureact_hero_demo.gif

📋 前置准备

环境要求

  • Node.js ≥ 19(版本过低易导致依赖安装/编译失败)
  • 克隆示例仓库:
git clone https://github.com/vureact-js/example-customer-support-hub.git

初始化项目

cd customer-support-hub
npm install

安装完成后检查 package.json,确认包含 VuReact 核心编译脚本:

"scripts": {
  "vr:watch": "vureact watch",  // 增量迁移-监听模式
  "vr:build": "vureact build"   // 全量编译
}

同时确认项目根目录存在 vureact.config.ts(核心配置文件),路由入口需指向 Vue 路由文件:

router: {
  configFile: 'src/router/index.ts', // 声明Vue路由入口
},

🔧 核心步骤:VuReact 编译与产物解析

Step 1:执行编译(关键操作)

# 全量编译(首次迁移推荐)
npm run vr:build

# 增量迁移可选监听模式(开发阶段)
# npm run vr:watch

编译成功的核心特征

  1. 控制台输出编译统计(SFC/script/style 处理数量);

在这里插入图片描述

  1. 生成 .vureact/react-app 目录,目录结构与 Vue 源码完全一致;

在这里插入图片描述

  1. 样式自动注入(通过配置钩子修复路径):
// vureact.config.ts
onSuccess: async () => {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const entryFile = path.resolve(__dirname, './.vureact/react-app/src/main.tsx');
  const data = fs.readFileSync(entryFile, 'utf-8');
  // 修复React入口样式导入路径
  const newData = data.replace('index.css', 'styles/app.css');
  fs.writeFileSync(entryFile, newData, 'utf-8');
};

常见编译失败排查

报错类型 快速排查方向
Network/NPM 错误 切换 npm 淘宝源,检查网络连通性
SFC 语法错误 先修复 Vue 源文件的模板插值、指令格式问题
产物目录缺失 确认在项目根目录执行命令,且 vureact.config.ts 配置正常

Step 2:React 产物核心逻辑解析

编译后的 React 工程完全复用 Vue 原有逻辑,核心适配层由 @vureact/runtime@vureact/router 支撑。

1. 路由系统适配

React 入口文件 main.tsx 通过 RouterProvider 挂载路由:

import RouterInstance from './router/index';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

Vue 路由守卫逻辑自动继承(比如未登录跳转登录):

// react-app/src/router/index.ts
import { createRouter, createWebHashHistory } from "@vureact/router";
import routes from './routes';
import { appStore } from '../store/useAppStore';

const router = createRouter({
  history: createWebHashHistory(),
  routes
});

router.beforeEach((to, _from, next) => {
  // 放行公开页面(如登录页)
  if (to.meta.public) {
    next();
    return;
  }
  // 未登录跳转登录页
  const session = appStore.getState().session;
  if (!session.user) {
    next({
      name: 'login',
      query: { redirect: to.fullPath }
    });
    return;
  }
  next();
});

export default router;

2. 状态管理适配(Vue + Zustand)

Vue 源码中直接使用的 Zustand(React 状态库),编译后自动适配 React 语法:

// src/store/useAppStore.ts
import { createStore } from 'zustand/vanilla';

// 核心状态:会话、工单筛选、SLA配置等
export const appStore = createStore<AppState>((set) => ({
  session: { user: null },
  ticketFilters: { status: 'all' },
  activities: [],
  // 核心动作
  login: (user) => set((state) => ({ session: { ...state.session, user } })),
  appendActivity: (text) => set((state) => ({
    activities: [...state.activities, { id: Date.now(), text }]
  }))
}));

Vue 组件中订阅状态的逻辑,编译后也能正常工作:

// 原Vue代码
appStore.subscribe((state) => {
  userName.value = state.session.user?.name || '访客';
});

3. UI 组件适配(Vue 中用 Ant Design React)

Vue 源码中直接使用的 Ant Design React 组件,编译后自动转化为 React 语法:

<!-- 原Vue文件 src/pages/TicketsList.vue -->
<AntTable
  :columns="columns"
  :data-source="rows"
  :pagination="pagination"
  row-key="id"
  :loading="loading"
  @change="onTableChange"
/>

<AntDrawer 
  :open="drawerOpen" 
  width="560" 
  title="客户详情" 
  @close="onCloseDrawer"
>
  <!-- 客户信息展示 -->
</AntDrawer>

Step 3:启动 React 产物工程

# 进入编译后的React工程目录
cd .vureact/react-app

# 安装依赖(首次启动必做)
npm install

# 启动开发服务
npm run dev

启动成功特征

  • Vite 开发服务正常启动,浏览器打开默认进入登录页;

在这里插入图片描述

  • 登录后客服协同主界面样式、交互与原 Vue 项目完全一致;

在这里插入图片描述

  • 热更新生效:修改 Vue 源文件,npm run vr:watch 会同步更新 React 页面。

启动失败排查

  • 依赖缺失:根据日志补充 antdzustand 等包;
  • TS 报错:检查路由入口、运行时包的导入路径;
  • Vite 报错:确保 Node.js ≥ 19,或适当降级 Vite 版本。

✅ 业务闭环验收(核心验证)

迁移后需手动验证核心业务链路,确保功能完整:

  1. 登录与路由守卫:未登录访问业务页自动跳转登录,登录后可回跳目标页面;
  2. 工单全流程:筛选/接单/升级/状态更新正常,活动流、SLA 看板实时同步;
  3. 客户与建单联动:查看客户风险评分,快捷建单后工单列表可正常检索;
  4. 知识库检索:关键词/标签筛选、分页展示正常。

🚨 高效排错技巧(按症状)

问题症状 排查步骤
路由空白页 检查 RouterProvider 挂载逻辑,核对路由配置文件路径
编译失败 定位报错文件,修复 Vue 源码的语法/类型问题后重新编译
watch 模式不同步 确认 npm run vr:watch 正在运行,检查文件监听范围
业务联动不触发 检查 mock-api 调用(如 claimTicket),确认 appendActivity 执行

通用排错命令

# 重新全量编译
npm run vr:build

# 清空产物缓存后重新编译
rm -rf .vureact
npm run vr:build
cd .vureact/react-app && npm install && npm run dev

📌 核心能力覆盖(本案例)

技术维度 适配能力
模板语法 常用指令、事件、插槽全适配
组件系统 defineProps/defineEmits/slot 完全兼容
响应式 ref/computed/watch 编译为 React Hook
UI 库 Ant Design 表格/表单/抽屉/看板全量支持
状态管理 Zustand 跨页面状态同步、订阅更新
路由 守卫、嵌套路由、动态路由、重定向
样式 scoped 样式、Sass 语法适配
业务 工单流转、SLA 风险、客户评分、知识库检索

📚 后续学习导航

🎯 总结

VuReact 解决了 Vue 到 React 迁移的核心痛点:通过语义级编译将 Vue 源码转化为纯净的 React 工程,既复用了存量业务代码,又摆脱了框架耦合,最终产出可独立维护的 React 资产。

这套“可控混写迁移”方案,既降低了直接重写的成本与风险,又能平滑接入 React 生态,适合有存量 Vue 项目、想逐步迁移到 React 的团队落地。

🔗 相关资源

如果这个工具对你有帮助,欢迎给 GitHub 仓库点个 Star ✨,也欢迎在评论区交流迁移过程中遇到的问题~

在Next.js NFT市场中,我如何解决动态路由、链上数据获取与状态同步的连环坑

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

背景

上个月,我接手了一个NFT交易市场的前端重构项目。原版是用Create React App搭的,状态管理混乱,页面加载慢,尤其是NFT详情页,每次都要先白屏再慢慢加载链上数据,用户体验很差。团队决定用Next.js 14(App Router)重写,目标是利用服务端渲染(SSR)和静态生成(SSG)提升首屏速度,并构建更清晰的数据流。

我负责的核心模块是NFT详情页(/nft/[contractAddress]/[tokenId])和列表页。一开始我以为就是把React组件搬过来,用wagmiviem替换旧的ethers.js调用。但真正动手才发现,在Next.js的App Router架构下,如何优雅地结合SSR的确定性数据和客户端链上实时数据,如何管理因钱包切换、网络变更触发的全局状态更新,成了一个个需要具体解决的坑。

问题分析

我的初始思路很直接:

  1. 在NFT详情页的page.tsx里,用wagmiuseReadContract钩子获取NFT元数据(如名称、图片、所有者)。
  2. 用动态路由参数contractAddresstokenId作为查询依赖。
  3. 在服务端组件中获取一些静态信息。

但马上遇到了问题:页面在首次加载(或刷新)时,useReadContract返回的data初始值为undefined,虽然请求很快发出,但UI会有一个从“无数据”到“有数据”的闪烁。这对于一个追求体验的市场来说很扎眼。更麻烦的是,当用户在详情页发起一个购买交易后,我需要实时监听交易状态和NFT所有权的变化。最初我简单地在交易发起后设置了一个setInterval轮询,这导致了组件状态混乱和内存泄漏警告。

我意识到,问题可以拆解为三个关键点:

  1. 数据获取策略:哪些数据应该在服务端预取(SSR/SSG)?哪些必须在客户端实时获取?如何让两者无缝衔接?
  2. 实时状态监听:如何有效且安全地监听链上事件(如Transfer事件)和交易状态,而不是用低效的轮询?
  3. 状态同步:一个NFT的状态变化(如价格更新、所有权转移)如何及时反映在列表页和详情页,而不需要用户手动刷新?

核心实现

1. 混合数据获取:SSR静态骨架 + 客户端Hydration

我决定采用混合策略。对于NFT的“核心元数据”(如tokenURI解析后的name, image, description),这些数据一旦铸造就基本不变,非常适合在服务端获取并作为页面骨架。而对于实时变动的数据(如ownerprice),则在客户端获取。

这里有个坑:直接在服务端组件中使用viempublicClient.readContract需要配置RPC URL,并且要处理好错误(比如不支持的链)。我创建了一个服务端的工具函数:

// app/lib/server/nft-data.ts
import { createPublicClient, http, isAddress } from 'viem'
import { mainnet } from 'viem/chains'

export async function getBaseNFTMetadata(contractAddress: string, tokenId: string) {
  // 基础验证
  if (!isAddress(contractAddress)) {
    throw new Error('Invalid contract address')
  }

  const client = createPublicClient({
    chain: mainnet, // 根据你的主要链配置
    transport: http(process.env.NEXT_PUBLIC_RPC_URL_MAINNET)
  })

  try {
    // 1. 读取 tokenURI
    const tokenURI = await client.readContract({
      address: contractAddress as `0x${string}`,
      abi: [{
        inputs: [{ name: 'tokenId', type: 'uint256' }],
        name: 'tokenURI',
        outputs: [{ name: '', type: 'string' }],
        stateMutability: 'view',
        type: 'function'
      }],
      functionName: 'tokenURI',
      args: [BigInt(tokenId)]
    })

    // 2. 这里简化处理,实际项目需要处理IPFS、HTTP等不同协议
    // 假设tokenURI是一个直接可访问的HTTP URL
    const metadataResponse = await fetch(tokenURI)
    if (!metadataResponse.ok) {
      throw new Error(`Failed to fetch metadata: ${metadataResponse.status}`)
    }
    const metadata = await metadataResponse.json()

    return {
      name: metadata.name || `NFT #${tokenId}`,
      image: metadata.image,
      description: metadata.description,
      // 注意:这里不返回owner,因为它是实时变化的
    }
  } catch (error) {
    console.error('Failed to fetch base NFT metadata:', error)
    // 返回一个安全的默认值,避免页面崩溃
    return {
      name: `NFT #${tokenId}`,
      image: '/placeholder-nft.png',
      description: 'Metadata not available',
    }
  }
}

然后在详情页的page.tsx中:

// app/nft/[contractAddress]/[tokenId]/page.tsx
import { getBaseNFTMetadata } from '@/app/lib/server/nft-data'

interface PageProps {
  params: Promise<{ contractAddress: string; tokenId: string }>
}

export default async function NFTDetailPage({ params }: PageProps) {
  const { contractAddress, tokenId } = await params
  // 服务端获取静态元数据
  const baseMetadata = await getBaseNFTMetadata(contractAddress, tokenId)

  return (
    <div>
      <h1>{baseMetadata.name}</h1>
      <img src={baseMetadata.image} alt={baseMetadata.name} />
      <p>{baseMetadata.description}</p>
      {/* 客户端组件负责实时数据 */}
      <NFTLiveData contractAddress={contractAddress} tokenId={tokenId} />
    </div>
  )
}

2. 使用wagmi + viem监听实时事件与交易状态

对于实时数据,我创建了一个客户端组件NFTLiveData。为了避免轮询,我决定利用viemwatchContractEventwagmiuseWatchContractEvent钩子来监听Transfer事件。同时,使用useWaitForTransactionReceipt来优雅地监听交易状态。

注意这个细节:监听事件需要组件挂载,并且要在组件卸载时清理。wagmi的钩子内部帮我们处理了,但直接使用viem的客户端时需要注意。

// app/components/nft-live-data.tsx
'use client'

import { useReadContract, useWatchContractEvent, useAccount, useWaitForTransactionReceipt } from 'wagmi'
import { erc721Abi } from 'viem'
import { useState, useEffect } from 'react'

interface NFTLiveDataProps {
  contractAddress: `0x${string}`
  tokenId: string
}

export function NFTLiveData({ contractAddress, tokenId }: NFTLiveDataProps) {
  const { address } = useAccount()
  const [currentOwner, setCurrentOwner] = useState<string>()
  const [lastTxHash, setLastTxHash] = useState<`0x${string}`>()

  // 1. 读取当前所有者
  const { data: ownerData, refetch: refetchOwner } = useReadContract({
    address: contractAddress,
    abi: erc721Abi,
    functionName: 'ownerOf',
    args: [BigInt(tokenId)],
    // 只有当合约地址和tokenId有效时才查询
    query: {
      enabled: !!contractAddress && !!tokenId,
    },
  })

  // 2. 监听该NFT的Transfer事件
  useWatchContractEvent({
    address: contractAddress,
    abi: erc721Abi,
    eventName: 'Transfer',
    // 监听特定tokenId的转移
    args: [null, null, BigInt(tokenId)],
    onLogs: (logs) => {
      console.log('Transfer event detected!', logs)
      // 事件触发后,重新获取所有者信息
      refetchOwner()
      // 可以在这里触发一个Toast通知
    },
  })

  // 3. 如果有正在进行的交易,监听其状态
  const { data: receipt, isSuccess: isTxConfirmed } = useWaitForTransactionReceipt({
    hash: lastTxHash,
    // 确认数等配置
    confirmations: 2,
  })

  useEffect(() => {
    if (ownerData) {
      setCurrentOwner(ownerData)
    }
  }, [ownerData])

  useEffect(() => {
    if (isTxConfirmed && receipt) {
      // 交易确认,可以更新UI状态,比如显示“购买成功”
      console.log('Transaction confirmed!', receipt)
      // 事件监听器会捕获到Transfer事件并触发refetchOwner,所以这里不一定需要再调用
    }
  }, [isTxConfirmed, receipt])

  const handlePurchase = async () => {
    // ... 购买逻辑,成功后会设置 setLastTxHash(txHash)
  }

  return (
    <div>
      <p>当前所有者: {currentOwner ? `${currentOwner.slice(0,6)}...${currentOwner.slice(-4)}` : '加载中...'}</p>
      <p>你是所有者吗? {address && currentOwner?.toLowerCase() === address.toLowerCase() ? '是' : '否'}</p>
      <button onClick={handlePurchase} disabled={!address}>
        购买
      </button>
      {lastTxHash && !isTxConfirmed && <p>交易确认中...</p>}
    </div>
  )
}

3. 全局状态同步:使用Zustand管理NFT状态

当用户在详情页购买了一个NFT后,列表页应该能及时反映出该NFT“已售出”的状态。如果只用本地状态或上下文,在复杂路由下会很麻烦。我选择了Zustand,因为它轻量且与React并发特性兼容性好。

我创建了一个store来管理关键NFT的状态:

// app/stores/nft-store.ts
import { create } from 'zustand'

interface NFTState {
  // 记录最近更新的NFT,key为`${contractAddress}-${tokenId}`
  recentlyUpdated: Record<string, { lastUpdated: number; owner?: string }>
  // 标记需要重新获取数据的NFT
  markAsUpdated: (contractAddress: string, tokenId: string, newOwner?: string) => void
  // 检查某个NFT是否需要更新
  needsRefresh: (contractAddress: string, tokenId: string, cacheThreshold?: number) => boolean
}

export const useNFTStore = create<NFTState>((set, get) => ({
  recentlyUpdated: {},
  markAsUpdated: (contractAddress, tokenId, newOwner) => {
    const key = `${contractAddress}-${tokenId}`
    set((state) => ({
      recentlyUpdated: {
        ...state.recentlyUpdated,
        [key]: {
          lastUpdated: Date.now(),
          owner: newOwner,
        },
      },
    }))
    // 可以设置一个定时器,一段时间后清理旧记录,避免内存膨胀
  },
  needsRefresh: (contractAddress, tokenId, cacheThreshold = 60000) => { // 默认60秒缓存
    const key = `${contractAddress}-${tokenId}`
    const record = get().recentlyUpdated[key]
    if (!record) return false
    // 如果记录更新时间在阈值内,则认为UI数据可能已过时
    return Date.now() - record.lastUpdated < cacheThreshold
  },
}))

然后在列表项组件和详情页组件中,都可以订阅这个store。当详情页完成购买后,调用markAsUpdated。列表页的组件通过useNFTStoreneedsRefresh方法判断是否需要重新获取数据,从而触发refetch

// 在列表项组件中
'use client'
import { useNFTStore } from '@/app/stores/nft-store'

function NFTListItem({ contractAddress, tokenId, initialOwner }) {
  const { needsRefresh } = useNFTStore()
  const shouldRefetch = needsRefresh(contractAddress, tokenId)

  const { data: owner, refetch } = useReadContract({
    // ... 配置,
    query: {
      // 当标记为需要刷新时,重新启用查询
      enabled: shouldRefetch,
    },
  })

  // ... 其他渲染逻辑
}

完整代码示例

以下是一个简化但可运行的NFT详情页核心结构:

// app/nft/[contractAddress]/[tokenId]/page.tsx
import { getBaseNFTMetadata } from '@/app/lib/server/nft-data'
import { NFTLiveData } from '@/app/components/nft-live-data'
import { Suspense } from 'react'

interface PageProps {
  params: Promise<{ contractAddress: string; tokenId: string }>
}

export default async function NFTDetailPage({ params }: PageProps) {
  const { contractAddress, tokenId } = await params

  // 服务端获取基础元数据(可能失败,需要容错)
  let baseMetadata
  try {
    baseMetadata = await getBaseNFTMetadata(contractAddress, tokenId)
  } catch (error) {
    baseMetadata = {
      name: `NFT #${tokenId}`,
      image: '/placeholder-nft.png',
      description: 'Could not load metadata.',
    }
  }

  return (
    <div className="container mx-auto p-4">
      <div className="grid md:grid-cols-2 gap-8">
        {/* 左侧:静态图片 */}
        <div>
          <img
            src={baseMetadata.image}
            alt={baseMetadata.name}
            className="w-full rounded-xl shadow-lg"
            onError={(e) => {
              // 图片加载失败时使用占位图
              e.currentTarget.src = '/placeholder-nft.png'
            }}
          />
        </div>

        {/* 右侧:信息与交互 */}
        <div>
          <h1 className="text-3xl font-bold mb-2">{baseMetadata.name}</h1>
          <p className="text-gray-600 mb-6">{baseMetadata.description}</p>

          {/* 客户端实时数据部分,用Suspense包裹避免阻塞流 */}
          <Suspense fallback={<div>加载实时数据...</div>}>
            <NFTLiveData
              contractAddress={contractAddress as `0x${string}`}
              tokenId={tokenId}
              baseName={baseMetadata.name}
            />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

踩坑记录

  1. BigInt序列化错误:在服务端获取tokenIduint256)后,直接将其作为props传递给客户端组件,Next.js在序列化时会报错“BigInt not serializable”。解决:在服务端将其转换为string,在客户端需要时再转回BigInt
  2. useWatchContractEvent重复触发:最初没有在args中指定具体的tokenId,导致监听整个合约的所有Transfer事件,任何NFT的交易都会触发回调,造成不必要的重渲染和API调用。解决:精确指定事件参数过滤器。
  3. Zustand store在服务端组件中导入错误:尝试在服务端组件中导入useNFTStore会导致错误,因为Zustand使用了React上下文。解决:严格区分服务端与客户端代码,store只在客户端组件或钩子中使用。服务端数据通过props传递。
  4. RPC限流与错误处理:在服务端函数getBaseNFTMetadata中直接使用公共RPC,在流量大时容易触发限流。解决:增加了健壮的try-catch,返回友好的默认数据;对于生产环境,应考虑使用付费RPC服务、设置缓存(如redis)或使用像The Graph这样的索引服务来减轻链上直接查询的压力。

小结

这次重构让我深刻体会到,在Next.js中构建响应式Web3前端,关键在于分层处理数据(SSR静态层 + 客户端动态层)和选择正确的同步机制(事件监听优于轮询)。一个简单的refetch背后,需要全局状态管理的配合才能实现流畅的跨页面状态同步。下一步,我计划将链上事件监听抽象为更通用的自定义Hook,并探索React Querywagmi更深入的集成,来管理更复杂的缓存策略。

Chrome偷藏了你的JS!V8引擎到底做了什么?

作者 牛奶
2026年4月7日 09:57

Chrome偷藏了你的JS!V8引擎到底做了什么?

你有没有想过:为什么 JavaScript 能"秒执行"?你写的 console.log('Hello') 到底经历了什么?从 Chrome 偷藏你的代码,到 V8 引擎对你的 JS 做了什么——今天全部揭秘!


原文地址

墨渊书肆/Chrome偷藏了你的JS!V8引擎到底做了什么?


V8 是什么?

JavaScript 引擎

浏览器能执行 JavaScript,全靠 JavaScript 引擎

常见的引擎有:

  • V8 — Chrome、Node.js、Deno 在用
  • SpiderMonkey — Firefox 在用
  • JavaScriptCore — Safari 在用
  • Chakra — 旧版 Edge 在用

V8 是 Google 开发的高性能引擎,用 C++ 编写,让 JS 执行速度可以媲美编译型语言。

V8 的工作流程

你写的 JS 代码,V8 要做的事情很简单:

JS代码 → 解析 → 编译 → 执行

但这中间,V8 做了大量偷跑优化

V8 架构演进

时代 架构 说明
早期 Full Codegen → Crankshaft 快速生成机器码,但维护困难
现在 Ignition → TurboFan 字节码+优化编译器,更高效
最新 Ignition + TurboFan + Sparkplug 新增无解释的 baseline JIT

代码是怎么跑起来的?

从 JS 到机器码

你写了一段代码:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2));

V8 拿到这段代码后,经历了这些阶段:

1. 解析(Parser)
   
   把JS代码变成 AST(抽象语法树)
   
2. 解释(Ignition)
   
   编译成字节码,立即执行
   
3. 优化编译(TurboFan)
   
   热代码被编译成高效的机器码
   
4. 执行

Ignition — 解释器

字节码是什么?

V8 首先用 Ignition 解释器处理代码。

Ignition 会把你的 JS 代码编译成字节码——一种中间代码,比机器码容易生成,但比 JS 容易执行。

// 你写的 JS
function add(a, b) {
  return a + b;
}

对应的字节码(简化版):

# 字节码类似这样
LdaSmi [1]      # 加载小整数 1
StaA [0]        # 存到 [0] 位置(寄存器)
LdaSmi [2]      # 加载小整数 2
AddA [0]        # 加上 [0] 位置的数
Return           # 返回结果

为什么要转字节码?

直接执行 JS 转字节码再执行
每次都要重新解析 字节码更紧凑
无法优化 可以记录执行信息
启动慢 启动更快

Ignition 不只解释执行,还会记录信息——哪些函数被调用多次、参数类型是什么。这些信息给后续优化用。

Ignition 的执行反馈

function add(a, b) {
  return a + b;
}

add(1, 2);      // 第1次:记录类型
add(3, 4);      // 第2次:类型一致,继续记录
add("x", "y");  // 第3次:类型变了!记录下来

Ignition 维护一个 Feedback Vector(反馈向量),记录每段代码的类型信息。


TurboFan — 优化编译器

JIT 是什么?

JIT(Just-In-Time)= 即时编译。

不是提前编译好,而是一边执行一边编译。执行次数多的代码,会被更高效的机器码替代。

TurboFan 优化流程

TurboFan 不是直接生成最优机器码,而是层层优化:

字节码 + 执行反馈
   
Sea of Nodes(中间表示)
    优化 Pass 1: 类型推导
    优化 Pass 2: 内联
    优化 Pass 3: 环路优化
    优化 Pass 4: 寄存器分配
   
机器码

热代码检测

V8 有一套"热点检测"机制:

function add(a, b) {
  return a + b;
}

// 这个函数被调用了10000次
for (let i = 0; i < 10000; i++) {
  add(1, 2);
}
调用次数 < 1000

Ignition 解释器执行(字节码)

调用次数 > 1000

TurboFan 优化编译(机器码)

优化与反优化

TurboFan 很聪明,但也有"翻车"的时候:

function add(a, b) {
  return a + b;
}

// 前1000次调用,参数都是整数
for (let i = 0; i < 1000; i++) {
  add(1, 2);  // TurboFan 优化:整数加法
}

// 第1001次,参数变成字符串
add("hello", "world");  // 反优化!退回字节码

TurboFan 发现类型变了,会反优化(Deoptimization),退回字节码。

常见的优化场景

// ✅ 好优化:类型稳定
function length(arr) {
  return arr.length;  // 数组 length 是稳定的
}
length([1, 2, 3]);
length([4, 5]);

// ❌ 难优化:类型不稳定
function getX(obj) {
  return obj.x;  // obj 可能是任意类型
}
getX({ x: 1 });
getX("string");  // 字符串没有 x 属性!

隐藏类 — 快速属性访问

对象属性查找

JS 里访问对象属性很快,这要归功于隐藏类(Hidden Class),也叫 ShapesMaps

const person = { name: 'Tom', age: 18 };

V8 内部会为这个对象创建一个隐藏类:

隐藏类 HC0
├── name: offset 0
└── age: offset 1

属性访问加速原理

当你访问 person.name 时:

// 幕后发生的事情
person.name
  → 通过隐藏类 HC0
  → 直接定位到 offset 0
  → 拿到值 "Tom"

就像图书馆的书有固定编号(隐藏类),管理员知道每本书在哪个书架第几格。

隐藏类转换

对象属性改变时,会产生新的隐藏类:

const obj = { x: 1 };
//   ↓ 添加 y
obj.y = 2;
//   ↓ 修改 x
obj.x = 10;
HC0: { x: 1 }
   添加 y 属性
HC1: { x: 1, y: 2 }
   修改 x 属性(值变化不改变结构)
HC1(不变)

属性顺序很重要!

// 好:属性顺序一致 → 共享同一个隐藏类
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// 差:属性顺序不一致 → 产生多个隐藏类
const p3 = { y: 1, x: 2 };  // 新建 HC1!

多态与全态

// 单态(Monomorphic):一种隐藏类,最快
function getX(obj) { return obj.x; }
getX({ x: 1 });      // HC0
getX({ x: 2 });      // 还是 HC0,命中缓存

// 多态(Polymorphic):2-4种隐藏类,较慢
function getX(obj) { return obj.x; }
getX({ x: 1, a: 0 });    // HC0
getX({ x: 2, b: 0 });    // HC1

// 全态(Megamorphic):5+种隐藏类,最慢
function getX(obj) { return obj.x; }
getX({ ... });  // 每次都是新结构

内联缓存 — 加速函数调用

函数调用有多慢?

函数调用看起来简单:

function getName(user) {
  return user.name;
}

const user = { name: 'Tom' };
getName(user);

但每次调用,V8 都要查找 user.name 在哪里。

内联缓存的原理

V8 第一次执行 getName(user) 时:

第1次调用:
1. 查找 user 的隐藏类  HC0
2. 查找 name 属性在 HC0 的位置  offset 0
3. 返回结果
4. 记录:HC0 的对象调用这个函数,返回 offset 0

之后调用同样的函数,直接跳过查找

第2次调用:
1. 检查隐藏类是 HC0 
2. 直接用记录的 offset 0
3. 返回结果

这就是内联缓存(Inline Cache)——把查找结果"缓存"起来。

IC 的类型状态

Uncached  Monomorphic  Polymorphic(2-4)  Megamorphic(5+)
                                           
 每次查     命中缓存       部分命中         全局查表

垃圾回收 — 内存管理

什么是垃圾?

程序里不再使用的对象就是"垃圾":

function createUser() {
  const user = { name: 'Tom' };
  return user.name;  // user 对象还在用
}  // 但 user 变量没了

createUser();
// 之后再也访问不到这个 { name: 'Tom' } 对象了
// 它就成了"垃圾"

V8 的内存布局

┌─────────────────────────────┐
          新生代                新对象
    (New Space / Semi-Space) 
├─────────────────────────────┤
          老生代                存活久的对象
    (Old Space)              
├─────────────────────────────┤
        大对象区                 无法放入其他区的对象
    (Large Object Space)    
├─────────────────────────────┤
        代码区                   JIT 编译后的机器码
    (Code Space)            
├─────────────────────────────┤
        Cell / Map              特殊对象
    (Cell / Map Space)       
└─────────────────────────────┘

V8内存布局图

V8 的垃圾回收策略

V8 采用分代回收

代际 对象来源 回收频率 算法
新生代 新创建的对象 频繁 Scavenge(复制)
老生代 经历一次 GC 仍存活 较少 Mark-Sweep-Compact

新生代:Scavenge 算法

新生代内存分两半:FromTo

┌─────────────────┬─────────────────┐
│      FromTo        │
│   (使用中)     │   (空闲)       │
└─────────────────┴─────────────────┘

1. From 满了,存活对象复制到 To
2. From 清空
3. FromTo 交换

晋升:经历两次 Scavenge 仍存活的对象,会进入老生代。

老生代:Mark-Sweep-Compact

步骤1:标记(Mark)

遍历所有根对象(全局变量、栈上变量)
    
标记能访问到的对象为"存活"
    
没被标记的就是垃圾

步骤2:清除(Sweep)

回收没有标记的对象的内存

步骤3:压缩(Compact)

存活对象移动到一起

解决内存碎片问题

增量 GC

为了避免长时间停顿(Stop-The-World),V8 使用增量标记:

传统 GC:
████████████████████████████  100% 停顿
     执行时间 ←────────────────→

增量 GC:
███    ████    ███    ██
                    
执行  执行  执行  执行

Orinoco — 并行与并发 GC

现代 V8 使用更先进的 GC 算法:

技术 说明 效果
并行 GC GC 多线程并行执行 充分利用多核 CPU
增量 GC GC 分多次小步执行 减少停顿时间
并发 GC GC 与 JS 执行同时进行 几乎无停顿

深入了解 V8 🔬

V8 执行流程全图

JS代码
    Parser
AST(抽象语法树)
    Ignition
字节码 + Feedback Vector(反馈向量)
    (热代码触发)
TurboFan
   
优化机器码
    (类型不稳定)
反优化  退回字节码

V8执行流程详图

为什么 V8 这么快?

优化手段 作用
JIT 即时编译 热代码用机器码执行
隐藏类 对象属性快速访问
内联缓存 函数调用加速
分代回收 高效内存管理
懒解析 延迟解析,只解析用到的
并行 GC 多核加速垃圾回收

Sparkplug — 无解释的 Baseline JIT

V8 最近引入了 Sparkplug,一个超快的 baseline JIT:

之前:JS  Ignition 字节码  TurboFan 机器码
现在:JS  Ignition 字节码  Sparkplug 机器码  TurboFan 优化机器码

Sparkplug 不做任何优化,直接把字节码转成机器码,比 Ignition 快 2-5 倍。

TurboFan 优化的代码例子

// 优化前:字节码执行
function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) {
    total += arr[i];
  }
  return total;
}

// 优化后:TurboFan 可能生成的机器码
// 1. 使用寄存器代替变量
// 2. 循环展开(Loop Unrolling)
// 3. 预取数据到 CPU 缓存

编写高性能 JS

// ✅ 好:保持属性类型一致
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// ✅ 好:避免类型变化
function add(a, b) {
  return a + b;
}
add(1, 2);       // 都是整数
add(3.14, 2.86); // 都是浮点数

// ❌ 差:属性顺序不一致
const a = { x: 1, y: 2 };
const b = { y: 1, x: 2 };  // 新建隐藏类!

// ❌ 差:类型乱变
function example(x) {
  return x.value;  // x 可能是对象,可能是 undefined
}

// ✅ 好:使用固定形状的对象
const cache = {};
for (let i = 0; i < 1000; i++) {
  cache.key = i;  // 每次都用相同的 key
}

V8 性能陷阱

陷阱 说明 解决方案
隐藏类爆炸 对象结构不一致 保持属性顺序一致
类型不稳定 参数类型经常变化 使用多态函数时要小心
内存泄漏 闭包引用大量对象 及时解除引用
大对象 大数组、大对象放新生代 手动管理或拆分

总结

概念 作用 比喻
Ignition 解释器,生成字节码 + 记录反馈 同声传译先听懂意思
TurboFan 优化编译器,生成高效机器码 翻译稿润色升级
JIT 即时编译,热代码加速 多次练习后越说越溜
隐藏类 快速属性访问 图书馆编号系统
内联缓存 函数调用加速 记住常走的路
分代回收 高效内存管理 新书放前台,旧书放仓库
Sparkplug 超快 baseline JIT 不用练习,直接上岗

写在最后

现在你知道了:

  • V8 不是直接执行 JS,而是经过 Parser → Ignition → TurboFan
  • JIT 让热代码越来越快,但类型变化会导致反优化
  • 隐藏类和内联缓存,是 JS 快的秘密
  • 写代码时保持类型一致,能帮助 V8 优化
  • 新生代用复制算法,老生代用标记清除

下次有人说"JS 慢",你可以理直气壮地说:你了解 V8 吗?

为什么禁止我请求别的网站的接口?——跨域与CORS

作者 牛奶
2026年4月7日 09:54

你有没有遇到过这种情况:在自己的网页上想请求别人的API,结果浏览器直接报错:Access-Control-Allow-Origin' header is missing。为什么浏览器要阻止你?服务器不响应不就完了吗?

今天,用**"小区门禁"**的故事,来讲讲 跨域CORS


原文地址

墨渊书肆/为什么禁止我请求别的网站的接口?——跨域与CORS


什么是"跨域"?

同源策略 — 浏览器的安全基石

浏览器有个同源策略Same-Origin Policy):只有来自同一个"家"的资源才能随便用。

什么叫"同一个家"?看三个条件:协议(http/https)、域名(example.com)、端口(:8080)。三个都一样,才是同源;有一个不一样,就是跨域。

跨域的例子

http://example.com 和 http://example.com/profile     // 协议+域名+端口都相同 → 同源
✅ https://example.com 和 https://example.com           // 协议+域名+端口都相同 → 同源
❌ http://example.com 和 https://example.com           // 协议不同 → 跨域
❌ http://example.com 和 http://api.example.com        // 域名不同(子域名)→ 跨域
❌ http://example.com:8080 和 http://example.com:3000  // 端口不同 → 跨域

跨域限制了什么?

浏览器的同源策略主要限制了三件事:

  • DOM 访问:无法读取不同源的 iframe 内容、无法修改不同源的 iframe DOM
  • AJAX 请求:无法请求不同源的 API
  • Cookie/LocalStorage:无法访问不同源的数据

为什么要限制跨域?

模拟一个攻击场景

想象一下:你登录了银行网站 bank.com,浏览器保存了你的登录 Cookie。

然后你手滑点进了一个恶意网站 evil.com,这个网站里有一段代码:

<form action="http://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="hacker">
  <input type="hidden" name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>

如果没有同源策略,这个表单请求会自动带上 bank.com 的 Cookie,银行服务器以为是你本人操作的——钱就没了。

同源策略就是浏览器的"门禁":只有同一家人才能进,陌生人要查证件。

💡 注意:<img> 标签的 GET 请求虽然也会带 Cookie,但现代浏览器有 SameSite Cookie 保护。上面表单 POST 场景更典型。


CORS — 跨域的"通行证"

CORS 是什么?

CORS(Cross-Origin Resource Sharing)= 跨域资源共享。

它的工作原理很简单:让服务器告诉浏览器,"我允许来自这些源的请求"

简单请求 vs 预检请求

简单请求

满足以下条件的请求是"简单请求":

条件 要求
请求方法 GETPOSTHEAD
请求头部 只有几种常见类型
Content-Type 只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain

简单请求的流程:

1. 浏览器发送请求(自动带上 Origin 头)
   
2. 服务器检查 Origin,决定是否允许
   
3. 服务器返回响应头 Access-Control-Allow-Origin
   
4. 浏览器检查响应头,允许就完事

服务器端示例(Node.js):

app.get('/api/data', (req, res) => {
  const origin = req.headers.origin;

  if (origin === 'https://example.com') {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }

  res.json({ data: '这是返回的数据' });
});

响应头

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com
Content-Type: application/json

{"data": "这是返回的数据"}

预检请求(Preflight)

不满足"简单请求"条件的,浏览器会先发一个 OPTIONS 请求"探路":

1. 浏览器发送 OPTIONS 预检请求
   
2. 服务器检查方法/头部/Origin
   
3. 服务器返回允许的头 Access-Control-*
   
4. 浏览器发送实际请求

预检请求检查什么?

预检请求(OPTIONS)就像登机前的安检——先检查你带没带危险品。

浏览器会问服务器三件事:

  • 我从哪来?(Origin)
  • 我想用什么方法?(Access-Control-Request-Method)
  • 我想带什么头?(Access-Control-Request-Headers)

服务器回答"可以",浏览器才放行实际请求。

# 请求(浏览器发给服务器)
OPTIONS /api/data HTTP/1.1
Origin: https://example.com              # 我从哪来
Access-Control-Request-Method: PUT        # 我想用 PUT 方法
Access-Control-Request-Headers: Content-Type, Authorization  # 我想带这些头

---

# 响应(服务器告诉浏览器)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com  # 允许这个源
Access-Control-Allow-Methods: GET, POST, PUT, DELETE  # 允许这些方法
Access-Control-Allow-Headers: Content-Type, Authorization  # 允许这些头
Access-Control-Max-Age: 86400          # 预检结果缓存24小时

服务器端处理

app.options('/api/data', (req, res) => {
  const origin = req.headers.origin;

  if (origin === 'https://example.com') {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
  }

  res.status(204).send();
});

CORS 响应头详解

常用响应头

响应头 作用 例子
Access-Control-Allow-Origin 允许的源 *https://example.com
Access-Control-Allow-Methods 允许的方法 GET, POST, PUT
Access-Control-Allow-Headers 允许的头部 Content-Type, Authorization
Access-Control-Max-Age 预检缓存时间 86400(秒)
Access-Control-Allow-Credentials 是否允许带 Cookie true

credentials 模式

默认情况下,CORS 不带 Cookie。如果需要携带 Cookie:

前端

fetch('/api/data', {
  credentials: 'include'
});

服务端

res.setHeader('Access-Control-Allow-Origin', 'https://example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');

注意:Access-Control-Allow-Origin 不能用 *,必须是具体域名。


跨域的解决方案

1. JSONP(已不推荐)

利用 <script> 标签不受同源策略限制的特性:

<script>
  function handleData(data) {
    console.log(data);
  }
</script>
<script src="http://api.example.com/data?callback=handleData"></script>
缺点 说明
只支持 GET 无法处理 POST 等请求
有安全风险 可能被注入恶意代码
无法捕获错误 错误处理困难

2. 代理服务器

在自己的服务器上转发请求,"伪装"成同源:

浏览器 ──> 我的服务器(同一源) ──> 目标服务器

Nginx 代理

location /api/ {
  proxy_pass http://target-server.com/;
}

Node.js 代理

app.get('/api/data', async (req, res) => {
  const response = await fetch('http://target-server.com/data');
  const data = await response.json();
  res.json(data);
});

3. Webpack/Vite 开发代理

开发环境配置代理:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://target-server.com',
        changeOrigin: true
      }
    }
  }
};

4. postMessage

不同窗口/iframe 之间的通信:

window.addEventListener('message', (event) => {
  if (event.origin === 'https://example.com') {
    console.log('收到消息:', event.data);
  }
});

iframe.contentWindow.postMessage('hello', 'https://example.com');

深入了解 CORS 🔬

第三方 Cookie 的限制

现代浏览器正在逐步限制第三方 Cookie:

浏览器 政策
Chrome 计划逐步淘汰第三方 Cookie
Safari 默认阻止第三方 Cookie
Firefox 提供第三方 Cookie 阻止选项

CORS 和 CSRF 的区别

CORS CSRF
是什么 跨域资源共享机制 跨站请求伪造攻击
作用 服务端允许/禁止跨域请求 利用用户已登录状态发起攻击
防御 服务端配置 Access-Control-* Token、SameSite Cookie、验证码

为什么 OPTIONS 叫"预检"?

"预检"就像登机前的安检——先检查你带没带危险品(方法、头部),没问题了才让你登机(发送实际请求)。


常见错误排查

错误 1:No 'Access-Control-Allow-Origin' header

原因 解决
服务端没配置 CORS 添加 Access-Control-Allow-Origin
Origin 不匹配 检查配置的域名是否正确
credentials 时用了 * 必须指定具体域名

错误 2:Method not allowed

原因 解决
请求方法(如 PUT)不在允许列表 检查 Access-Control-Allow-Methods

错误 3:Header not allowed

原因 解决
请求头部(如 Authorization)不在允许列表 检查 Access-Control-Allow-Headers

错误 4:预检请求 404

原因 解决
服务端没有处理 OPTIONS 请求 中间件或网关要放行 OPTIONS

总结

概念 像什么 作用
同源策略 小区门禁 限制不同源的访问,保护安全
CORS 通行证 告诉浏览器哪些跨域请求是允许的
简单请求 普通访客 不需要预检,直接请求
预检请求 安检验票 先检查再放行,更安全的请求
JSONP 走后门 已不推荐,有安全风险
代理 同一个家门 绕过跨域,最推荐的开发方案

写在最后

现在你应该明白了:

  • 跨域是浏览器的安全机制,不是为了刁难你
  • CORS 是服务器授权机制,服务器说可以,浏览器才放行
  • 预检请求 = 安检,OPTIONS 通过了才能发送实际请求
  • 生产环境推荐用代理,开发环境用 webpack/vite 代理

下次遇到跨域错误,先看浏览器控制台的报错信息——是"缺通行证"(header 缺失)还是"通行证不对"(origin 不匹配),处理方式不一样的。

❌
❌