当前位置: 首页 > news >正文

改一个叶子要重渲染 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,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-workbench
  • GitHub 组织主页:https://github.com/wangzifan396-wzf
http://www.cnnetsun.cn/news/4179922.html

相关文章:

  • 大学生用 AI 学编程的正确姿势:从问答案到做验证
  • 2026年5款AI写网文剧本工具实测横评:谁才是终极消痕助手?
  • Multimodal-Sentiment-Analysis 完整指南:BERT+ResNet50 五种融合方法,多模态情感分析快速上手
  • 真理不需要验证:KTS理论对西方学术范式动机污染的彻底诊断
  • 探秘 Python 枚举类型:从基础到实战的深度指南
  • Cursor版GitHub上线后再升级,/goal转正、子Agent独立,重塑软件工程生产线!
  • 【AI大模型进阶】写一个“法律条文检索助手”,体验RAG实战威力
  • 拓扑排序详解(Topological Sort)
  • 无锡芯健细胞:免疫细胞存储适配人群全解析
  • 雨晨 Windows 11 IoT 企业版 26H1 轻装 28120.2760
  • 工信部三级智能制造评审通关背后:一天,一个项目组,一家灯饰厂
  • 德系车维修质保体系的技术支撑分析:从配件追溯到施工标准化
  • python的运筹学工业场景模拟第九十二篇:金属型材下料,多种型材原料,多规格零件,整数规划,最小原料消耗,统计边角料。
  • 大厂Java面试实录:从Java SE到微服务,电商场景下的技术拷问与谢飞机翻车合集
  • 科颜氏白泥同源配方OEM代工厂揭秘:比价输在起跑线的老板,都忽略了泥膜料体的这三道隐形门槛
  • 福意联血液运输冷藏箱的优势特点详解
  • 关于“真理硬度”与KTS体系绝对自明性的系统性陈述
  • 让大模型思考,让小模型执行:在 Elastic Workflows 中拆分 LLM 成本
  • 技术面试黄金技巧:从STAR法则到薪资谈判
  • Java面试:从八股文到实战的演变与准备策略
  • Ceres损失函数选型指南:从原理到实战的鲁棒优化策略
  • 齿轮参数化设计:从建模到校核的工程实践指南
  • CANdelaStudio入门指南:从零创建汽车诊断数据库
  • Gradle配置全解析:从核心文件到性能优化与实战避坑指南
  • VSCode调试中No such file or directory错误:彻底解决相对路径与工作目录问题
  • Python类型注解与typing模块实战指南:从基础到工程化应用
  • Linux系统安装Qt5:三种方法详解与配置实战指南
  • IntelliJ IDEA自定义背景全攻略:用Background Image Plus插件打造高效护眼开发环境
  • 电商支付与结算系统架构实战:从网关设计到微服务中台演进
  • CSMA/CD协议详解:从碰撞检测到以太网演进