TC39 Signals 提案:JavaScript 原生响应式的终局之战
TC39 Signals 提案:JavaScript 原生响应式的终局之战
每个前端框架都在做同一件事:管理状态变化,并高效地更新 UI。Vue 有 ref/reactive,Solid 有 createSignal,Angular 有 signal(),Svelte 有 Runes,Preact 有 @preact/signals。
它们各自精彩,但本质上是在用户态反复造同一个轮子。
现在,TC39(JavaScript 标准委员会)正在推进一个提案,要把 Signals 写入语言标准。如果成功,这将是 JavaScript 自 Promise 之后最重要的响应式原语。
一、为什么需要原生 Signals?
碎片化的现状
// Vueconst count = ref(0)const doubled = computed(() => count.value * 2)watchEffect(() => console.log(doubled.value))
// Solidconst [count, setCount] = createSignal(0)const doubled = createMemo(() => count() * 2)createEffect(() => console.log(doubled()))
// Angularconst count = signal(0)const doubled = computed(() => count() * 2)effect(() => console.log(doubled()))
// Preact Signalsconst count = signal(0)const doubled = computed(() => count.value * 2)effect(() => console.log(doubled.value))四种语法,同一件事。开发者在不同框架间切换要重学 API,库作者要为每个框架写适配层,工具链要分别理解每种响应式系统。
更深层的问题是互操作性。一个用 Vue ref 写的工具库,在 Solid 项目里用不了。一个用 Preact Signals 写的状态管理方案,在 Angular 里需要 wrapper。响应式成了框架的”方言”,而不是语言的”普通话”。
性能天花板
用户态的响应式系统受限于 JavaScript 引擎的优化策略。引擎不知道你的 ref 是响应式的,不会为它做特殊优化。而如果 Signals 成为语言原语,引擎可以:
- 在 JIT 编译时识别 Signal 的读写模式
- 优化依赖追踪的内存布局
- 将 Signal 的变更通知内联到热路径中
- 利用引擎内部的对象模型,避免闭包开销
二、TC39 Signals 提案长什么样
提案目前处于 Stage 1(2024 年 4 月进入),由 Daniel Ehrenberg(Igalia,前 TC39 联合主席)和 Rob Eisenberg(曾创建 Aurelia)共同推动。
核心 API
// 创建一个可写 Signalconst count = new Signal.State(0)
// 读取count.get() // 0
// 写入count.set(1)
// 创建 Computed Signal(自动追踪依赖)const doubled = new Signal.Computed(() => count.get() * 2)doubled.get() // 2
// 创建 Effect(副作用,非提案核心,由框架层实现)// 提案只定义 State 和 Computed,Effect 交给用户态设计哲学:Push-Pull 混合模型
这是提案最精妙的部分。Signals 的更新采用 Push-Pull 混合策略:
State 变化 → Push 通知(标记 dirty)→ 读取时 Pull 计算(lazy evaluation)const firstName = new Signal.State('张')const lastName = new Signal.State('三')const fullName = new Signal.Computed(() => { console.log('computing fullName...') return firstName.get() + lastName.get()})
firstName.set('李')lastName.set('四')// 此时 fullName 只是被标记为 dirty,没有重新计算// 直到有人读取它:fullName.get() // 'computing fullName...' 只打印一次// 输出 '李四'为什么这很重要?考虑一个复杂的依赖图:
A → B → D → FA → C → E → F如果 A 变了,纯 Push 模型会立即触发 B、C、D、E、F 的重新计算,即使 F 最终没人读取。纯 Pull 模型需要在每次读取 F 时遍历整个依赖图检查是否过期。
Push-Pull 混合模型的做法是:
- A 变了 → Push:标记 B、C 为 dirty(不计算)
- 有人读取 F → Pull:从 F 开始,沿依赖链向上检查哪些真正需要重算
- 按拓扑顺序计算,跳过没变化的分支
这个策略和 Vue 3.5 的优化思路不谋而合——避免不必要的计算,但确保读到的值总是最新的。
Watcher API:框架的接入点
提案还定义了 Signal.subtle.Watcher,这是框架用来”桥接” Signals 和渲染系统的低级 API:
const watcher = new Signal.subtle.Watcher(() => { // 当被监听的 Signal 变 dirty 时触发 // 框架在这里调度更新(如 queueMicrotask、requestAnimationFrame 等) queueMicrotask(flushUpdates)})
// 监听一组 Computed Signalswatcher.watch(computedA, computedB)
// 获取哪些 Signal 变 dirty 了const dirtySignals = watcher.getPending()
// 手动触发脏检查for (const signal of dirtySignals) { signal.get() // 触发重新计算}这个设计非常聪明——提案不规定调度策略。同步更新、微任务批量更新、还是 requestAnimationFrame 更新?交给框架决定。Signals 只负责”什么变了”,不管”什么时候更新 UI”。
三、各框架的态度
Vue:积极拥抱
Vue 团队从提案早期就深度参与讨论。事实上,Vue 的响应式系统(@vue/reactivity)被用作提案的参考实现之一。
尤雨溪在多个场合表达了对 Signals 标准化的支持,并提出了关键的设计建议——比如 Computed Signal 应该是 lazy 的(和 Vue 的 computed 一致),以及不应该在标准层面定义 Effect(因为不同框架的调度策略不同)。
未来的 Vue 可能会:
// 可能的未来:Vue 的 ref 底层使用原生 Signalimport { ref } from 'vue'
const count = ref(0)// 内部实现:new Signal.State(0) + Vue 的 .value 语法糖
// 或者直接使用原生 Signalconst raw = new Signal.State(0)// Vue 的模板编译器能直接理解原生 SignalSolid:精神共鸣
Solid.js 本身就是”Signals-first”的框架,Ryan Carniato(Solid 作者)是提案的核心参与者。Solid 的 createSignal 和提案的 Signal.State 语义几乎一致。
Angular:全面转向
Angular 在 v16 引入 Signals 后,已经将整个框架的未来押在 Signals 上。Angular 团队是提案的积极推动者,希望标准化能让 Angular 的 Signals 获得引擎级别的性能优化。
Svelte:观望
Svelte 的响应式深度依赖编译器,和运行时 Signals 的路线有本质差异。但 Rich Harris 也承认,如果 Signals 成为标准,Svelte 的编译输出可以使用原生 Signal 而非自定义的运行时代码。
四、深入:依赖追踪的实现原理
理解 Signals 的关键在于理解自动依赖追踪。这不是魔法,而是一个精巧的栈结构:
// 简化的实现原理let currentComputation = nullconst computationStack = []
class State { #value #subscribers = new Set()
constructor(initialValue) { this.#value = initialValue }
get() { // 如果当前有 Computed 在执行,记录依赖 if (currentComputation) { this.#subscribers.add(currentComputation) currentComputation.dependencies.add(this) } return this.#value }
set(newValue) { if (Object.is(this.#value, newValue)) return this.#value = newValue // 通知所有订阅者:你 dirty 了 for (const sub of this.#subscribers) { sub.markDirty() } }}
class Computed { #fn #value #dirty = true dependencies = new Set()
constructor(fn) { this.#fn = fn }
get() { // 同样参与依赖追踪 if (currentComputation) { // Computed 也可以被其他 Computed 依赖 }
if (this.#dirty) { // 清除旧依赖,重新收集 this.cleanup()
// 入栈:让内部的 Signal.get() 知道当前是谁在读 computationStack.push(currentComputation) currentComputation = this
try { this.#value = this.#fn() } finally { // 出栈 currentComputation = computationStack.pop() }
this.#dirty = false }
return this.#value }
markDirty() { this.#dirty = true // 继续向下游传播 dirty 标记 }
cleanup() { for (const dep of this.dependencies) { dep.#subscribers.delete(this) } this.dependencies.clear() }}核心技巧是执行时收集依赖。当 Computed 的回调函数执行时,所有被 .get() 的 State 都会自动注册为依赖。这就是为什么你不需要手动声明依赖列表(不像 React 的 useEffect 需要 deps 数组)。
动态依赖
这个机制天然支持动态依赖:
const showDetails = new Signal.State(false)const summary = new Signal.State('摘要')const details = new Signal.State('详情内容...')
const display = new Signal.Computed(() => { if (showDetails.get()) { return details.get() // 依赖 showDetails + details } return summary.get() // 依赖 showDetails + summary})当 showDetails 从 false 变为 true 时,display 重新执行,依赖自动从 [showDetails, summary] 变为 [showDetails, details]。之后 summary 再怎么变,都不会触发 display 重算。
这比 React 的 useMemo(() => ..., [dep1, dep2]) 优雅得多——你不需要操心依赖列表,运行时帮你精确追踪。
五、Signals 与现有模式的对比
vs React Hooks
// React:手动依赖,闭包陷阱function Counter() { const [count, setCount] = useState(0) const doubled = useMemo(() => count * 2, [count]) // 手动 deps
useEffect(() => { document.title = `Count: ${count}` return () => { /* cleanup */ } }, [count]) // 忘了加 count?bug。
return <button onClick={() => setCount(c => c + 1)}>{doubled}</button>}
// Signals:自动依赖,无闭包陷阱const count = new Signal.State(0)const doubled = new Signal.Computed(() => count.get() * 2) // 自动追踪
// Effect(假设由框架提供)effect(() => { document.title = `Count: ${count.get()}` // 自动追踪依赖,自动 cleanup})React Hooks 的心智模型是”每次渲染都是一次函数调用”,闭包捕获的是某次渲染时的值。Signals 的心智模型是”值是活的引用”,任何时候 .get() 都拿到最新值。
vs RxJS / Observable
// RxJS:基于流,组合操作符const count$ = new BehaviorSubject(0)const doubled$ = count$.pipe(map(v => v * 2))doubled$.subscribe(v => console.log(v))
// Signals:基于值,同步求值const count = new Signal.State(0)const doubled = new Signal.Computed(() => count.get() * 2)RxJS 是基于事件流的——适合处理异步事件序列(HTTP 请求、WebSocket、用户输入流)。Signals 是基于同步值的——适合管理 UI 状态。它们解决的是不同层面的问题,未来大概率会共存。
六、对前端开发的实际影响
1. 跨框架组件库成为可能
// 一个不依赖任何框架的状态管理库export function createTodoStore() { const todos = new Signal.State([]) const filter = new Signal.State('all')
const filtered = new Signal.Computed(() => { const list = todos.get() switch (filter.get()) { case 'active': return list.filter(t => !t.done) case 'done': return list.filter(t => t.done) default: return list } })
const count = new Signal.Computed(() => filtered.get().length)
return { todos, filter, filtered, count, add(text) { todos.set([...todos.get(), { text, done: false }]) }, toggle(index) { const list = [...todos.get()] list[index] = { ...list[index], done: !list[index].done } todos.set(list) } }}
// 在 Vue 中使用// 在 Solid 中使用// 在 Angular 中使用// 同一份代码,零适配2. 状态管理库简化
当 Signals 成为标准,Pinia、Zustand、Jotai 等状态管理库的核心复杂度会大幅降低——它们不再需要自己实现响应式原语,只需在原生 Signals 上提供更好的开发体验(DevTools、持久化、中间件等)。
3. Web Components 终于有了好用的状态方案
Web Components 一直缺少好用的响应式方案。原生 Signals 填补了这个空白:
class MyCounter extends HTMLElement { #count = new Signal.State(0) #display = new Signal.Computed(() => `Count: ${this.#count.get()}`)
connectedCallback() { // 用框架提供的 effect,或自己实现 this.#render() }
#render() { // 利用 Watcher API 监听变化并更新 DOM const watcher = new Signal.subtle.Watcher(() => { this.shadowRoot.querySelector('span').textContent = this.#display.get() }) watcher.watch(this.#display) }}七、时间线与展望
| 阶段 | 状态 | 预期 |
|---|---|---|
| Stage 1 | ✅ 已通过(2024.04) | 提案被 TC39 正式接受 |
| Stage 2 | 🔄 讨论中 | API 设计基本稳定 |
| Stage 3 | ⏳ 待定 | 浏览器开始试验性实现 |
| Stage 4 | ⏳ 待定 | 写入 ECMAScript 标准 |
即使提案最终落地可能还需要 2-3 年,但它的影响已经开始了。各框架都在向 Signals 模型靠拢,polyfill(signal-polyfill)已经可用。理解 Signals 的思想,现在就是最好的时机。
总结
TC39 Signals 提案代表了前端响应式系统的”大统一理论”。它不是要取代现有框架,而是要为所有框架提供一个共同的底层原语——就像 Promise 统一了异步编程,Signals 要统一响应式编程。
对于前端开发者,我的建议是:
- 理解 Signals 的核心概念——State、Computed、自动依赖追踪、Push-Pull 模型
- 关注你所用框架的 Signals 集成计划——Vue、Angular、Solid 都在积极对接
- 尝试
signal-polyfill——提前感受标准 API 的手感 - 思考”框架无关”的代码架构——当响应式不再是框架的”私有”能力,你的代码可以更自由
JavaScript 正在从”需要框架才能响应式”走向”语言原生就是响应式的”。这是一个不可逆的方向,而我们正站在这个转折点上。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!