JavaScript 响应式系统,从框架实现到 ES TC39 Signals
2025年5月7日Elecmonkey
"响应式数据"
传统的 UI 开发,开发者通常采用一种"命令"的画风去更新 UI——命令某个具体的组件,做一个具体的更改。不管是客户端开发,使用 Qt:
cpp1QPushButton *button = new QPushButton("Click me"); 2connect(button, &QPushButton::clicked, this, [this]() { 3 button->setText("Clicked!"); 4});
还是 Web 开发,原生的操作 DOM:
js1const button = document.querySelector("button"); 2button.addEventListener("click", () => { 3 button.textContent = "Clicked!"; 4});
在那个年代风靡前端的工具是 jQuery,用它来实现可以少些很多固定的 DOM 操作:
js1$("button").click(function() { 2 $(this).text("Clicked!"); 3});
当界面较为简单的时候,代码仍然是一目了然的。但随着界面数据越来越多、更新逻辑越来越复杂,代码会有一个明显的特点——更新 UI 的逻辑和数据本身的逻辑耦合在一起。但其实 UI 和数据是不同的东西——同一套数据逻辑可能渲染在不同的 UI 平台上。例如我要实现一个 Web 应用,还要用 Qt 实现一个桌面客户端,或许还要写一个小程序,再用 Kotlin 实现一个 Android APP。它们的数据逻辑是相同的,是 UI 无关的。
于是前端就诞生了各种各样的 UI 框架,负责接管 UI 的更新逻辑,我们开发者则只需要关心数据逻辑本身——这是我们实际要实现的业务逻辑。这种分离为很多事情带来了可能,比如遵循相同的 API,React 的底层也可以渲染在移动设备上(所以我们有了 React Native)。
除了包装大量的 DOM 操作之外,框架们不约而同的采用了"声明式"的画风,用"声明"来描述 UI 的"样子"。至于这些数据如何渲染在 DOM 上,则被框架全权接管。一种名为"响应式数据"的概念被提出,它允许我们声明式地描述数据之间的依赖关系,当数据发生变化时,所有依赖于这些数据的计算和 UI 都会自动更新。这种模式不仅让代码更加清晰,也让状态管理变得更加可预测。
在过去的十年里,前端框架在响应式系统的实现上经历了多次迭代。现在我们熟悉的响应式系统的核心思想在 2010 年的开源 JavaScript Web 框架 Knockout.js 中首次受到关注,随后经 Angular 发扬光大。在此后的几年中,已经创建了许多变体和实现,从 Vue2 的 Object.defineProperty
到 Vue3 的 Proxy
,从 SolidJS 的细粒度响应式,每个框架都在探索更高效、更易用的响应式方案。而最近,TC39 委员会提出了 Signals 提案,试图为 JavaScript 提供标准化的响应式原语,这可能会彻底改变前端框架的响应式实现方式。
主流框架实现
Vue 2
Vue 2 的响应式系统是基于 Object.defineProperty
实现的。这是 Javascript 标准提供的 API,它允许我们拦截对象属性的访问和修改操作。
js1const data = { 2 count: 0 3} 4 5Object.defineProperty(data, 'count', { 6 get: () => data.count, 7 set: (value) => data.count = value 8})
所谓响应式系统,就是希望我的某一个值发生改变,与它有关的值就会自动重新计算(像 Excel 表格一样,,当 A1 的值发生变化时,A2 的值会自动重新计算),与它有关的"动作"也会自动重新执行。
其实"更新与它(响应式数据)有关的值"(计算属性)也是一种与它有关的"动作"(副作用)——我们的核心目标是"自动的收集(依赖收集)和执行(派发更新)这些动作"。
Vue 2 的响应式系统核心是依赖收集和派发更新两个机制:
当组件渲染时,会访问响应式数据,触发 getter。Vue 会在 getter 中收集当前正在渲染的组件作为依赖。
js1// 依赖收集示例 2 3let activeEffect = null; 4function effect(fn) { 5 activeEffect = fn; 6 // 执行 effect,这会触发响应式数据的 getter 7 fn(); 8 activeEffect = null; 9} 10 11function defineReactive(obj, key, val) { 12 const deps = new Set(); // 存储依赖的桶🪣 13 14 Object.defineProperty(obj, key, { 15 get() { 16 // 收集依赖,进桶吧您嘞 17 // 这就是为什么副作用一定要用effect包起来——Vue才能拿到这个函数 18 deps.add(activeEffect); 19 return val; 20 }, 21 set(newVal) { 22 if (val === newVal) return; 23 val = newVal; 24 // 派发更新:挨个儿调用 25 deps.forEach(effect => effect()); 26 } 27 }); 28} 29 30// 使用示例,下面的写法写过Vue太熟悉了 31const data = { count: 0 }; 32defineReactive(data, 'count', 0); 33 34// 创建一个 effect,它会自动收集依赖 35effect(() => { 36 console.log('count is:', data.count); 37}); 38 39// 修改数据,触发更新 40data.count = 1; // 输出: count is: 1
当响应式数据发生变化时,触发 setter,Vue 会派发更新,通知所有依赖该数据的组件重新渲染。
js1// 简化的组件更新机制 2function createComponent(options) { 3 const component = { 4 render() { 5 // 组件渲染函数 6 activeEffect = () => { 7 // 重新渲染组件 8 component.update(); 9 }; 10 // 执行渲染,触发依赖收集 11 return options.render.call(this); 12 }, 13 update() { 14 // 更新 DOM 15 } 16 }; 17 return component; 18}
Vue 2 的实现复杂得多,但差不多是这么个意思。在 Vue 2 中常见以下用法:
VueTemplate1<template> 2 <div> 3 <p>Count: {{ count }}</p> 4 <p>Doubled: {{ doubled }}</p> 5 <button @click="increment">Increment</button> 6 </div> 7</template>
Script1<script> 2export default { 3 data() { 4 return { 5 count: 0 6 } 7 }, 8 computed: { 9 doubled() { 10 return this.count * 2 11 } 12 }, 13 methods: { 14 increment() { 15 this.count++ 16 } 17 } 18} 19</script>
Vue 3 with Composition API
Vue 2 使用的 Object.defineProperty
有以下限制:
- 无法检测对象属性的添加或删除
- 无法检测数组索引的直接修改和长度的变化
- 需要递归遍历对象的所有属性,对深层嵌套对象进行响应式转换
Vue 3 的响应式系统是基于 Proxy
实现的。Proxy
API 于 ES6 中被引入,它提供了更强大的对象拦截能力,可以拦截对象的几乎所有操作,解决了 Object.defineProperty
的诸多限制——例如不能监听新增属性、删除属性,以及数组索引的变更等。
js1const data = { 2 count: 0 3} 4 5const proxy = new Proxy(data, { 6 get: (target, prop) => target[prop], 7 set: (target, prop, value) => target[prop] = value 8})
Vue 3 引入了 Composition API 和 Setup 语法糖,可以更加优雅的把"属于相同部分的逻辑"聚合在一起。
VueTemplate1<template> 2 <div> 3 <p>Count: {{ count }}</p> 4 <p>Doubled: {{ doubled }}</p> 5 <button @click="increment">Increment</button> 6 </div> 7</template>
Script1<script setup> 2import { ref, computed } from 'vue' 3 4const count = ref(0) 5const doubled = computed(() => count.value * 2) 6 7function increment() { 8 count.value++ 9} 10</script>
SolidJS & Signals
Solid 说,我们核心的过程就是把用到这个数据的函数收集起来(依赖收集),然后当数据变化的时候,执行这些函数(派发更新)。那其实 Proxy
和 Object.defineProperty
这俩 API 只是给我们包了一下,让开发者能用接近原生 JS 变量的使用风格去写代码,这俩并不是必须的。我们直接把这个思路做出来,但不进行任何包装,做一个裸金属风格的响应式系统。你不管是读还是写都要调用我框架给你的函数,既然函数是我给的,那我在里面"横插一脚"去做依赖收集、派发更新不是随随便便的事儿。
同时 Solid 还使用 JSX ,于是有了这样一个很 React 味儿的写法:
jsx1import { createSignal, createEffect } from 'solid-js' 2 3function Counter() { 4 const [count, setCount] = createSignal(0) 5 const doubled = () => count() * 2 6 7 createEffect(() => { 8 console.log(`Count: ${count()}, Doubled: ${doubled()}`) 9 }) 10 11 return ( 12 <div> 13 <p>Count: {count()}</p> 14 <p>Doubled: {doubled()}</p> 15 <button onClick={() => setCount(c => c + 1)}>Increment</button> 16 </div> 17 ) 18}
响应式系统核心概念
到这里,我们大概就可以抽象出各种响应式系统的核心概念了——我们需要一个Signal,它是一个可读可写的值,并且当它发生变化时,会有Computed计算属性发生变化 + 自动触发副作用。
Signal(信号)
typescript1type Signal<T> = [() => T, (value: T) => void]; 2 3function createSignal<T>(value: T): Signal<T> { 4 const subscribers = new Set<Effect>(); 5 6 function read(): T { 7 if (currentEffect) { 8 subscribers.add(currentEffect); 9 } 10 return value; 11 } 12 13 function write(newValue: T): void { 14 if (value === newValue) return; 15 value = newValue; 16 const effectsToRun = new Set(subscribers); 17 effectsToRun.forEach(effect => { 18 try { 19 effect(); 20 } catch (e) { 21 console.error('Error running effect:', e); 22 } 23 }); 24 } 25 26 return [read, write]; 27}
Signal是响应式系统的基础,它包含:
- 一个值
- 一个订阅者集合
- 读取方法(自动收集依赖)
- 写入方法(通知所有订阅者)
Effect(副作用)
typescript1type Effect = () => void; 2let currentEffect: Effect | null = null; 3 4function effect(fn: Effect): void { 5 const wrappedEffect = () => { 6 currentEffect = wrappedEffect; 7 try { 8 fn(); 9 } finally { 10 currentEffect = null; 11 } 12 }; 13 wrappedEffect(); 14}
Effect是响应式系统的执行单元:
- 在创建时执行一次
- 在其依赖的信号变化时重新执行
- 通过全局变量追踪当前正在"被收集"的副作用
Computed(计算值)
typescript1function computed<T>(fn: () => T): () => T { 2 const [get, set] = createSignal<T>(fn()); 3 4 effect(() => { 5 const newValue = fn(); 6 set(newValue); 7 }); 8 9 return get; 10}
Computed是基于其他信号派生的值:
- 在创建时计算初始值
- 当依赖的信号变化时自动重新计算
TC39 Signals提案
好,现在问题来了。这么多框架都手工实现了一套相似的功能,说明这是一个很有需求量的功能。Proxy
进入 ES 标准,大大的简化了 Vue 3 的实现,增强了功能。Signal 这个东西非常好,如果我们让它进入 ES 标准,那框架设计的时候只需要包装一下 API 就行了。
TC39 Signals 提案旨在为 JavaScript 提供标准化的信号原语,目前处于 TC39 提案 Stage 1 阶段。
目前提案包含三个核心概念:
Signal.State
- 可写状态信号Signal.Computed
- 派生计算信号Signal.Watcher
- 用于观察信号变化的低级API
提案给出了一个光明的前景——
js1// 创建一个可写的状态信号,初始值为0 2const counter = new Signal.State(0); 3 4// 创建一个计算信号,依赖于counter 5const isEven = new Signal.Computed(() => (counter.get() & 1) == 0); 6 7// 创建另一个计算信号,依赖于isEven 8const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd"); 9 10// 库或框架可以基于Signal原语定义effect 11declare function effect(cb: () => void): (() => void); 12 13// 创建一个effect,当parity变化时更新DOM 14effect(() => element.innerText = parity.get()); 15 16// 模拟外部更新counter... 17// 每秒counter加1,这会触发一系列更新: 18// 1. counter变化 19// 2. isEven重新计算 20// 3. parity重新计算 21// 4. effect重新执行,更新DOM 22setInterval(() => counter.set(counter.get() + 1), 1000);
目前该提案已有一个 polyfill 实现:signal-polyfill。