TC39 Signals 提案:JavaScript 原生响应式的终局之战

2821 字
14 分钟
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?#

碎片化的现状#

// Vue
const count = ref(0)
const doubled = computed(() => count.value * 2)
watchEffect(() => console.log(doubled.value))
// Solid
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(doubled()))
// Angular
const count = signal(0)
const doubled = computed(() => count() * 2)
effect(() => console.log(doubled()))
// Preact Signals
const 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#

// 创建一个可写 Signal
const 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 → F
A → C → E → F

如果 A 变了,纯 Push 模型会立即触发 B、C、D、E、F 的重新计算,即使 F 最终没人读取。纯 Pull 模型需要在每次读取 F 时遍历整个依赖图检查是否过期。

Push-Pull 混合模型的做法是:

  1. A 变了 → Push:标记 B、C 为 dirty(不计算)
  2. 有人读取 F → Pull:从 F 开始,沿依赖链向上检查哪些真正需要重算
  3. 按拓扑顺序计算,跳过没变化的分支

这个策略和 Vue 3.5 的优化思路不谋而合——避免不必要的计算,但确保读到的值总是最新的。

Watcher API:框架的接入点#

提案还定义了 Signal.subtle.Watcher,这是框架用来”桥接” Signals 和渲染系统的低级 API:

const watcher = new Signal.subtle.Watcher(() => {
// 当被监听的 Signal 变 dirty 时触发
// 框架在这里调度更新(如 queueMicrotask、requestAnimationFrame 等)
queueMicrotask(flushUpdates)
})
// 监听一组 Computed Signals
watcher.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 底层使用原生 Signal
import { ref } from 'vue'
const count = ref(0)
// 内部实现:new Signal.State(0) + Vue 的 .value 语法糖
// 或者直接使用原生 Signal
const raw = new Signal.State(0)
// Vue 的模板编译器能直接理解原生 Signal

Solid:精神共鸣#

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 = null
const 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
})

showDetailsfalse 变为 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 要统一响应式编程。

对于前端开发者,我的建议是:

  1. 理解 Signals 的核心概念——State、Computed、自动依赖追踪、Push-Pull 模型
  2. 关注你所用框架的 Signals 集成计划——Vue、Angular、Solid 都在积极对接
  3. 尝试 signal-polyfill——提前感受标准 API 的手感
  4. 思考”框架无关”的代码架构——当响应式不再是框架的”私有”能力,你的代码可以更自由

JavaScript 正在从”需要框架才能响应式”走向”语言原生就是响应式的”。这是一个不可逆的方向,而我们正站在这个转折点上。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TC39 Signals 提案:JavaScript 原生响应式的终局之战
https://boke.hackerdream.xyz/posts/tc39-signals-proposal/
作者
晴天
发布于
2026-03-29
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录