改一个叶子要重渲染 1 万次,Signals 只跑 1 次:细粒度响应式的实测复盘

发布时间:2026/8/23 23:41:38
改一个叶子要重渲染 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 是一个最小 signalget()在执行 effect 期间自动把当前 effect 登记为订阅者set()只通知这些订阅者绝不波及无关组件// 系统 Bgetter 自动收集依赖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 个组件重复更新 K2000 次后统计「组件函数重执行总次数」与耗时。为保证公平两套系统的每次重执行都做相同的整数乘法并累加到同一个 sink确认两者做了等量真实计算局部场景 sink 完全相等。先测「局部更新」——只改下标为 0 的那一个叶子场景组件数 N每次更新重执行2000 次累计耗时(ms)局部 · 整树重渲染2,0002,0004,000,00026.9局部 · 细粒度信号2,00012,000≈0.1局部 · 整树重渲染5,0005,00010,000,00077.6局部 · 细粒度信号5,00012,000≈0.1局部 · 整树重渲染10,00010,00020,000,000178.3局部 · 细粒度信号10,00012,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,00020,000,000全部重跑广播 · 细粒度信号10,00020,000,000全部订阅者都被通知当依赖被所有组件共享时细粒度信号同样要重跑全部 1 万个订阅者——推送模型并没有减少「该跑的组件」数量只是省掉了 diff。也就是说信号的优势严格绑定在「变更是局部化的」这一前提上。除此之外还有几个常被忽略的边界订阅图本身要占用内存并随组件数维护SSR 阶段信号需要额外的运行时追踪开销依赖关系不可见会让调试更费劲而对十几个状态的小型应用三者差异用户根本无感。图3局部化变更时信号省下 1 万倍重执行共享广播时两者完全相同信号并无优势。结论与下一步一句话方法论细粒度响应式省下的是「与组件规模成正比、但与变更粒度无关」的重渲染所以把它当作热路径优化大表单、实时仪表盘、万行列表才划算而不是当作全局架构信仰。广播型状态、小型应用、SSR 首屏该用整树模型或编译期 memo 仍要用。开源地址矩阵门户https://github.com/wangzifan396-wzf/WB单文件工具聚合器https://github.com/wangzifan396-wzf/nano-workbenchGitHub 组织主页https://github.com/wangzifan396-wzf