引子
可以前往专栏阅读该系列其他文章:传送门
本文章只结合官网内容,介绍 vue3 中响应式系统的设计思路,不涉及过多的源码解读。
后续会单独出一篇文章,分析 vue3 中的响应式源码:)
什么是响应性
官网:本质上,响应性是一种可以使我们声明式地处理变化的编程范式。
用人话说,就是:先声明好对于数据会进行什么样的操作,然后当数据输入或者有改变的时候,基于改变,会做出反应
。
但是,js 默认并不是响应性的,用一段代码说明一下:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // 仍然是 3
// 只有再执行一边,才能更新 A2 的值
A2 = A0 + A1
console.log(A2) // 4
那么,为了能够实现响应性
首先,我们需要将 A0 A1 这两个数据的处理,包装成一个函数:
let A2
function update() {
// `A0` 和 `A1` 被视为这个作用的**依赖** (dependency)
A2 = A0 + A1
}
这个 update()
函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。
包装成函数,目的就是为了需要更新 A2 的时候,调用一次函数即可。
A0
和 A1
被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此副作用函数( update
函数)也可以说是依赖的订阅者 (subscriber)
理解完依赖、订阅者后,还不够。
此时当依赖改变,订阅者并不能感知到它的依赖改变了。
所以我们还需要一个“魔法函数”
:
// 该函数会存储副作用函数,必要时调用它
whenDepsChange(update)
能够在 A0
或 A1
(这两个依赖) 变化时调用 update()
(产生作用)。
接下来,分析一下这个魔法函数需要承担的任务,主要有三个:
当一个变量被读取时进行追踪。
例如我们执行了表达式 A0 + A1
的计算,则 A0
和 A1
都被读取到,并基于他们建立追踪。
如果一个变量在当前运行的副作用中被读取了,就将该副作用设为此变量的一个订阅者。
例如由于 A0
和 A1
在 update()
执行时被访问到了,则 update()
需要在第一次调用之后成为 A0
和 A1
的订阅者,将依赖和订阅者建立联系。
目的是为了能够及时,准确的根据依赖的改变,通知订阅者执行副作用。
探测一个变量的变化。
例如当我们给 A0
赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。
因为一个变量,可以是多个订阅者的依赖,所以魔法函数需要能够管理一个变量所对应的所有订阅者
。
Vue 中的响应性是如何工作的
理解完依赖、订阅者、观察者,这三者的关系后,就可以很好得理解 vue 的响应式工作原理了。
首先,我们要知道,像上一章中的例子,js 其实是无法直接实现的,因为 A0 A1 这两个依赖,都是局部变量,而 js 没有任何一种方法能够直接追踪局部变量的读写的。
但是,我们是可以追踪对象属性的读写的。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxies。
该系列第一篇文章,在介绍基础知识的时候,提到过 vue2 实现响应式的大致过程,就是使用 getter / setters 进行了对象属性的拦截。
而在 Vue 3 中则使用了 Proxy
来创建响应式对象
,但也不全是使用的 Proxy,ref 的响应式变量则是使用的 getter / setter。
伪代码展示:
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
}
从这段伪代码,我们可以看出响应式对象 reactive 和响应式变量 ref 的实现区别。
这也解释了在基础篇中我们提到的响应式对象的限制
:
当你将一个响应性对象的属性解构
为一个局部变量时,响应性就会“断开连接”
,因为对局部变量的访问不再触发 get / set 代理捕获;
从 reactive()
返回的代理尽管行为上表现得像原始对象,但我们通过使用 ===
运算符还是能够比较出它们的不同
接下来,详细介绍一下内部的两个函数:
用来追踪依赖的 track()
用来触发副作用的 trigger()
get 内部会调用 track()
首次访问时建立依赖
与订阅者函数
之间的联系。
// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key)
effects.add(activeEffect)
}
}
会检查当前是否有正在运行的副作用,如果有,就调用getSubscribersForProperty
函数,
该函数,
会查找到一个存储了所有追踪了该属性的订阅者的 Set
(即,dep
),然后将当前这个副作用作为新订阅者添加到该 Set 中;
如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建集合
。
副作用订阅将被存储在一个全局的 WeakMap<target, Map<key, Set<effect>>>
数据结构中。
分析一下这个数据结构:
WeakMap
是一个全局变量,存储全量的响应式依赖关系。
target
作为 WeakMap 的 key,为响应式对象
。
定义一个 Map 作为 WeakMap 的 value。
Map
的 key,就是响应式对象的 key,
Map
的 value,则是上面提到的订阅者 Set 集合
(dep),用来存储响应式对象中 key 所对应的所有订阅者。
总结一下就是:
通过 WeakMap 存储多个响应式对象,
通过 Map 存储单个响应式对象的所有属性,
通过 Set 存储了每个属性的所有订阅者,并起到去重
的效果。
所以 track
函数,其实就是在全局构建出一个:
响应式对象中,每个属性和订阅了这个属性的副作用函数之间的关系。起到了依赖收集的作用。
set 内部会调用 trigger()
trigger 函数就相对简单了。
查找到属性的所有订阅副作用,并执行
它们:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key)
effects.forEach((effect) => effect())
}
activeEffect
到目前为止,已经基本完成了响应式的依赖收集
、更新派发
,但是还存在一个细节,需要展开讲讲。那就是 track
函数在进行副作用函数保存前的判断:
if (activeEffect)
想要明白这个 activeEffect
是啥,得回到文章开头的那个“魔法函数”
:
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect
update()
activeEffect = null
}
effect()
}
我们完善了函数内部实现:
它接收一个 update
函数作为参数,
将原本的 update
函数包装在了一个副作用函数中,
在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。
activeEffect 其实就是一个指向当前活跃的副作用函数的全局变量。
我们带入场景进行理解:
实际开发中,我们通过 reactive 定义了一个响应式对象 person,该对象有两个属性 name 和 age
。
此时 person 就是一个 Proxy 代理对象,有 getter/setter ,分别实现了 track()
和 trigger()
。
然后我们将 person.name 和 person.age 分别写到了 vue 的模板中,就会得到两个 effect 函数
,计作 updateName
和 updateAge
,分别用来更新页面中的 name 和 age。
那么在首次渲染时,读取 name 和 age 的值,就会触发 getter 中的 track
进行依赖收集,
前面提到过,每个属性对应一个 Set
用来保存跟它相关的 effect 函数
。
虽然我们可以通过 key 获取到 Set 集合,但是想要将副作用函数添加进 Set 集合时,vue 的源码无法通过传入的 key 去找到跟这个 key 相关的 effect 函数
,总不能让源码中加入 if / else 判断吧,作者又不知道业务代码中的对象会定义成什么 key。
所以为了能够让 effect 函数和 key 对应起来,vue 的作者想到了一个很巧妙的设计,那就是定义了一个全局变量,activeEffect
。
再回头看“魔法函数”,可以想象一下,现在有两个 effect 函数,然后提前封装好两个“魔法函数”,分别传入 updateName 和 updateAge。
当页面首次渲染,其实就是按顺序触发“魔法函数”,当解析到 person.name 的模板的时候,此时的“魔法函数”是传入 updateName 的那个,当前的全局变量 activeEffect 其实本质是指向了 updateName
这个副作用函数,这样读取 person.name 的值,后续依赖收集的时候,获取到 name 的 Set 集合,只需要将全局变量 activeEffect 添加进去即可
。
由于代码是同步执行的,当 name 的依赖收集结束了,才会进行 age 的依赖收集,此时的 activeEffect 其实已经被释放
,可以被用作指向 updateAge 了。
至此,一个完整的响应式系统就实现了:
构建响应式对象 Proxy
依赖收集
(将依赖变量和副作用函数一一对应保存到 Set)
依赖变量更新,通知所有相关的副作用函数进行更新派发
还有一个关于 activeEffect 的小细节
:
track
内部当 activeEffect 有值
时,才会进行后续的依赖收集。
这样做的好处是,当定义了响应式对象,但是如果对某个属性只是 console 了一下的话,并不会进行依赖收集,可以提高性能。
只有定义了“魔法函数”,从“魔法函数”内部触发的属性访问,才会触发依赖收集。
什么是“魔法函数”
上面的例子中有一个很关键的点,就是只有从“魔法函数”中,才会改变 activeEffect 的指向,触发了属性访问,才会进行依赖收集。
这个“魔法函数”其实就是一个响应式副作用
,它能自动跟踪其依赖的副作用,它会在任意依赖被改动时重新运行。
比如:
watchEffect()
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
计算属性computed
import { ref, computed } from 'vue'
const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)
A0.value = 2
他们的用法跟“魔法函数” whenDepsChange 很相似。
总结
本文介绍了 vue3 中响应式系统的工作原理。
通过响应式副作用
,跟踪内部的依赖变量,进行依赖收集
,将每个依赖变量和副作用函数对应起来,当依赖变量变更时,通知
所有相关的副作用函数重新执行。