在上一篇文章《响应式原理与 reactive》中由于篇幅限制笔者留下了两个小悬念 track 依赖收集处理器与 trigger 派发更新处理器没有细致讲解,而在本篇文章中笔者会带着大家一起来学习 Vue3 响应式系统中的依赖收集部分和副作用函数。
Vue 是怎样追踪变化的?
当我们在 template 模板中使用响应式变量,或者在计算属性中传入 getter 函数后当计算属性中的源数据发生变化后,Vue 总能即时的通知更新并重新渲染组件,这些神奇的现象是如何实现的呢?
Vue 通过一个副作用(effect)函数来跟踪当前正在运行的函数。副作用是一个函数包裹器,在函数被调用前就启动跟踪,而 Vue 在派发更新时就能准确的找到这些被收集起来的副作用函数,当数据发生更新时再次执行它。
为了更好的理解依赖的收集过程,笔者先从副作用函数的实现开始说起。
effect 的类型
老规矩在介绍副作用之前,先一起看一下副作用的类型,这样能够帮助大家先对副作用“长的什么样子”有一个直观的概念。
export interface ReactiveEffect<T = any> {
(): T
_isEffect: true
id: number
active: boolean
raw: () => T
deps: Array<Dep>
options: ReactiveEffectOptions
allowRecurse: boolean
}
从副作用的类型定义中可以清晰的看到它定义了一个泛型参数,这个泛型会被当做内部副作用函数的返回值,并且这个类型本身就是一个函数。还有一个 _isEffect 属性标识这是一个副作用;active 属性是用来标识这个副作用启用和停用的状态;raw 属性保存初始传入的函数;deps 属性是这个副作用的所有依赖,对于这个数组中元素的 Dep 类型我们笔者就会介绍到;options 中保存着副作用对象的一些配置项;而 allowRecurse 暂时不用关注,它是一个副作用函数能否自身调用的标识。
副作用的全局变量
有三个变量是定义在副作用模块中的全局变量,而提前认识这些变量能够帮助我们了解整个副作用函数的生成以及调用的过程。
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined
targetMap:
这个 targetMap 是一个非常重要的变量,它是 WeakMap 类型,存储了 { target -> key -> dep } 的链接。
targetMap 的值的类型是 KeyToDepMap,而 KeyToDepMap 又是一个以 Dep 为值的类型的 Map 对象,Dep 就是笔者一直在提及的依赖,Vue 收集依赖其实就是在收集 Dep 类型。所以对照 Vue2 的源码,从概念上来讲,将依赖看成是一个维护了订阅者 Set 集合的 Dep 类更容易理解,在 targetMap 中只是将 Dep 存储在一个原始的 Set 集合中,是出于减少内存开销的考虑。
effectStatck
这是一个存放当前正被调用的副作用的栈,当一个副作用在执行前会被压入栈中,而在结束之后会被推出栈。
activeEffect
这个变量标记了当前正在执行的副作用,或者也可以理解为副作用栈中的栈顶元素。当一个副作用被压入栈时,会将这个副作用赋值给 activeEffect 变量,而当副作用中的函数执行完后该副作用会出栈,并将 activeEffect 赋值为栈的下一个元素。所以当栈中只有一个元素时,执行完出栈后,activeEffect 就会为 undefined。
副作用(effect)的实现
在学习完需要前置理解的类型与变量后,笔者就开始讲解副作用函数的实现,话不多说直接看代码。
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
// 如果 fn 已经是一个副作用函数,则返回副作用的原始函数
if (isEffect(fn)) {
fn = fn.raw
}
// 创建一个副作用
const effect = createReactiveEffect(fn, options)
// 如果不是延迟执行的,则立即执行一次副作用函数
if (!options.lazy) {
effect()
}
// 返回生成的副作用函数
return effect
}
effect api 的函数相对简单,当传入的 fn 已经是一个副作用函数时,会将 fn 赋值为这个副作用的原始函数。接着会调用 createReactiveEffect
创建一个 ReactiveEffect
类型的函数,如果副作用的选项中没有设置延迟执行,那么这个副作用函数会被立即执行一次,最后将生成的副作用函数返回。
接着一起来看创建副作用函数的 createReactiveEffect 的逻辑。
createReactiveEffect
在 createReactiveEffect 中,首先会创建一个变量名为 effect 的函数表达式,之后为这个函数设置之前在 ReactiveEffect 类型中提及到的一些属性,最后将这个函数返回。
而当这个 effect 函数被执行时,会首先判断自己是不是已经停用,如果是停用状态,则会查看选项中是否有调度函数,如果有调度函数就不再处理,直接 return undefined,若是不存在调度函数,则执行并返回传入的 fn 函数,之后就不再运行下去。
如果 effect 函数状态正常,会判断当前 effect 函数是否已经在副作用栈中,若是已经被加入栈中,则不再继续处理,避免循环调用。
如果当前 effect 函数不在栈中,就会通过 cleanup 函数清理副作用函数的依赖,并且打开依赖收集开关,将副作用函数压入副作用栈中,并记录当前副作用函数为 activeEffect。这段逻辑笔者在介绍这两个变量时已经讲过,它就是在此处触发的。
接下来就会执行传入的 fn 函数被返回结果。
当函数执行完毕后,会将副作用函数弹出栈中,并且将依赖收集开关重置为执行副作用前的状态,再将 activeEffect 标记为当前栈顶的元素。此时一次副作用函数的执行彻底结束,跟着笔者一起来看一下源码的实现。
function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
// 通过一个函数表达式,创建一个变量名为 effect ,函数名为 reactiveEffect 的函数
const effect = function reactiveEffect(): unknown {
// 如果 effect 已停用,当选项中有调度函数时返回 undefined,否则返回原始函数
if (!effect.active) {
return options.scheduler ? undefined : fn()
}
if (!effectStack.includes(effect)) {
// 清理依赖
cleanup(effect)
try {
// 允许收集依赖
enableTracking()
effectStack.push(effect)
activeEffect = effect
return fn()
} finally {
effectStack.pop()
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
}
}
} as ReactiveEffect
// 为副作用函数设置属性
effect.id = uid++
effect.allowRecurse = !!options.allowRecurse
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
当在最后一行 return 了副作用函数后,上一段提及提及当 options 参数中 lazy 为 false 时,这个副作用函数就会第一次被调用,此时就会触发这段函数 第 6 行 const effect
创建函数后的函数内部逻辑。
理解了createReactiveEffect 的执行顺序后,再配合详细的逻辑讲解,相信你也已经掌握 effect 副作用函数的创建了。
收集依赖、派发更新
为了更逻辑顺畅的引出依赖收集和派发更新的工作及实现流程,笔者决定在此处引入一个 Vue3 中 effect 模块的一个简单的单元测试用例,给大家讲解示例的同时顺带聊聊依赖收集和派发更新。
let foo
const counter = reactive({ num: 0 })
effect(() => (foo = counter.num))
// 此时 foo 应该是 0
counter.num = 7
// 此时 foo 应该是 7
这是一个最简单的 effect 的示例,我们都知道 foo 会随着 counter.num 的改变而改变。那么究竟是如何更新的呢?
首先,counter 通过 reactive api 生成一个 proxy 代理对象。这一个生成过程在上一篇文章中已经讲解过了,所以这里就不细讲了。
接着使用 effect,向它传入一个函数。这时 effect 开始它的创建过程,在 effect 函数中会执行到下方代码的这一步。
const effect = createReactiveEffect(fn, options)
通过 createReactiveEffect 开始创建 effect 函数,并返回。
当 effect 函数被返回后,就会判断当前副作用的选项中是否需要延迟执行,而这里我们没有传入任何参数,所以不是延迟加载,需要立即执行,所以会开始执行返回回来的 effect 函数。
if (!options.lazy) {
effect() // 不需要延迟执行,执行 effect 函数
}
于是会开始执行 createReactiveEffect 创建 effect 函数时的内部代码逻辑。
const effect = function reactiveEffect(): unknown {/* 执行此函数内的逻辑 */}
由于 effect 函数是 active 状态,并且也不在副作用栈中,于是会先清除依赖,由于现在并没有收集任何依赖,所以 cleanup 的过程不用关心。接着会将 effet 压入栈中,并设置为 activeEffect,接下来会开始执行初始传入的 fn:() => (foo = counter.num)
。
给 foo 赋值时,会先访问 counter 的 num 属性,所以会触发 counter 的 proxy handler 的 get 陷阱:
// get 陷阱
return function get(target: Target, key: string | symbol, receiver: object) {
/* 忽略逻辑 */
// 获取 Reflect 执行的 get 默认结果
const res = Reflect.get(target, key, receiver)
if (!isReadonly) {
// 依赖收集
track(target, TrackOpTypes.GET, key)
}
return res
}
这里我简化了 get 中的代码,只保留关键部分,可以看到在获取到 res 的值后,会通过 track 开始依赖收集。(🥺 注意要开始讲依赖收集了哦,不要走神)
track 收集依赖
track 函数的路径也是在 @vue/reactivity
库的 effect.ts 的文件中。
在 track 的过程中,首先会判断是否允许收集依赖,这个状态是受 enableTracking()
和 pauseTracking()
这一对函数控制的。接着会判断当前是否有正在执行的副作用函数,如果没有则直接 return。因为依赖收集其实就是在收集副作用函数。
接着从本文一开始介绍过的 targetMap 中去尝试获取对应的 traget 的依赖集合,并存储在 depsMap 变量中,如果获取失败,就会将当前 target 添加进依赖集合中,并将 value 初始化为 new Map()。例如在当前的示例中,target 即为 { num: 0 },是 counter 对象的值。
在有了 depsMap 后,就会根据 target 中被读取的 key,去依赖集合中查看是否有对应 key 的依赖,并赋值给 dep。如果没有,就跟创建 depsMap 的逻辑一样,创建一个 Set 类型的集合当做值。
如果当前执行的副作用函数没有被 dep 这个 Set 集合当做依赖收集,就会将当前副作用函数添加进 dep 中,并且在当前的副作用函数的 deps 属性中添加进该依赖 dep。
看到这里,就能够想象出依赖的收集是一个什么样的结构了。以 key 为维度,将每一个 key 关联的副作用函数收集起来,存放在一个 Set 数据结构中,并以键值对的形式存储在 depsMap 的 Map 结构中。此时再看文章开头描述 targetMap 这个Map 存储的形式 { target -> key -> dep } 应该说是非常明确了。
track 处理器函数的代码如下:
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 不启用依赖收集,或者没有 activeEffect 则直接 return
if (!shouldTrack || activeEffect === undefined) {
return
}
// 在 targetMap 中获取对应的 target 的依赖集合
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果 target 不在 targetMap 中,则加入,并初始化 value 为 new Map()
targetMap.set(target, (depsMap = new Map()))
}
// 从依赖集合中获取对应的 key 的依赖
let dep = depsMap.get(key)
if (!dep) {
// 如果 key 不存在,将这个 key 作为依赖收集起来,并初始化 value 为 new Set()
depsMap.set(key, (dep = new Set()))
}
// 如果依赖中并不存当前的 effect 副作用函数
if (!dep.has(activeEffect)) {
// 将当前的副作用函数收集进依赖中
dep.add(activeEffect)
// 并在当前副作用函数的 deps 属性中记录该依赖
activeEffect.deps.push(dep)
}
}
看完 track 继续看我们的示例:
effect(() => (foo = counter.num))
当 track 收集完依赖后,get 陷阱返回了 Reflect.get 的结果,读取到了 counter.num 的值为 0,并将此结果赋值给 foo 变量。此时副作用 函数第一次运行结束,foo 已经有了值:0。当副作用函数执行完,会将当前的副作用函数弹出栈中,并且将 activeEffect 赋值为 undefeind。
trigger 派发更新
搞懂了依赖收集之后,继续来看派发更新的过程。
示例的最后一行代码,将 num 赋值为 7。
counter.num = 7
我们知道 foo 一定会同步更新为 7 的。那么过程是怎样的呢?
当对 counter.num 赋值时,会触发 set 陷阱:
const result = Reflect.set(target, key, value, receiver)
if (target === toRaw(receiver)) {
if (!hadKey) {
// 当 key 不存在时,触发 trigger 的 ADD 事件
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
// 当 key 存在时,当新旧值变化后,触发 trigger 的 SET 事件
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
一起来看 set 陷阱的部分代码,trigger 的触发会传入一个 TriggerOpTypes 的枚举,枚举有四种类型,对应增、删、改、清空操作。
export const enum TriggerOpTypes {
SET = 'set',
ADD = 'add',
DELETE = 'delete',
CLEAR = 'clear'
}
由于 counter 通过 reactive api 创建代理对象时已经添加了 num 这个 key,所以此时新旧值发生改变,就会触发 SET 事件。
接着会执行 trigger 函数。
trigger 函数会立即从 targetMap 中通过 target 获取 depsMap,如果没有对应的 depsMap 就代表当前的 traget 从未通过 track 进行依赖收集,所以直接 return,不继续执行。
接着会创建一个名为 effects 的 Set 结构的集合,它的作用是存储这个 key 所有需要派发更新执行的副作用函数。
同时声明一个 add 函数,add 函数的作用是遍历传入的副作用函数,将不是当前正在执行的 activeEffect 函数或者能够自我执行的副作用函数都加入到 effects 集合中。
然后会判断清空依赖和数组的特殊情况,按需调用 add 函数添加依赖。
之后会判断当前 key 是否不为 undefined,注意这里的判断条件 void 0,是通过 void 运算符的形式表示 undefined,如果有 key 则将 key 相关的依赖通过 add 函数添加进 effects 集合中。
随后的 Switch Case 通过区分 triggerOpTypes 来处理一些迭代键的特殊逻辑。
之后声明的 run 函数作用就是来执行添加入 effects 数组中的副作用函数。
trigger 函数的结尾就是通过 effects.forEach(run)
遍历集合内的所有副作用函数并执行。
先一起来看一下 trigger 的代码:
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// 该 target 从未被追踪,不继续执行
return
}
// effects 集合存放所有需要派发更新的副作用函数。
const effects = new Set<ReactiveEffect>()
// 将不是当前副作用函数以及能执行自身的副作用函数加入集合中
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || effect.allowRecurse) {
effects.add(effect)
}
})
}
}
if (type === TriggerOpTypes.CLEAR) {
// 当需要清除依赖时,将当前 target 的依赖全部传入
depsMap.forEach(add)
} else if (key === 'length' && isArray(target)) {
// 处理数组的特殊情况
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
add(dep)
}
})
} else {
// 在 SET | ADD | DELETE 的情况,添加当前 key 的依赖
if (key !== void 0) {
add(depsMap.get(key))
}
// 对 ADD | DELETE | Map.SET 执行一些迭代键的逻辑
switch (type) { /* 暂时忽略 */ }
}
// 执行 effect 的函数
const run = (effect: ReactiveEffect) => {
// 判断是否有调度器,如果有则执行调度函数并将 effect 作为参数传入
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
// 否则直接执行副作用函数
effect()
}
}
// 遍历集合,执行收集到的副作用函数
effects.forEach(run)
}
在将 SwitchCase 的特殊逻辑,以及 DEV 环境的特殊逻辑隐藏后,trigger 函数的长度已经比较精简且逻辑清晰了。
回到我们的示例,当在 trigger 判断是否有 key,并将 key 对应的依赖传入 add 函数时,示例在 track 时被收集的副作用函数已经被 effects 集合获取到了。当 trigger 执行到最后一行代码时,副作用函数就会当做参数被传入 run 函数,由于没有设置调度器,所以会直接执行这个副作用函数:() => (foo = counter.num)
,执行完毕,foo 的值成功的被更新到 7。
至此收集依赖和派发更新的流程已经完整的结束,而本文的示例也运行完毕了,相信大家对这个过程也有了印象深刻的认识。如果还是有点犯迷糊,建议将本文 effect, track 和 trigger 的函数,以及上文 get、set 的陷阱源码联系起来再看看,相信你会豁然开朗的。
总结
本篇文章中,笔者先给大家详细讲解了副作用已经副作用函数的生成过程以及执行时机。又通过一个简单的示例引出依赖收集和派发更新的过程,在将这两个部分时,结合上文中讲过的 get 和 set 这两个代理对象的 hanlders 陷阱将流程完整的串在一起,按照示例执行的流程给大家讲完了整个依赖收集和派发更新的过程。
这也解答了文章开头提到的问题:Vue 是如何追踪变化的?通过 track 收集副作用的依赖,并在 trigger 时执行对应的副作用函数完成更新。
最后,如果这篇文章能够帮助到你了解 Vue3 中的响应式的副作用以及依赖收集和派发更新的流程,希望能给本文点一个喜欢❤️。如果想继续追踪后续文章,也可以关注我的账号或 follow 我的 github,再次谢谢各位可爱的看官老爷。