Vue3 源码解析:依赖收集与副作用函数
Posted Originalix
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue3 源码解析:依赖收集与副作用函数相关的知识,希望对你有一定的参考价值。
在上一篇文章《响应式原理与 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 开始依赖收集。(
以上是关于Vue3 源码解析:依赖收集与副作用函数的主要内容,如果未能解决你的问题,请参考以下文章
Vue3 源码解析:setup 揭秘与 expose 的妙用
Vue3官网-高级指南(十七)响应式计算`computed`和侦听`watchEffect`(onTrackonTriggeronInvalidate副作用的刷新时机`watch` pre)(代码片段