TC39 Signals 提案与框架里的信号:响应式终于要进语言层了吗

去年底我接手了一个有点别扭的项目:核心业务逻辑是一套用 Vue 3 写的状态管理,但有一块图表模块团队当初为了性能用了 Solid 单独渲染,两边通过事件总线手动同步。每次 Vue 这边的 ref 变了,要 emit 一个事件,Solid 那边监听后再 setX。来回二十几处同步代码,改一个字段经常忘了同步另一边,线上就出现"列表更新了图表没更新"的诡异 bug。

我盯着这堆胶水代码看了半天,心里冒出一个念头:Vue 的 ref、Solid 的 signal,本质上不就是同一个东西吗?为什么它们不能直接互相读?这个问题的答案,恰好就是 TC39 Signals 提案想解决的事。

每个框架都在重新发明响应式

先把这几年各家的响应式原语摆在一起看。它们的写法不一样,但模型惊人地相似。

Vue 3 的 refcomputed

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 同时依赖 ab,而 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 的项目里,胶水代码暂时还得继续写,但至少我知道哪些代码是注定会被未来淘汰的,写起来心里有数。