深入响应式系统
Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。当更改它们时,视图会随即自动更新。这让状态管理更加简单直观,但理解它是如何工作的也是很重要的,这可以帮助我们避免一些常见的陷阱。在本节中,我们将深入研究 Vue 响应性系统的一些底层细节。
什么是响应性
这个术语在今天的各种编程讨论中经常出现,但人们说它的时候究竟是想表达什么意思呢?本质上,响应性是一种可以使我们声明式地处理变化的编程范式。一个经常被拿来当作典型例子的用例即是 Excel 表格:
A | B | C | |
---|---|---|---|
0 | 1 | ||
1 | 2 | ||
2 | 3 |
这里单元格 A2 中的值是通过公式 = A0 + A1
来定义的 (你可以在 A2 上点击来查看或编辑该公式),因此最终得到的值为 3,正如所料。但如果你试着更改 A0 或 A1,你会注意到 A2 也随即自动更新了。
而 JavaScript 默认并不是这样的。如果我们用 JavaScript 写类似的逻辑:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // 仍然是 3
当我们更改 A0
后,A2
不会自动更新。
那么我们如何在 JavaScript 中做到这一点呢?首先,为了能重新运行计算的代码来更新 A2
,我们需要将其包装为一个函数:
let A2
function update() {
A2 = A0 + A1
}
然后,我们需要定义几个术语:
这个
update()
函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。A0
和A1
被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者 (subscriber)。
我们需要一个魔法函数,能够在 A0
或 A1
(这两个依赖) 变化时调用 update()
(产生作用)。
whenDepsChange(update)
这个 whenDepsChange()
函数有如下的任务:
当一个变量被读取时进行追踪。例如我们执行了表达式
A0 + A1
的计算,则A0
和A1
都被读取到了。如果一个变量在当前运行的副作用中被读取了,就将该副作用设为此变量的一个订阅者。例如由于
A0
和A1
在update()
执行时被访问到了,则update()
需要在第一次调用之后成为A0
和A1
的订阅者。探测一个变量的变化。例如当我们给
A0
赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。
Vue 中的响应性是如何工作的
我们无法直接追踪对上述示例中局部变量的读写,原生 JavaScript 没有提供任何机制能做到这一点。但是,我们是可以追踪对象属性的读写的。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxies。Vue 2 使用 getter / setters 完全是出于支持旧版本浏览器的限制。而在 Vue 3 中则使用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref。下面的伪代码将会说明它们是如何工作的:
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
这里和下面的代码片段皆旨在以最简单的形式解释核心概念,因此省略了许多细节和边界情况。
以上代码解释了我们在基础章节部分讨论过的一些 reactive()
的局限性:
当你将一个响应式对象的属性赋值或解构到一个本地变量时,访问或赋值该变量是非响应式的,因为它将不再触发源对象上的 get / set 代理。注意这种“断开”只影响变量绑定——如果变量指向一个对象之类的非原始值,那么对该对象的修改仍然是响应式的。
从
reactive()
返回的代理尽管行为上表现得像原始对象,但我们通过使用===
运算符还是能够比较出它们的不同。
在 track()
内部,我们会检查当前是否有正在运行的副作用。如果有,我们会查找到一个存储了所有追踪了该属性的订阅者的 Set,然后将当前这个副作用作为新订阅者添加到该 Set 中。
// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
副作用订阅将被存储在一个全局的 WeakMap<target, Map<key, Set<effect>>>
数据结构中。如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。这就是 getSubscribersForProperty()
函数所做的事。为了简化描述,我们跳过了它其中的细节。
在 trigger()
之中,我们会再查找到该属性的所有订阅副作用。但这一次我们需要执行它们:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
现在让我们回到 whenDepsChange()
函数中:
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
它将原本的 update
函数包装在了一个副作用函数中。在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。这使得在更新期间的 track()
调用都能定位到这个当前活跃的副作用。
此时,我们已经创建了一个能自动跟踪其依赖的副作用,它会在任意依赖被改动时重新运行。我们称其为响应式副作用。
Vue 提供了一个 API 来让你创建响应式副作用 watchEffect()
。事实上,你会发现它的使用方式和我们上面示例中说的魔法函数 whenDepsChange()
非常相似。我们可以用真正的 Vue API 改写上面的例子:
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
})
// 将触发副作用
A0.value = 2
使用一个响应式副作用来更改一个 ref 并不是最优解,事实上使用计算属性会更直观简洁:
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
在内部,computed
会使用响应式副作用来管理失效与重新计算的过程。
那么,常见的响应式副作用的用例是什么呢?自然是更新 DOM!我们可以像下面这样实现一个简单的“响应式渲染”:
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `计数:${count.value}`
})
// 更新 DOM
count.value++
实际上,这与 Vue 组件保持状态和 DOM 同步的方式非常接近——每个组件实例创建一个响应式副作用来渲染和更新 DOM。当然,Vue 组件使用了比 innerHTML
更高效的方式来更新 DOM。这会在渲染机制一章中详细介绍。
运行时 vs. 编译时响应性
Vue 的响应式系统基本是基于运行时的。追踪和触发都是在浏览器中运行时进行的。运行时响应性的优点是,它可以在没有构建步骤的情况下工作,而且边界情况较少。另一方面,这使得它受到了 JavaScript 语法的制约,导致需要使用一些例如 Vue ref 这样的值的容器。
一些框架,如 Svelte,选择通过编译时实现响应性来克服这种限制。它对代码进行分析和转换,以模拟响应性。该编译步骤允许框架改变 JavaScript 本身的语义——例如,隐式地注入执行依赖性分析的代码,以及围绕对本地定义的变量的访问进行作用触发。这样做的缺点是,该转换需要一个构建步骤,而改变 JavaScript 的语义实质上是在创造一种新语言,看起来像 JavaScript 但编译出来的东西是另外一回事。
Vue 团队确实曾通过一个名为响应性语法糖的实验性功能来探索这个方向,但最后由于这个原因,我们认为它不适合这个项目。
响应性调试
Vue 的响应性系统可以自动跟踪依赖关系,但在某些情况下,我们可能希望确切地知道正在跟踪什么,或者是什么导致了组件重新渲染。
组件调试钩子
我们可以在一个组件渲染时使用 onRenderTracked
生命周期钩子来调试查看哪些依赖正在被使用,或是用 onRenderTriggered
来确定哪个依赖正在触发更新。这些钩子都会收到一个调试事件,其中包含了触发相关事件的依赖的信息。推荐在回调中放置一个 debugger
语句,使你可以在开发者工具中交互式地查看依赖:
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked((event) => {
debugger
})
onRenderTriggered((event) => {
debugger
})
</script>
TIP
组件调试钩子仅会在开发模式下工作
调试事件对象有如下的类型定义:
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
两个回调函数的对象:
onTrack
将在响应属性或引用作为依赖项被跟踪时被调用。onTrigger
将在侦听器回调被依赖项的变更触发时被调用。
这两个回调都会作为组件调试的钩子,接受相同格式的调试事件:
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
选项:
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 的 .value
才会触发更新。
不可变数据
如果你正在实现一个撤销/重做的功能,你可能想要对用户编辑时应用的状态进行快照记录。然而,如果状态树很大的话,Vue 的可变响应性系统没法很好地处理这种情况,因为在每次更新时都序列化整个状态对象对 CPU 和内存开销来说都是非常昂贵的。
不可变数据结构通过永不更改状态对象来解决这个问题。与 Vue 不同的是,它会创建一个新对象,保留旧的对象未发生改变的一部分。在 JavaScript 中有多种不同的方式来使用不可变数据,但我们推荐使用 Immer 搭配 Vue,因为它使你可以在保持原有直观、可变的语法的同时,使用不可变数据。
我们可以通过一个简单的组合式函数来集成 Immer:
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]
}
状态机
状态机是一种数据模型,用于描述应用可能处于的所有可能状态,以及从一种状态转换到另一种状态的所有可能方式。虽然对于简单的组件来说,这可能有些小题大做了,但它的确可以使得复杂的状态流更加健壮和易于管理。
XState 是 JavaScript 中一个比较常用的状态机实现方案。这里是集成它的一个例子:
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 的响应性系统。
与信号 (signal) 的联系
很多其他框架已经引入了与 Vue 组合式 API 中的 ref 类似的响应性基础类型,并称之为“信号”:
从根本上说,信号是与 Vue 中的 ref 相同的响应性基础类型。它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。这种基于响应性基础类型的范式在前端领域并不是一个特别新的概念:它可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 等实现。Vue 的选项式 API 和 React 的状态管理库 MobX 也是基于同样的原则,只不过将基础类型这部分隐藏在了对象属性背后。
虽然这并不是信号的必要特征,但如今这个概念经常与细粒度订阅和更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前依靠编译器来实现类似的优化。然而,我们也在探索一种新的受 Solid 启发的编译策略 (Vapor Mode),它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。
API 设计权衡
Preact 和 Qwik 的信号设计与 Vue 的 shallowRef 非常相似:三者都通过 .value
属性提供了一个更改接口。我们将重点讨论 Solid 和 Angular 的信号。
Solid Signals
Solid 的 createSignal()
API 设计强调了读/写隔离。信号通过一个只读的 getter 和另一个单独的 setter 暴露:
const [count, setCount] = createSignal(0)
count() // 访问值
setCount(1) // 更新值
注意到 count
信号在没有 setter 的情况也能传递。这就保证了除非 setter 也被明确暴露,否则状态永远不会被改变。这种更冗长的语法带来的安全保证的合理性取决于项目的要求和个人品味——但如果你喜欢这种 API 风格,可以轻易地在 Vue 中复制它:
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 信号
Angular 正在经历一些底层的变化,它放弃了脏检查,并引入了自己的响应性基础类型实现。Angular 的信号 API 看起来像这样:
const count = signal(0)
count() // 访问值
count.set(1) //设置值
count.update((v) => v + 1) // 通过前值更新
// 对具有相同身份的深层对象进行更改
const state = signal({ count: 0 })
state.mutate((o) => {
o.count++
})
同样,我们可以轻易地在 Vue 中复制这个 API:
import { shallowRef, triggerRef } 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)
}
s.mutate = (mutator) => {
mutator(r.value)
triggerRef(r)
}
return s
}
与 Vue 的 ref 相比,Solid 和 Angular 基于 getter 的 API 风格在 Vue 组件中使用时提供了一些有趣的权衡:
()
比.value
略微省事,但更新值却更冗长;- 没有 ref 解包:总是需要通过
()
来访问值。这使得值的访问在任何地方都是一致的。这也意味着你可以将原始信号作为组件的参数传递下去。
这些 API 风格是否适合你,在某种程度上是主观的。我们在这里的目标是展示这些不同的 API 设计之间的基本相似性和取舍。我们还想说明 Vue 是灵活的:你并没有真正被限定在现有的 API 中。如有必要,你可以创建你自己的响应性基础 API,以满足更多的具体需求。