我必须提前说明:下面这篇博文围绕的是“信号(Signals)”这种前端响应式机制,和硬件里的信号处理完全是两码事。但有趣的是,我翻热搜词时看到“信号混叠失真”“信号完整性”“采样、抗混叠滤波与信号重建”这些词被搜得很热,它们其实是电子工程里的术语。我写完之后发现,用硬件信号处理的思维去理解前端 Signal 的某些坑,居然特别顺。所以这篇会中间穿插一点这种跨界的类比,帮大家换个角度想问题。
1. 虚拟DOM的黄金时代,账本是怎么一步步算不明白的
先说个结论:虚拟DOM没死,只是“性价比”开始出问题了。2026年的前端框架集体转向信号(Signals),本质不是虚拟DOM技术烂,而是业务场景变了,硬件环境变了,开发者对性能的敏感度也变了。这就像当年jQuery被React取代一样,不是jQuery写不了页面,而是复杂应用的状态同步成本太高,虚拟DOM这套“全量对比再局部更新”的思路,在最开始很优雅,但规模一大、交互一密,账就算不过来了。
1.1 虚拟DOM为什么能统治这么多年
虚拟DOM核心贡献是让前端从“命令式操作DOM”迈向了“声明式描述UI”。你只需要描述某个状态长什么样,框架负责算出真实DOM该怎么改。它的杀手锏是diff算法:新旧两棵虚拟节点树做对比,找出最小差异,再批量更新到页面上。面试里常问的“虚拟dom和diff算法”,本质上就是在考你对这套机制的底层理解:key的作用、同层对比策略、双端diff的优化等。
这套机制在2015年前后是降维打击。那时候单页应用刚刚爆发,前端要管理的数据复杂度远没有今天夸张,diff一次几百个节点的成本可以忽略。更重要的是,虚拟DOM带来了跨平台能力——同一个渲染描述层可以映射到DOM、原生组件、Canvas,React Native就是这么长出来的。说白了,虚拟DOM是描述UI的一种中间格式,它最值钱的是那层“抽象”,而不是diff本身。
1.2 性能账单上的三座大山
虚拟DOM有几个成本是绕不开的。第一,每次状态变化通常要从根组件或者触发组件开始重新执行render函数,生成一棵全新的虚拟节点树。哪怕只有一个很小的状态变了,整个组件子树都要重新跑一遍代码逻辑。React里那个经典的“父组件setState,子组件跟着重渲染”,就是这棵树的结构性成本。
第二,虚拟节点对象的创建和销毁会产生大量对象分配。一屏列表1000行,每行20个节点,一次全量渲染就是2万个对象。如果是每秒更新好几次的实时仪表盘,GC压力肉眼可见。我实习的时候维护过一个老React项目,打开性能面板,满屏的Minor GC标记,帧率掉到40fps以下,就是虚拟DOM对象在内存里频繁生灭造成的。
第三是水合(Hydration)成本。服务端渲染出的HTML要在浏览器端“复活”成可交互应用,React的经典做法是生成虚拟DOM树去对标现有DOM结构,分配事件处理器。这套逻辑在复杂页面里非常重,用户看着首屏HTML已经出来了,但页面要过好久才能点击,本质就是水合在拖后腿。业内搜“前端框架最新框架”“虚拟dom和diff算法面试”的高频词,背后对应的其实是同一个焦虑:虚拟DOM的性能模型开始跟不上产品复杂度了。
1.3 为什么偏要等到2026年才“背叛”
技术转向从来不是一夜之间的事。信号的底层思想——数据对象本身具有响应能力,谁读取了它就把谁记进依赖表,值一变直接通知订阅者——其实2010年以前就在Knockout、MobX这些库里出现过。但那时候这门手艺有几个硬伤:依赖收集容易漏、update时序容易乱、循环依赖难排查,社区吃过亏之后敬而远之。
真正让Signal翻身的,是过去五六年工程实践的成熟。如今前端页面交互密度大幅提升,高频实时数据、协作编辑器、数据可视化大屏成为刚需,而智能手表、车载中控、低端安卓机这类受限设备也要跑复杂前端。这些场景里,“更新一个数却要对比整棵组件树”的虚拟DOM模型太奢侈了,信号机制按需连接、精准更新的特性正好命中痛点。所谓2026年的集体转向,其实是信号工程化成熟之后,市场情绪的自然转折点。
2. 信号的本质:把“通知”变成一次精确的“广播”
如果让我用一句话解释信号,我会说:虚拟DOM是“定期告诉你全公司所有人的状态”,信号是“谁变了,就只给关心他的人发一条消息”。前者省心,但量大。后者需要先建立一套完整的订阅关系,可一旦建好,每次更新的成本几乎等于零。
2.1 最朴素的理解模型:灯泡和开关
想象一个控制面板,上面有一排灯泡,每个灯泡背后连着一个特定的开关。虚拟DOM的做法是:你不知道哪个开关管哪个灯,所以每次有人操作面板,你就把整排灯泡全部检查一遍,看哪个该亮哪个该灭。信号的做法是:施工时就把线一对一接好,某个开关按下去,只有对应的那盏灯收到电流。
放到前端场景里,开关就是“状态单元”,灯泡就是“读取了该状态的视图片段或副作用”。Signal框架在运行时维护一张依赖图:数据被读取时,读取方自动注册为依赖者;数据被写入时,直接向这一组依赖者发送更新通知。这就是为什么Signal更新粒度可以达到“单个组件内部的一段JSX表达式”这个级别,而不是整个组件重新渲染。
这套模型带来的直接好处是:内存里不再需要持久保存一棵庞大的虚拟DOM树作为“预演层”,真实DOM本身就是渲染结果。更新时不需要生成新对象去对比差异,改一个值就把对应的DOM节点文本直接改掉。好多人问“信号还要不要diff算法”,答案是:不需要了。依赖关系图本身就是“哪段UI依赖哪个状态”的精确索引,根本不存在“全量对比找差异”的阶段。
2.2 依赖收集背后的微机制:push和pull之争
信号系统在实现上分两个流派。一类是Push模型,数据变化后主动推送给所有下游依赖,通知链路长、中间产物多,但响应快。另一类是Pull模型,数据变化只标记一个“脏”状态,等真正需要读取结果时才向上游重新计算。Pull模型的极端代表是Solid,它的更新是“推拉结合”:状态变更先推送到边界组件,具体谁要更新由组件内部精确定位。
为什么这么折腾?因为纯Push模型遇到“一个状态被上千个计算属性依赖”时,会引发重复计算。Pure Pull模型则可能导致更新不及时。实际工程中大部分框架都在两个极端之间做平衡:既要用依赖表控制传播范围,又要用惰性求值减少无效计算。这里“多bit信号跨时钟域”那种硬件工程师爱聊的问题,其实前端也会遇到——不同数据源在不同时机更新时,如何保证信号在“同一帧”里的一致性?这个等会儿在第五章细说。
2.3 与虚拟DOM的三层底层差异
第一层差异是更新粒度。虚拟DOM的最小更新单位通常是一个组件函数,而信号的最小更新单位是一个数据绑定表达式。第二层差异是调度方式。虚拟DOM框架一般通过“一次事件循环内自动批量更新”来合并状态变化,信号框架同样有批处理,但它的依赖图天然知道哪些字段发生了变化,可以更精准地跳过无关更新。第三层差异是内存结构。虚拟DOM需要维护一棵持续的节点树,信号维护的则是一张依赖关系图,节点数量更少,连接关系更明确,回收更干净。
这些差异带来的结果不是“快了多少”,而是“省了多少”。省的是主线程上无意义的JS执行,省的是内存垃圾回收压力,省的是渲染进程里“莫名其妙被重渲染”的排查时间。我在实际项目里最强烈的感受是:用信号框架写的复杂表格,更新单元格时你几乎感觉不到主线程在工作,而用老虚拟DOM框架,2000行数据的表格随便改一格,CPU曲线至少要跳一下。
3. 2026年主流框架的“信号宣言”,一个比一个决绝
“前端框架最新框架”的热搜词在2026年基本指向同一件事:头部框架全部把信号作为一等公民。有的像Solid从第一天就长在信号上,有的是半路转型,比如Vue、Angular、Svelte,还有React靠编译器在虚拟DOM内部偷偷模拟信号行为。方向相同,打法各异。
3.1 Solid:从头到尾就是信号的形状
Solid是我认为最“纯粹”的信号框架。它的JSX不是虚拟DOM模板,而是一层编译语法糖,编译后直接生成对真实DOM的精确插入操作。比如这样:
import { createSignal } from "solid-js"; function Counter() { const [count, setCount] = createSignal(0); const double = () => count() * 2; return ( <div> <p>当前值:{count()}</p> <p>两倍值:{double()}</p> <button onClick={() => setCount(c => c + 1)}>加一</button> </div> ); }编译之后,这个组件里的count()调用点会直接变成一个数据绑定表达式。setCount时,Solid运行时直接定位到那两个<p>元素并更新textContent,不会重新执行整段JSX逻辑,也不会重建虚拟节点。我第一次跑Solid性能测试时,说实话有点恍惚:两千行的表格,点击单元格改一个值,录制的Performance面板几乎看不到长任务。后来仔细想想也合理,虚拟DOM省的是“避免全量DOM更新”的成本,信号省的是“连组件逻辑全量重跑都省了”,不在一个量级。
3.2 Vue:从响应式到Signal的自然回归
很多人不知道,Vue从2.0开始就有了一套完整的依赖追踪系统:data里的对象被getter/setter包裹,render函数里读取了谁就收集谁,数据变化精确触发相关组件更新。这套机制其实已经是信号的雏形。真正让Vue在信号阵营里站稳脚跟的,是3.4之后ref、computed、watchEffect这组API在编译器层(Vapor Mode)的支持。
Vapor模式绕过了虚拟DOM,模板直接编译成细粒度的指令更新。看一段直观的例子:
import { ref, computed } from "vue"; const count = ref(0); const double = computed(() => count.value * 2); watchEffect(() => { document.title = `当前:${count.value},两倍:${double.value}`; }); count.value++;这里computed自带缓存和依赖追踪,只有count变化时才会重新计算。Vapor模式跑这种代码时,更新路径上没有虚拟DOM,模板里的插值点是直接编译成指令的。Vue最聪明的点是保留了响应式心智模型,让老用户几乎无痛过渡。我周围不少Vue新手根本不知道模板里每个{{ }}背后其实是一个信号绑定,这种“无感知”恰恰说明信号机制可以做到多自然。
3.3 Angular、Svelte、Preact,各有各的“跳法”
Angular在v16之后正式引入signalAPI,v17又把变更检测机制全面重构为基于信号的细粒度更新。原先那个强悍却沉重的Zone.js全局检测机制,终于等到了一套更精确的替代方案。Svelte 5则通过Runes语法把信号机制放进了语言层,比如这么写:
<script> let count = $state(0); const double = $derived(count * 2); $effect(() => console.log(count)); </script> <p>{count} / {double}</p> <button onclick={() => count++}>加一</button>$state、$derived、$effect看着像框架魔法,本质就是信号的三件套:可变状态、派生值、副作用。Preact更直接,发布了独立的@preact/signals包,你甚至可以在React项目里局部引入信号机制,不改变组件树结构就能对高频模块精准优化。
3.4 React:嘴上说不要,身体还是诚实的
React官方至今没有把信号作为一等公民,因为它最强的资产是跨平台抽象层——虚拟DOM是连接Web、React Native、Canvas的统一描述格式。如果全面转向信号,这套跨平台模型就得推翻重来。但React不是没动作,React Compiler的思路是:在保留虚拟DOM外壳的前提下,自动识别数据依赖关系,为每个组件生成细粒度的缓存,避免无意义的重复渲染。换句话说,React试图用编译器把“手动优化”变成“自动完成”,在结果上接近信号的效果,但底层的虚拟DOM树仍然存在。
对这个路线我个人的判断是:它能让旧项目平滑升级,但性能上限不如原生信号框架。原因很简单,只要保留虚拟DOM树,GC压力和水合成本就不可能完全消失。React的价值在于“存量市场”和“跨平台生态”,如果哪天React Native彻底成熟、框架团队找到同时保住抽象层和细粒度更新的办法,那才是真正的技术奇点。
4. 实战对比:从性能数据到开发体验,一张表说清楚
我在做技术选型时最烦“无脑吹性能”,所以这里打算给出一套自己用的对比思路,不是推广某个框架,而是帮你在做决定时有个决策框架。
4.1 我的一套对比基准思路
我会同时跑三个场景:大表格高频更新(1000行×20列,每200ms随机改一个单元格)、实时数据流图表(每秒20个数据点追加,带动折线图重绘)、长列表滚动(1万条数据带复杂行组件)。指标不只看FPS,还要看长任务数量、JS堆内存波动、GC暂停时间。单独看FPS很容易被骗,因为现在大部分框架都能用“节流”或“并发调度”把掉帧藏起来,但内存曲线骗不了人。
实测下来,VirtualDOM框架在第一个场景里JS堆会周期性跳高,因为每次更新都会创建一轮临时虚拟节点对象。信号框架的曲线则平稳得多,它只更新绑定片段,没有大规模临时对象。这个差异在低端安卓机上会被放大:虚拟DOM框架容易触发频繁GC,GC一跑就卡,卡就掉帧。这不是框架BUG,而是内存模型的底层差异。
4.2 内存与CPU账本:谁动用了更多的“物资”
虚拟DOM的一次全量渲染消耗可以用一个很简单的公式估算:临时对象数≈组件数量×平均节点数×渲染频率。信号框架的临时对象数≈更新触发的绑定片段数,二者往往差一个数量级。我见过一个真实项目优化案例:把高频轮询模块从虚拟DOM迁移到信号后,JS堆峰值从45MB降到22MB,长任务从每帧都有变成几乎为0。这里面没有深奥的优化技巧,全靠“少建临时对象”。
CPU上的差异更直接。虚拟DOM框架每次更新都要经过“render生成新树→diff对比新旧树→提交到真实DOM”三阶段,哪怕优化再好,函数调用和对象遍历的开销都省不掉。信号框架省略了前两步,直接从“状态节点”走到“DOM绑定节点”。还是那句老话——不是信号快,是虚拟DOM干了很多这个场景里根本不需要干的活。
4.3 水合、并发与信号的新问题
SSR下的水合是虚拟DOM的经典痛点,信号框架的解法要干净得多:服务端输出的HTML自带标记,客户端初始化时只建立依赖关系,不需要重新执行一遍完整的组件树逻辑来对齐状态。所以用Solid或Vapor模式做巨长页面时,可交互时间能缩短不少。
不过信号也有自己的软肋:并发调度。React的并发特性可以在多任务之间切分CPU时间片,保证输入响应不卡顿。而大多数信号框架目前的更新是同步的,一次状态变更会顺着依赖图一路执行到底。遇到“一个状态变化带动上千个派生值重算”的场景时,同步更新可能导致主线程长时间占用。这也正是我在第五章要展开的信号“时序与完整性”问题。
4.4 开发体验:信号也不是没有代价
信号框架的强项是性能直接、代码可预测,但调试体验差异很大。虚拟DOM框架调试时看组件树就够了,DOM节点从哪来一目了然。信号框架的依赖关系图藏在运行时,普通DevTools里看不到,排查“谁更新了这段UI”时你得靠框架自带的调试工具。Solid的DevTools、Vue的组件面板都能展示依赖追踪路径,但用起来没有React DevTools那么无脑。
我个人的体验是:团队里只要有一两个人能讲清依赖收集原理,信号框架的效率会远超虚拟DOM;但如果整个团队都是一路“React式”开发思维走过来的,学习曲线会很陡。选型不能只看benchmark,团队认知成本和项目维护成本也得算进去。
5. 信号系统的“信号完整性”问题,借硬件思维破案
这一节的标题用了热搜词里那些硬件术语,是因为我发现前端信号系统里最隐蔽的几个坑,和模拟电路里的信号混叠、采样重建、噪声问题有惊人的同构性。别误会,这里不是讲硬件,而是借它的概念帮你建立一个新的排查直觉。
5.1 混叠失真:批量更新里看不见的中间态
硬件里的混叠是指采样频率不足时高频信号被伪装成低频信号。前端信号的混叠失真则表现为:中间态被副作用观察到了。比如你连续修改一个状态的三个字段,在虚拟DOM框架里,render函数只会在整批更新结束后执行一次,取到最终快照。但信号框架里,如果每个字段的setter都立即触发依赖通知,而中间又有人去读取这个状态,那么一个“只应该看到结果”的effect,就可能看到半成品中间态。
这种问题在“跨模块协同更新”时尤其严重。例如A模块先设置了一个“加载中”标志位,B模块则等待某个商品列表更新完成后再渲染价格。如果A和B直接订阅同一个信号对象,很可能在列表还没完全就绪时,B已经把中间态消费掉了。解决手段通常是框架自带的批量更新API(Solid的batch、Vue的nextTick、Angular的untracked辅助函数),把一组变更包成一个事务,等到副作用时机统一执行。写代码的时候要养成习惯:和计算属性相关的状态修改尽量放在同一个批量事务里。
5.2 采样与重建:调度器就是你的“采样器”
虚拟DOM框架在调度策略上更接近“过采样”:不管状态变了多少次,以帧为单位统一做一次全量diff。信号框架则是“事件驱动”:状态一变立刻精确更新,延迟最低,但更新次数可能更多。这里和“采样、抗混叠滤波与信号重建”很像——虚拟DOM用高成本过采样换平滑性,信号用精准采样换延迟和资源消耗。
实际开发中,我建议对高频数据设置自己的“抗混叠滤波器”。最简单的方式是加一个节流信号:原始信号高频变化,但下游只订阅一个每100ms更新一次的派生信号。这样既保留信号系统的低延迟,又不会让图表每秒重绘几十次。这个“手动重建”的思路,本质上就是软著里的数字低通滤波。
5.3 噪声与去噪:生产环境里的信号抖动排查
信号系统在生产环境最典型的问题是“抖动”:一个状态没变,但某段UI频繁更新,CPU曲线呈锯齿状。最常见原因有三个:一是在effect里读取了一个被其他effect频繁写入的信号,形成反馈回路;二是派生值没有缓存,每次读取都重算,导致本不该更新的订阅者被反复触发;三是依赖收集越界——比如在effect里读取了一个全局单例状态,而这个状态跟组件本身无关。
排查这类问题的思路和硬件“信号完整性”很像:先看发射端(谁在写入状态),再看传输通道(依赖图有没有意外连接),最后看接收端(谁在读取状态,读取时机是什么)。只要在依赖追踪路径上找到意料之外的中间节点,基本就锁定问题了。Solid的DevTools能展示effect的依赖树,Vue的renderEffect日志能打印依赖列表,Angular也有signal调试面板。不要在脑子里推,直接用图说话。
5.4 “多bit信号跨时钟域”的前端版:跨帧状态同步
前端同样存在“多个状态在不同时机更新,但合到一起才构成一个合法快照”的场景。举个例子:折线图同时展示温度和湿度两条曲线,温度信号先到,湿度信号后到,如果渲染逻辑读温度时湿度还是旧值,图例就会产生“错位帧”。这和硬件里跨时钟域同步问题非常像:不同步的数据在合并时出现逻辑毛刺。
我的处理经验是:为这类跨信号组合创建聚合派生状态,而不是让视图直接订阅多个独立信号。例如用createMemo把温度和湿度合并为一个“聚合数据对象”,下游只有这一个数据源。这样无论两个信号如何先后变化,视图永远只能看到聚合后的完整帧。
6. 迁移路线图与常见问题速查
聊这么多原理,最后给真正要动干活的同学一套可落地的方案。很多人问“要不要把现在项目重写成信号框架”,我的答案永远是先别急着重写,先计算一下你项目的核心痛点是不是状态更新效率。如果你的页面是内容型低交互,虚拟DOM那点性能开销根本无感知,重写纯属自找麻烦。如果你的页面有高频表格、实时图表、协同编辑之类的模块,那就按渐进式方案来。
6.1 渐进式迁移策略,四步走
第一步,在现有项目里找一个更新最频繁、性能最差的独立模块(常见的是数据监控面板、表格组件),单独用信号方案重构。React项目可以直接引入@preact/signals,在组件内部局部使用信号优化高频逻辑,不动整个框架。Vue项目则直接用自带ref/computed写好这个模块,Vapor模式已经能自动优化编译产物。
第二步,把模块对外接口设计为纯函数输入输出。模块内部用信号,对外只暴露状态和回调,让父组件感知不到信号的存在。这样万一这个模块后续要回退,成本也极低。
第三步,评估模块的性能收益,同时在代码评审里专门花一节课时间给团队讲信号机制。记住,架构迁移最大的风险不是技术而是团队认知。
第四步,如果收益足够,再考虑把信号机制推广到其他模块,最终形成一套团队自己的响应式状态管理规范。这个过程可能持续两三个季度,要留给开发同学充分踩坑的时间。
6.2 常见问题排查速查表
| 现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 信号更新了,UI不刷新 | 视图订阅的是派生值,但派生值没有被正确追踪 | 检查派生值是否在高频变化的依赖里,用DevTools追踪依赖图 | 用batch包住多状态更新,或直接订阅原始信号 |
| effect陷入死循环 | effect里读取并写入了同一组信号,形成反馈回路 | 打印effect依赖列表,看是否有意外依赖 | 把写入操作移到事件回调或untracked作用域 |
| 异步更新丢失 | 在异步回调里直接修改信号,外部批量事务破坏了同步语义 | 查看更新时序,确认修改发生在异步微任务中 | 用框架的批量API包裹异步回调,或改用不可变快照对象 |
| SSR水合后内容不一致 | 服务端和客户端首次渲染时对同一信号初始值不同 | 检查信号初始化是否依赖浏览器API | SSR阶段统一使用服务端快照作为信号初始值 |
| 内存持续增长 | 组件销毁时,effect或computed没有完成订阅清理 | 在DevTools的Memory面板里抓“Detached DOM” | 确保使用框架的onCleanup清理所有手动订阅 |
| 高频数据导致渲染帧率下降 | 下游UI直接订阅高频原始信号,没有做采样/节流 | 用Performance面板查看更新触发间隔 | 增加派生节流信号,低频采样后再渲染 |
6.3 一个真实项目的迁移复盘
去年我把一个工业数据大屏从虚拟DOM框架迁到信号方案,这个项目有实时仪表盘、上百个传感器通道、每秒数据刷新,原先滚动监控数据时经常卡到没法用。迁移范围只动了数据展示层,业务逻辑全部保留。前期最痛苦的不是改代码,而是团队成员始终想用“组件重渲染”的思维去套信号——一遇到数据不更新就习惯性去看shouldCompoentDidUpdate在哪里,根本不存在那个概念了。后来我给团队做了一次三小时的分享,专门讲清楚“谁读了信号谁就订阅了更新”这条核心原则,代码产出才真正顺起来。
迁移完成后,最直观的变化是CPU占用从长期30%降到了个位数,打包体积还小了约15%(去掉了虚拟DOM运行时的开销)。更重要的是,从此这类的面板类新需求,大家第一直觉不再是“加个定时器刷新”,而是“让数据自己找到它该去的DOM节点”。这种思维方式上的变化,比性能收益本身更值钱。
我个人在实际操作中的体会是:2026年的前端框架集体转向信号,说到底是在为一个更复杂的交互时代做底层基础设施。这不是说虚拟DOM一无是处,跨平台抽象、生态积累、行业心智,这些东西短时间内无法替代。但如果你正在做一个对性能敏感的复杂应用,或者你即将从零搭建一个长期演进的前端项目,认真研究一下信号机制绝对值得。最后送大家一句实在话:框架可以晚点跟风,但领域里最核心的“为什么变了”这个问题,早想清楚比晚想清楚好得多。