TC39 Signals 提案与框架里的信号:响应式终于要进语言层了吗
去年底我接手了一个有点别扭的项目:核心业务逻辑是一套用 Vue 3 写的状态管理,但有一块图表模块团队当初为了性能用了 Solid 单独渲染,两边通过事件总线手动同步。每次 Vue 这边的 ref 变了,要 emit 一个事件,Solid 那边监听后再 setX。来回二十几处同步代码,改一个字段经常忘了同步另一边,线上就出现"列表更新了图表没更新"的诡异 bug。
我盯着这堆胶水代码看了半天,心里冒出一个念头:Vue 的 ref、Solid 的 signal,本质上不就是同一个东西吗?为什么它们不能直接互相读?这个问题的答案,恰好就是 TC39 Signals 提案想解决的事。
每个框架都在重新发明响应式
先把这几年各家的响应式原语摆在一起看。它们的写法不一样,但模型惊人地相似。
Vue 3 的 ref 和 computed:
1import { ref, computed, watchEffect } from 'vue' 2 3const count = ref(0) 4const double = computed(() => count.value * 2) 5 6watchEffect(() => { 7 console.log('double is', double.value) 8}) 9 10count.value = 5 // 下一个 microtask 后打印 "double is 10" 11// 注意:watchEffect 默认 flush: 'pre',副作用推入微任务队列,不会在赋值的同一行同步执行
Solid 的 createSignal:
1import { createSignal, createMemo, createEffect } from 'solid-js' 2 3const [count, setCount] = createSignal(0) 4const double = createMemo(() => count() * 2) 5 6createEffect(() => { 7 console.log('double is', double()) 8}) 9 10setCount(5) // 打印 "double is 10"
Preact Signals:
1import { signal, computed, effect } from '@preact/signals-core' 2 3const count = signal(0) 4const double = computed(() => count.value * 2) 5 6effect(() => { 7 console.log('double is', double.value) 8}) 9 10count.value = 5 // 打印 "double is 10"
Angular 从 v16 开始也内建了 signals:
1import { signal, computed, effect } from '@angular/core' 2 3const count = signal(0) 4const double = computed(() => count() * 2) 5 6effect(() => { 7 console.log('double is', double()) 8}) 9 10count.set(5)
四套 API,四种取值写法(.value / 函数调用 / 函数调用 / 函数调用),但概念完全一致:有一个可写的"状态源",有一个根据它派生且自动缓存的"计算值",还有一个在依赖变化时重新执行的"副作用"。读取时自动建立依赖关系,写入时自动通知下游。
问题在于,这四套东西彼此不通。Vue 的 computed 不知道怎么订阅一个 Solid signal,反过来也一样。我那个项目里的事件总线,本质就是在手写"把 A 框架的响应式信号搬运到 B 框架"的桥。每个用了两种框架的团队都在重复造这座桥,而且各造各的、各有各的 bug。
TC39 Signals 提案想干什么
Signals 提案(目前在 TC39 的 Stage 1)的核心主张很简单:把这套响应式模型放进语言层,让所有库共享同一套底层信号,从而能互相读写、互相追踪依赖。
它的 API 表面非常小,核心只有三样东西。
Signal.State —— 可写的状态源:
1import { Signal } from 'signal-polyfill' 2 3const count = new Signal.State(0) 4 5count.get() // 读取,返回 0 6count.set(5) // 写入
Signal.Computed —— 惰性的派生计算:
1const double = new Signal.Computed(() => count.get() * 2) 2 3double.get() // 10,且会缓存,依赖不变时不重算
Signal.subtle.Watcher —— 底层的变更监听器:
1const w = new Signal.subtle.Watcher(() => { 2 // 注意:这个回调里不能读 signal,它只是个"脏了"的通知 3 queueMicrotask(() => { 4 for (const s of w.getPending()) s.get() // 重新求值 5 w.watch() // 重新武装监听 6 }) 7}) 8 9w.watch(double)
注意那个命名空间 Signal.subtle。提案故意把 Watcher 放进 subtle 里,意思是"这是给框架作者用的底层零件,不是给应用开发者天天调用的"。crypto.subtle 用的是同一个套路——告诉你这里有锋利的边缘,用之前想清楚。
为什么非得进语言层
直接的好处就是互操作。如果 Vue 的 ref、Solid 的 signal 底层都用同一个 Signal.State,那么一个 Vue computed 读到一个 Solid signal 时,依赖追踪能天然贯通——不需要事件总线,不需要手动同步。我那个项目里二十几处胶水代码,理论上可以全删掉。
更广的场景是库之间共享响应式状态。设想一个数据请求库(类似 TanStack Query)想暴露响应式的请求状态,今天它要么自己造一套信号、要么给每个框架出一个适配包(@tanstack/vue-query、@tanstack/solid-query……)。如果有了语言层的 Signal,它只需暴露 Signal.State,任何认识标准 Signal 的框架都能直接消费。适配层的维护成本是真金白银,这个提案瞄准的就是这块。
还有一个容易被忽略的点:开发者工具。今天每个框架的响应式调试都是各自的 devtools 插件。如果信号是语言原语,浏览器和工具链可以提供统一的依赖图可视化。
惰性求值、自动追踪和那个叫 glitch 的坑
提案里有几个设计细节值得说,因为它们直接决定了行为正确性。
惰性求值:Signal.Computed 不会在依赖变化时立刻重算,而是等到有人 get() 它的时候才算,并缓存结果。这和 Vue computed、Solid createMemo 的行为一致。好处是没人读的派生值永远不会浪费 CPU。
自动依赖追踪:在 Computed 的回调里调用某个 signal 的 get(),运行时会自动记录"我依赖了它"。不需要手动声明依赖数组——这是它和 React useMemo 那种手动依赖列表最大的体验差异。
拓扑排序避免 glitch:glitch 指的是在更新传播过程中,下游一度读到不一致的中间状态。经典例子:
1const a = new Signal.State(1) 2const b = new Signal.Computed(() => a.get() + 1) 3const c = new Signal.Computed(() => a.get() + b.get())
c 同时依赖 a 和 b,而 b 又依赖 a。当 a 从 1 变成 2,如果传播顺序错了,c 可能先用新的 a(2)和旧的 b(2)算出 4,然后等 b 更新到 3 再重算成 5——中间那个 4 就是 glitch。Signal 提案保证按依赖拓扑顺序求值,且因为是惰性的,c.get() 永远拿到的是一致的最终值 5,不会暴露中间态。这是一个数学性质的保证,不是"大概不会出问题"。
用 polyfill 自己实现一个 effect
提案里没有内建 effect。这点很多人第一次看会愣一下——既然信号是为了响应式,怎么连副作用都不给?答案是:effect 的调度策略(什么时候批量、什么时候刷新、用 microtask 还是 requestAnimationFrame)各框架诉求不同,提案把这个决策权留给框架,只提供 Watcher 这块原料。
下面是官方 polyfill README 里那个 effect 实现的精简版,我在项目里实际跑过:
1import { Signal } from 'signal-polyfill' 2 3let needsEnqueue = true 4 5const watcher = new Signal.subtle.Watcher(() => { 6 if (needsEnqueue) { 7 needsEnqueue = false 8 queueMicrotask(processPending) 9 } 10}) 11 12function processPending() { 13 needsEnqueue = true 14 for (const s of watcher.getPending()) { 15 s.get() // 触发重新求值,从而跑 effect 回调 16 } 17 watcher.watch() 18} 19 20export function effect(callback) { 21 let cleanup 22 const computed = new Signal.Computed(() => { 23 cleanup?.() 24 cleanup = callback() 25 }) 26 watcher.watch(computed) 27 computed.get() 28 return () => { 29 watcher.unwatch(computed) 30 cleanup?.() 31 } 32}
用起来就和别的框架一样了:
1const count = new Signal.State(0) 2const double = new Signal.Computed(() => count.get() * 2) 3 4const dispose = effect(() => { 5 console.log('double is', double.get()) 6 return () => console.log('cleanup before next run') 7}) 8 9count.set(5) 10// microtask 后打印:cleanup before next run / double is 10 11 12dispose() // 停止监听
这段代码里有几个关键点。Watcher 的回调本身不能读 signal——它只负责"有东西脏了,去排个任务"。真正的重新求值发生在 processPending 里的 s.get()。把 effect 实现成一个 Computed、再把它交给 Watcher 监听,是为了复用 Computed 的依赖追踪能力。needsEnqueue 那个开关则是最朴素的批量:一个 microtask 周期内多次写入只刷新一次。
我特意在 set 之后没有立刻看到输出,而是等到 microtask——这就是"没有内建 batching,调度由你定"的直接体现。Vue 帮你把这些都做好了,原生 Signal 把这层主动权还给了你,代价是你得自己写对。
它和框架信号的真实差异
理解了上面这些,再看它和现成框架的区别就清楚了。
- 没有内建 effect 和 batching。Vue 的
watchEffect、Solid 的createEffect都是开箱即用、自带调度的。原生 Signal 把这两块都留空,要你用Watcher自己拼。这意味着应用开发者基本不会直接用裸Signal,会用框架封装后的版本。 - 取值是方法调用
get(),不是.value属性。提案选了显式的方法调用,避免属性 getter 带来的隐式开销和歧义。 Signal.subtle命名空间的存在本身就是态度:这是基建,不是日常 API。
所以我想强调的是:这个提案不是要取代你的框架。它是想成为框架底下那一层共享的地基。Vue、Solid、Angular 的维护者都参与了讨论,长远目标是这些框架的响应式核心可以构建在同一个标准之上,从而天然互操作。框架的差异会上移到 API 设计、模板、编译优化这些层面,而不是底层模型本身。
现阶段我会怎么用
说回我那个 Vue + Solid 的项目。我有没有真的把它换成 Signals 提案?没有,至少现在没有。原因很实际。
提案还在 Stage 1。Stage 1 意味着委员会认可这个方向值得探索,但 API 细节、甚至要不要继续推进,都还可能变。把生产项目押在一个可能改 API 的标准上,不是负责任的做法。
那 polyfill 能用吗?signal-polyfill 包是能跑的,我也在一个内部小工具里拿它做过状态层,体验不错。但要清楚两件事:一是它会随提案变化而变化,二是性能上它毕竟是 JS 模拟,比不上原生实现(原生实现的意义之一就是引擎可以做底层优化)。
我现在的判断是:
- 生产业务代码:继续用框架自带的响应式,别自己上裸 Signal。
- 想做框架无关的状态逻辑库:可以认真评估用 polyfill,因为这正是提案的目标场景,未来迁移成本低。
- 学习和原型:非常值得花一下午用 polyfill 把上面那个
effect写一遍,理解依赖追踪和 glitch 之后,再去读 Vue 和 Solid 的源码会顺畅很多。
至于我那二十几处事件总线胶水,我最后的处理是把同步逻辑收敛到一个单一的 store 抽象后面,先把 bug 面积缩小。等哪天 Signals 进了 Stage 3、主流框架开始对接标准,那座手写的桥才有机会真正拆掉。技术提案最迷人也最磨人的地方就在这儿——你能清楚看到未来的样子,但还得在当下这套不完美的工具里把活干完。那个 Vue + Solid 的项目里,胶水代码暂时还得继续写,但至少我知道哪些代码是注定会被未来淘汰的,写起来心里有数。