深入响应式系统
Vue 最具特色的功能之一就是它那种不显眼的响应式系统。组件状态由响应式的 JavaScript 对象组成。当你修改它们时,视图也会更新。它让状态管理变得简单直观,但了解它的工作原理也很重要,这样才能避免一些常见陷阱。在这一节中,我们将深入了解 Vue 响应式系统的一些底层细节。
什么是响应式?
这些年这个术语在编程中经常出现,但人们说它时到底指什么呢?响应式是一种编程范式,它允许我们以声明式的方式来应对变化。人们通常会给出的经典示例,因为它非常典型,就是一个 Excel 电子表格:
| A | B | C | |
|---|---|---|---|
| 0 | 1 | ||
| 1 | 2 | ||
| 2 | 3 |
这里单元格 A2 通过公式 = A0 + A1 定义(你可以点击 A2 来查看或编辑公式),所以电子表格给出的是 3。没什么意外的。但如果你更新 A0 或 A1,你会注意到 A2 也会自动更新。
JavaScript 通常不是这样工作的。如果我们用 JavaScript 写一个类似的例子:
js
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // 仍然是 3当我们修改 A0 时,A2 不会自动变化。
那么我们如何在 JavaScript 中实现这一点呢?首先,为了重新运行更新 A2 的代码,我们把它包装到一个函数里:
js
let A2
function update() {
A2 = A0 + A1
}然后,我们需要定义几个术语:
update()函数会产生一个副作用,简称effect,因为它修改了程序的状态。A0和A1被视为这个 effect 的依赖,因为它们的值被用来执行这个 effect。这个 effect 被称为其依赖的订阅者。
我们需要的是一个神奇的函数:只要 A0 或 A1(依赖)变化,就能调用 update()(effect):
js
whenDepsChange(update)这个 whenDepsChange() 函数有以下任务:
跟踪变量何时被读取。例如,在求值表达式
A0 + A1时,A0和A1都会被读取。如果在某个 effect 正在运行时读取了一个变量,就让这个 effect 成为该变量的订阅者。例如,因为在执行
update()时读取了A0和A1,所以第一次调用后,update()会成为A0和A1的订阅者。检测变量何时被修改。例如,当
A0被赋予新值时,通知它所有订阅的 effect 重新运行。
Vue 中的响应式如何工作
像示例中那样,我们其实没法真正跟踪局部变量的读取和写入。在原生 JavaScript 中没有任何机制可以做到这一点。不过我们可以拦截对象属性的读取和写入。
在 JavaScript 中,有两种方式可以拦截属性访问:getter / setters 和 Proxies。由于浏览器支持的限制,Vue 2 只使用 getter / setters。在 Vue 3 中,响应式对象使用 Proxies,而 refs 使用 getter / setters。下面是一些伪代码,用来说明它们是如何工作的:
js
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
}
})
}
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
}
}
return refObject
}TIP
这里以及下面的代码片段旨在以尽可能简单的形式解释核心概念,因此省略了许多细节,并忽略了边界情况。
这也解释了我们在基础部分讨论过的响应式对象的一些限制:
当你将响应式对象的属性赋值给局部变量,或对其进行解构时,对这个变量的访问或赋值将不再是响应式的,因为它不再触发源对象上的 get / set 代理拦截。注意,这种“断开连接”只影响变量绑定——如果该变量指向的是一个非原始值,比如对象,那么对该对象的修改仍然会是响应式的。
reactive()返回的代理对象,虽然表现得与原对象一样,但如果使用===运算符与原对象比较,它们的身份并不相同。
在 track() 内部,我们会检查当前是否有正在运行的 effect。如果有,我们会查找正在被跟踪的属性对应的订阅者 effect(存储在一个 Set 中),并把这个 effect 加入到该 Set:
js
// 这个会在 effect 即将
// 运行之前设置。我们稍后再处理它。
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}effect 的订阅关系存储在一个全局的 WeakMap<target, Map<key, Set<effect>>> 数据结构中。如果某个属性没有找到订阅它的 effect Set(即第一次被跟踪),就会创建一个。这就是 getSubscribersForProperty() 函数的大致作用。为了简单起见,我们先跳过它的细节。
在 trigger() 内部,我们再次查找该属性的订阅者 effect。但这一次,我们改为调用它们:
js
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}现在让我们回到 whenDepsChange() 函数:
js
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}它把原始的 update 函数包装成一个 effect,在真正执行更新之前先把自己设为当前活跃的 effect。这样,更新过程中调用的 track() 就能找到当前活跃的 effect。
到这里,我们已经创建了一个可以自动追踪依赖,并且在依赖变化时重新运行的 effect。我们把它称为响应式 effect。
Vue 提供了一个 API 让你创建响应式 effect:watchEffect()。实际上,你可能已经注意到,它的工作方式和示例里的神奇 whenDepsChange() 非常相似。现在我们可以使用真实的 Vue API 重写原始示例:
js
import { ref, watchEffect } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = ref()
watchEffect(() => {
// 跟踪 A0 和 A1
A2.value = A0.value + A1.value
})
// 触发 effect
A0.value = 2使用响应式 effect 来修改一个 ref 并不是最有趣的用例——实际上,使用计算属性会让它更具声明性:
js
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2在内部,computed 通过响应式 effect 来管理它的失效和重新计算。
那么,一个常见且有用的响应式 effect 示例是什么呢?更新 DOM!我们可以像这样实现简单的“响应式渲染”:
js
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `Count is: ${count.value}`
})
// 更新 DOM
count.value++实际上,这已经非常接近 Vue 组件保持状态与 DOM 同步的方式了——每个组件实例都会创建一个响应式 effect 来渲染并更新 DOM。当然,Vue 组件更新 DOM 的方式比 innerHTML 高效得多。这个话题在渲染机制中有讨论。
运行时 vs. 编译时响应式
Vue 的响应式系统主要基于运行时:所有的跟踪和触发都是在代码直接于浏览器中运行时完成的。运行时响应式的优点是它不需要构建步骤,而且边界情况更少。另一方面,这也使它受限于 JavaScript 的语法限制,因此需要像 Vue refs 这样的值容器。
一些框架,例如 Svelte,选择通过在编译期间实现响应式来克服这些限制。它会分析并转换代码,以模拟响应式。编译步骤允许框架改变 JavaScript 本身的语义——例如,自动注入在局部定义变量的访问周围执行依赖分析和 effect 触发的代码。缺点是,这类转换需要构建步骤,而且改变 JavaScript 语义本质上就是创建一种看起来像 JavaScript、但编译后会变成别的东西的语言。
Vue 团队确实通过一个名为 Reactivity Transform 的实验性功能探索过这个方向,但最终我们决定它并不适合这个项目,原因见这里。
响应式调试
Vue 的响应式系统会自动追踪依赖,这很棒,但在某些情况下,我们可能想要准确地弄清楚到底追踪了什么,或者是什么导致组件重新渲染。
组件调试钩子
我们可以使用 onRenderTracked 和 onRenderTriggered 这两个生命周期钩子,来调试组件渲染过程中使用了哪些依赖,以及是哪个依赖触发了更新。这两个钩子都会接收一个调试器事件,其中包含相关依赖的信息。建议在回调中放置 debugger 语句,以便交互式地检查该依赖:
vue
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked((event) => {
debugger
})
onRenderTriggered((event) => {
debugger
})
</script>TIP
组件调试钩子仅在开发模式下生效。
调试事件对象具有以下类型:
ts
type DebuggerEvent = {
effect: ReactiveEffect
target: object
type:
| TrackOpTypes /* 'get' | 'has' | 'iterate' */
| TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */
key: any
newValue?: any
oldValue?: any
oldTarget?: Map<any, any> | Set<any>
}计算属性调试
我们可以通过给 computed() 传入第二个选项对象,并提供 onTrack 和 onTrigger 回调,来调试计算属性:
- 当某个响应式属性或 ref 被追踪为依赖时,会调用
onTrack。 - 当某个依赖发生变更并触发侦听器回调时,会调用
onTrigger。
这两个回调都会接收与组件调试钩子相同格式的调试器事件:相同格式:
js
const plusOne = computed(() => count.value + 1, {
onTrack(e) {
// 当 count.value 被追踪为依赖时触发
debugger
},
onTrigger(e) {
// 当 count.value 发生变更时触发
debugger
}
})
// 访问 plusOne,应触发 onTrack
console.log(plusOne.value)
// 修改 count.value,应触发 onTrigger
count.value++TIP
onTrack 和 onTrigger 计算属性选项仅在开发模式下生效。
侦听器调试
与 computed() 类似,侦听器也支持 onTrack 和 onTrigger 选项:
js
watch(source, callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
watchEffect(callback, {
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})TIP
onTrack 和 onTrigger 侦听器选项仅在开发模式下生效。
与外部状态系统集成
Vue 的响应式系统通过将普通 JavaScript 对象深度转换为响应式代理来工作。在与外部状态管理系统集成时,这种深度转换可能是不必要的,有时甚至是不希望的(例如外部方案也使用了 Proxy)。
将 Vue 的响应式系统与外部状态管理方案集成的一般思路,是将外部状态保存在一个 shallowRef 中。浅层 ref 只有在访问其 .value 属性时才是响应式的——内部值会保持原样。当外部状态变化时,替换 ref 的值以触发更新。
不可变数据
如果你正在实现撤销 / 重做功能,你很可能希望在每次用户编辑时都为应用状态创建一个快照。然而,如果状态树很大,Vue 的可变响应式系统并不适合这种场景,因为在每次更新时序列化整个状态对象,在 CPU 和内存成本上都可能很昂贵。
不可变数据结构 通过不直接修改状态对象来解决这个问题——它会创建新的对象,并与旧对象共享未改变的部分。JavaScript 中使用不可变数据有多种方式,但我们推荐在 Vue 中使用 Immer,因为它既能使用不可变数据,又能保持更符合人体工学的可变语法。
我们可以通过一个简单的组合式函数将 Immer 与 Vue 集成:
js
import { produce } from 'immer'
import { shallowRef } from 'vue'
export function useImmer(baseState) {
const state = shallowRef(baseState)
const update = (updater) => {
state.value = produce(state.value, updater)
}
return [state, update]
}状态机
状态机 是一种用于描述应用可能处于的所有状态,以及它如何从一种状态转换到另一种状态的模型。对于简单组件来说它可能有些大材小用,但它可以帮助使复杂的状态流更加健壮和易于管理。
JavaScript 中最流行的状态机实现之一是 XState。下面是一个与之集成的组合式函数:
js
import { createMachine, interpret } from 'xstate'
import { shallowRef } from 'vue'
export function useMachine(options) {
const machine = createMachine(options)
const state = shallowRef(machine.initialState)
const service = interpret(machine)
.onTransition((newState) => (state.value = newState))
.start()
const send = (event) => service.send(event)
return [state, send]
}RxJS
RxJS 是一个用于处理异步事件流的库。VueUse 库提供了 @vueuse/rxjs 扩展,用于将 RxJS 流与 Vue 的响应式系统连接起来。
与 Signals 的关系
不少其他框架也引入了与 Vue Composition API 中的 ref 类似的响应式原语,并将其称为 “signals”:
从根本上说,signals 与 Vue 的 ref 属于同一类响应式原语。它是一个值容器,在访问时提供依赖追踪,在变更时触发副作用。这种基于响应式原语的范式在前端领域并不算新:它可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 等实现。Vue Options API 和 React 状态管理库 MobX 也基于同样的原则,只是把这些原语隐藏在对象属性之后。
尽管“signals”并不一定要求具备这一特性,但如今这个概念通常也会与通过细粒度订阅来执行更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前依赖编译器来实现类似的优化。不过,我们也在探索一种受 Solid 启发的新编译策略,称为 Vapor Mode,它不依赖虚拟 DOM,并且能更多地利用 Vue 内置的响应式系统。
API 设计权衡
Preact 和 Qwik 的 signals 设计与 Vue 的 shallowRef 非常相似:三者都通过 .value 属性提供可变接口。下面我们将重点讨论 Solid 和 Angular 的 signals。
Solid Signals
Solid 的 createSignal() API 设计强调读 / 写分离。signals 以只读 getter 和单独的 setter 形式暴露:
js
const [count, setCount] = createSignal(0)
count() // 访问值
setCount(1) // 更新值注意 count 这个 signal 可以在不带 setter 的情况下向下传递。这确保了只要没有显式暴露 setter,状态就永远不会被修改。至于这种安全性是否值得采用更冗长的语法,这取决于项目需求和个人偏好——但如果你喜欢这种 API 风格,也可以很容易在 Vue 中复现:
js
import { shallowRef, triggerRef } from 'vue'
export function createSignal(value, options) {
const r = shallowRef(value)
const get = () => r.value
const set = (v) => {
r.value = typeof v === 'function' ? v(r.value) : v
if (options?.equals === false) triggerRef(r)
}
return [get, set]
}Angular Signals
Angular 正在经历一些根本性的变化:它放弃了脏检查,并引入了自己对响应式原语的实现。Angular Signal API 如下所示:
js
const count = signal(0)
count() // 访问值
count.set(1) // 设置新值
count.update((v) => v + 1) // 基于前一个值更新同样,我们也可以很容易地在 Vue 中复现这一 API:
js
import { shallowRef } from 'vue'
export function signal(initialValue) {
const r = shallowRef(initialValue)
const s = () => r.value
s.set = (value) => {
r.value = value
}
s.update = (updater) => {
r.value = updater(r.value)
}
return s
}与 Vue refs 相比,Solid 和 Angular 基于 getter 的 API 风格在 Vue 组件中使用时提供了一些有趣的权衡:
()比.value稍微简洁一些,但更新值的写法更冗长。- 没有 ref 解包:访问值始终需要
()。这使得在任何地方访问值的方式都保持一致。这也意味着你可以把原始 signal 直接作为组件 props 传递下去。
这些 API 风格是否适合你,在某种程度上是主观的。这里我们的目标是展示这些不同 API 设计之间的底层相似性和权衡。我们也想说明 Vue 是灵活的:你并不真的被现有 API 所束缚。如果有必要,你可以创建自己的响应式原语 API,以满足更具体的需求。