改一个叶子要重渲染 1 万次,Signals 只跑 1 次:细粒度响应式的实测复盘
2026 年,Angular、Vue、Solid、Svelte 几乎同时把「细粒度响应式 / Signals」推到台前,社区共识是它比虚拟 DOM 的整树重渲染更快。但「更快」到底快在哪、快多少、有没有失效场景,大多停留在口号。我用约 50 行 JS 在 Node 22 搭了两套最小系统,做了一次可复现的对照。
背景:为什么这件事值得写
虚拟 DOM 的心智模型是「状态变 → 重渲染整棵组件树 → diff → 打补丁」。即便只改了一个按钮文案,React(未加 memo)默认也会重跑整棵树的组件函数。Signals 的做法相反:在值层面精确记录「谁读了它」,状态变化时只更新真正依赖它的那一个 DOM 节点,不做 diff、不重跑父组件。
共识归共识,工程上仍有三个没说清的点:第一,所谓「更快」省下的到底是什么——是 diff 成本,还是组件函数本身的重执行?第二,省下的量到底和什么成正比?第三,有没有信号救不了的场景?不量化,这些就只是信仰。
解剖:两套最小系统怎么搭
为了把变量控制干净,我没有引入任何框架,而是各写了约 25 行的最小实现,保证两者「做等量真实计算」后再比时间。
系统 A(整树重渲染)持有一个长度为 N 的 state 数组,每个组件函数读取自己那份 state。update时无条件把所有组件函数重新跑一遍:
// 系统 A:任一状态变化 → 遍历重跑全部 N 个组件函数 function makeTreeModel(N) { const state = new Array(N).fill(0); const renders = new Array(N); for (let i = 0; i < N; i++) renders[i] = () => state[i] * 2654435761 >>> 0; return { update(idx, v) { state[idx] = v; for (let i = 0; i < N; i++) renders[i](); } }; }系统 B 是一个最小 signal:get()在执行 effect 期间自动把当前 effect 登记为订阅者,set()只通知这些订阅者,绝不波及无关组件:
// 系统 B:getter 自动收集依赖,setter 只推送给订阅者 let activeEffect = null; function signal(value) { let v = value; const subs = new Set(); return { get() { if (activeEffect) subs.add(activeEffect); return v; }, // 读时收集依赖 set(nv) { v = nv; for (const e of [...subs]) e.run(); }, // 写时只推送订阅者 }; } function effect(fn) { const e = { run() { const prev = activeEffect; activeEffect = e; fn(); activeEffect = prev; } }; e.run(); }两者的关键差别不在「算什么」,而在「哪些组件函数会被重新执行」。下面用 1 万组件把这件事压出来。
图1:左为整树重渲染——任一状态变化都重新执行全部组件函数;右为细粒度信号——getter 自动收集依赖,只重跑读过它的那一个组件。
实证:1 万组件的对照结果
实验环境为 Node 22.22.2,纯 JS 无第三方依赖。构建 N 个组件,重复更新 K=2000 次后统计「组件函数重执行总次数」与耗时。为保证公平,两套系统的每次重执行都做相同的整数乘法并累加到同一个 sink,确认两者做了等量真实计算(局部场景 sink 完全相等)。
先测「局部更新」——只改下标为 0 的那一个叶子:
| 场景 | 组件数 N | 每次更新重执行 | 2000 次累计 | 耗时(ms) |
|---|---|---|---|---|
| 局部 · 整树重渲染 | 2,000 | 2,000 | 4,000,000 | 26.9 |
| 局部 · 细粒度信号 | 2,000 | 1 | 2,000 | ≈0.1 |
| 局部 · 整树重渲染 | 5,000 | 5,000 | 10,000,000 | 77.6 |
| 局部 · 细粒度信号 | 5,000 | 1 | 2,000 | ≈0.1 |
| 局部 · 整树重渲染 | 10,000 | 10,000 | 20,000,000 | 178.3 |
| 局部 · 细粒度信号 | 10,000 | 1 | 2,000 | ≈0.1 |
结论很直接:局部更新时,整树模型每次都要重跑「全部 N 个」组件函数,细粒度信号每次只重跑「那 1 个」。组件数从 2 千涨到 1 万,前者的耗时从 26.9ms 涨到 178.3ms,近乎线性;后者始终 ≈0.1ms,几乎恒定。两者耗时差约 1,700 倍,重执行次数差正好是 1 万倍。
图2:横轴为组件数,橙色为整树重渲染耗时(随 N 近乎线性增长),绿色为细粒度信号耗时(始终低于 1ms)。
复现命令(文章同目录bench.mjs,纯 JS 无依赖):
# Node 22.x node bench.mjs局限:信号不是万能
把「局部更新」换成「广播更新」——改一个被全部组件读取的共享值,结果立刻反转:
| 场景 | 每次更新重执行 | 2000 次累计 | 说明 |
|---|---|---|---|
| 广播 · 整树重渲染 | 10,000 | 20,000,000 | 全部重跑 |
| 广播 · 细粒度信号 | 10,000 | 20,000,000 | 全部订阅者都被通知 |
当依赖被所有组件共享时,细粒度信号同样要重跑全部 1 万个订阅者——推送模型并没有减少「该跑的组件」数量,只是省掉了 diff。也就是说,信号的优势严格绑定在「变更是局部化的」这一前提上。除此之外还有几个常被忽略的边界:订阅图本身要占用内存并随组件数维护;SSR 阶段信号需要额外的运行时追踪开销;依赖关系不可见会让调试更费劲;而对十几个状态的小型应用,三者差异用户根本无感。
图3:局部化变更时信号省下 1 万倍重执行;共享广播时两者完全相同,信号并无优势。
结论与下一步
一句话方法论:细粒度响应式省下的是「与组件规模成正比、但与变更粒度无关」的重渲染,所以把它当作热路径优化(大表单、实时仪表盘、万行列表)才划算,而不是当作全局架构信仰。广播型状态、小型应用、SSR 首屏,该用整树模型或编译期 memo 仍要用。
开源地址
- 矩阵门户:https://github.com/wangzifan396-wzf/WB
- 单文件工具聚合器:https://github.com/wangzifan396-wzf/nano-workbench
- GitHub 组织主页:https://github.com/wangzifan396-wzf
