Vue 3 的响应式变量使得开发者得以简单高效地开发响应式界面。在日常开发的学习之外,Vue 3 响应式原理的设计同样非常巧妙优雅。本文将带你逐层拨开Vue 3响应式原理的运行逻辑。
1. 响应式的本质
日常开发中,响应式变量的定义和使用我们都已经相当熟练了。当一个响应式变量发生变化时,涉及该变量的组件渲染函数、watchEffect() 回调以及 computed() 内部的计算等都要重新执行。我们称这些因响应式变量发生变化而出现执行的运算为副作用,而响应式变量则是这些副作用的依赖。
这套依赖-副作用的响应式系统是怎么工作的呢?其实原理相当简单。举个例子:每个依赖(被副作用读取的响应式属性)都是一个零件生产商,而副作用(例如组件的渲染副作用)则是零件加工厂。当零件发生变化时,零件生产商(依赖)就需要通知每个零件加工厂(副作用)来获取最新的零件,这样一来所有需要该零件的加工厂都能及时用最新的零件进行加工。
这就是响应式的本质:依赖与副作用深度绑定,当依赖发生变化,通知所有订阅了该依赖的副作用重新执行,从而实现响应式。从思想上看,这套机制与观察者模式和发布—订阅模式非常相似:响应式属性记录订阅它的副作用,并在值发生变化时通知这些副作用。
2. 如何实现响应式系统
响应式的原理搞清楚了,我们现在还有几个问题:
- 如何存储依赖和副作用呢?
- 副作用如何订阅依赖?
- 依赖发生变化的时候,如何去通知副作用?
- ref和reactive如何实现?
为了突出核心原理,下面将实现一个最小版响应式系统。它省略了 Vue 中的新旧值比较、嵌套对象转换、依赖清理、副作用调度、批量更新、数组与集合处理等机制,因此用于理解设计思路,而非还原完整源码。
2.1 存储依赖和副作用
- 一个依赖通常有多个订阅它的副作用。对此,我们可以用一个Set来存储这些副作用
Set<effect>。 - 为了让依赖和订阅它的副作用关联起来,我们使用一个Map来存储他们的关系
Map<Key, Set<effect>> - Vue的响应式变量都是基于对象封装的,每个响应式变量都是对象的一个属性。因此我们还需要一个全局的Map来存储所有这些对象。我们使用
WeakMap<Target, Map<Key, Set<effect>>>来存储所有这些对象。
最终,我们就通过一个WeakMap存储了响应式系统里面的依赖和副作用。下面我们来看看副作用是如何订阅依赖的。
2.2 副作用订阅依赖
前面我们说过,副作用通过订阅依赖,在依赖发生变化的时候重新执行,实现响应式。
实现副作用订阅,我们需要一个函数用于追踪。当某个依赖被访问时,我们需要将该副作用存储到这个依赖的副作用集合中。
下面是追踪函数的基础实现:
我们用一个全局变量表示我们追踪到的副作用。(如何追踪后面会讲,这里先假设我们已经知道这个副作用需要订阅依赖)
let activeEffect = null // 未追踪到任何副作用时为null
下面定义track()函数:将副作用activeEffect存入依赖的副作用集合Set中
const targetMap = new WeakMap() // 全局映射,存储依赖与订阅的容器,见2.1
// target 和 key 共同确定一个响应式属性,也就是一个依赖
function track(target, key) {
// 如果当前没有活跃的副作用,停止
if (!activeEffect) return
// 寻找该对象中存储了所有依赖的映射
let depsMap = targetMap.get(target)
// 这里depsMap的类型是Map<Key, Set<effect>>, 见2.1
// 假如还没有映射,则创建
if (!depsMap) {
targetMap.set(target, new Map())
depsMap = targetMap.get(target)
}
// 寻找该依赖的副作用集合
let deps = depsMap.get(key)
// deps的类型是Set<effect>, 见2.1
// 假如还没有映射,则创建
if (!deps) {
depsMap.set(key, new Set())
deps = depsMap.get(key)
}
// 收集副作用,完成订阅
deps.add(activeEffect)
}
2.3 通知副作用
当依赖的值发生变化,我们需要一个触发函数来依次通知订阅该依赖的副作用重新执行。
下面定义trigger()函数,用于通知副作用并重新执行
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (!effects) return
for (const effect of effects) {
effect()
}
}
track()和trigger()两个函数共同实现了响应式系统的订阅和通知功能,构成了Vue的响应式基础。接下来,我们需要在副作用读写依赖的时候相应地触发这两个函数。ref和reactive的封装就是为了实现这个目的。
2.4 ref 和 reactive 的实现
普通 ref 通过 .value 提供响应式读写入口。要实现响应式,我们需要在副作用读取value的时候触发track(),让副作用订阅依赖。还需要在副作用修改value的值的时候触发trigger(),通知其他副作用重新执行。要实现这个效果,我们只需要使用JavaScript的访问器属性即可。访问器属性可以在属性被读取或修改时执行对应的 getter 或 setter。JavaScript 无法直接拦截普通局部变量的读写,因此ref 将值包装在对象中,通过.value的 getter/setter 拦截访问和修改。闭包中的变量用于保存内部值。
简易ref实现
// 此处的myValue是闭包变量
function ref(myValue) {
const obj = {
get value() {
// 外部通过.value访问时自动触发
// 触发track,收集副作用
track(obj, 'value')
// 将依赖的值返回
return myValue
},
set value(newValue) {
// 外部修改.value的值时触发
// 修改依赖的值
myValue = newValue
// 触发trigger,通知所有订阅的副作用重新执行
trigger(obj, 'value')
return
}
}
return obj
}
需要注意:暴露在外的 obj.value 是访问入口,内部的 myValue 才是真实存储
接下来我们看看reactive的实现。reactive() 接收一个对象,并返回该对象的响应式代理。与ref的实现同理,我们需要拦截JavaScript对象的读写,而Proxy是一个很合适的选择。
简易reactive实现
function reactive(obj) {
return new Proxy(obj, {
// 拦截读取
get(target, key, receiver) {
// 触发track,收集副作用
track(target, key)
// 将依赖的值返回
return Reflect.get(target, key, receiver)
},
// 拦截修改
set(target, key, newValue, receiver) {
// 修改依赖的值
const result = Reflect.set(target, key, newValue, receiver)
// 触发trigger,通知所有订阅的副作用重新执行
trigger(target, key)
return result
}
})
}
reactive的实现相对简单,道理与ref是一样的,就不再赘述了。
现在,track() + trigger() + ref/reactive 的路线终于连通了。我们还剩下最后一个问题:如何让activeEffect与当前活跃副作用绑定?
2.5 获取当前活跃的副作用
还记得我们前面使用了一个全局变量来记录副作用:
let activeEffect = null
这个变量在track()函数中使用(见2.1),用于给依赖收集副作用。那么我们回答前面一直没有讨论的问题,这个activeEffect是如何锁定副作用的?
假设我们有一个函数update()用到了一个依赖A:
let B = 0
function update() {
B = A.value + 1
}
当依赖A的值发生变化时,update()需要重新执行。因此我们需要将update()封装为一个副作用并订阅依赖A。
要完成这个工作,我们需要定义一个函数,whenDepsChange(update)。
function whenDepsChange(update) {
function effect() {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
为了将 update() 注册为响应式副作用,vue会显式调用 whenDepsChange(update)。首先将activeEffect赋值为当前的effect,然后执行update()。重点来了:由于update()的执行,意味着依赖A会被读取,从而触发track(),那么track()便可以将此时activeEffect的值——effect进行收集,从而实现了副作用的订阅。update()与A的订阅便完成了。
至此,vue 3 的响应式系统基础框架就搭建好了。
3. 总结
ref 通过访问器属性拦截 .value 的读写,reactive 通过 Proxy 拦截对象属性的读写;whenDepsChange() 则负责注册并执行响应式副作用,使 track() 能够收集当前正在运行的副作用,trigger()用于在依赖被修改后通知所有订阅了该依赖的副作用。环环相扣,十分优雅。