一、什么是依赖?
在看一些关于Vue的资料时,经常都能看到依赖收集和依赖更新的字样,那么什么是依赖? 在Vue3中,关于依赖的定义如下:
export type Dep = Set<ReactiveEffect> & TrackedMarkers // 依赖定义
type TrackedMarkers = {w: numbern: number
}
可以看出来,依赖本质上就是一个ReactiveEffect的Set集合。关于TrackedMarkers参数,在介绍依赖收集优化时分析。
二、ReactiveEffect对象详解
上面提到,一个Dep依赖就是一个ReactiveEffect集合。那么ReactiveEffect对象到底是什么?接下来我将详细介绍这个对象以及响应式系统的实现原理。
1.主要对象简要关系图
在上面描述依赖时,完全没有提到响应式对象。而每次提及依赖更新和依赖收集,都是在读写响应式对象的时候。它们之间的关系如下图:
- 关系A:一个响应式对象,总能通过某种方式与Dep依赖进行关联,讲解依赖收集时详解
- 关系B:一个Dep依赖是一组ReactiveEffect的集合,注意这儿是双向关系,一个ReactiveEffect对象也保存了一个Dep依赖数组。
2.核心源码
export class ReactiveEffect<T = any> {active = true // 是否激活deps: Dep[] = [] // 保存Deps数组parent: ReactiveEffect | undefined = undefined // 父reactiveEffect节点computed?: ComputedRefImpl<T> // 是否是计算属性allowRecurse?: boolean // 是否允许递归依赖收集private deferStop?: boolean // 是否异步停止依赖收集onStop?: () => void // 停止依赖收集时调用的回调constructor( public fn: () => T,public scheduler: EffectScheduler | null = null,scope?: EffectScope // 所属响应式域 ) {recordEffectScope(this, scope)}run() {if (!this.active) {// 未激活,直接调用fn回调,没有依赖收集return this.fn()}// 保存前一个激活的activeEffect和收集状态let parent: ReactiveEffect | undefined = activeEffectlet lastShouldTrack = shouldTrack// 遍历激活的reactiveEffect链,如果自身已经存在于链中,则退出,避免无限递归while (parent) {if (parent === this) {return}parent = parent.parent}try {// 设置父activeEffect对象this.parent = activeEffect// 设置当前激活activeEffect对象activeEffect = this// 收集状态置为trueshouldTrack = true// 收集轮次bit值-每一轮左移一位trackOpBit = 1 << ++effectTrackDepthif (effectTrackDepth <= maxMarkerBits) {// 标记当前依赖initDepMarkers(this)} else {// 直接清空当前依赖cleanupEffect(this)}// 调用回调fnreturn this.fn()} finally {if (effectTrackDepth <= maxMarkerBits) {// 处理最终依赖finalizeDepMarkers(this)}// bit值右移一位trackOpBit = 1 << --effectTrackDepth// 激活activeEffect还原为父对象activeEffect = this.parent// 还原父对象的收集状态shouldTrack = lastShouldTrack// 父对象值空this.parent = undefinedif (this.deferStop) {// 是否异步停止this.stop()}}}stop() {if (activeEffect === this) {// 异步停止收集依赖this.deferStop = true} else if (this.active) {// 清理依赖cleanupEffect(this)if (this.onStop) {// 调用设置的回调this.onStop()}// 激活状态置为falsethis.active = false}}
}
在介绍计算属性时,我说过计算属性是基于现有响应式对象而衍生出来的,它的实现代码中就有创建ReactiveEffect对象的流程,现在我就以一个计算属性来详解这个类。讲解代码如下:
const data = ref(1)
computed(() => {return data.value + 1
})
计算属性相关核心代码片段如下:
this.effect = new ReactiveEffect(getter, () => {// 当这个计算属性的依赖变更时,这个匿名方法被执行if (!this._dirty) {// 将数据标识为脏数据,下一次读取时重新计算this._dirty = true// 触发依赖更新,虽然计算属性在依赖数据变更时不主动计算,// 但需要通知依赖于当前计算属性的EffectReactive对象执行响应回调triggerRefValue(this)}
})
- 构造函数从ReactiveEffect的构造方法和计算属性创建ReactiveEffect对象源码可知,构造函数的fn回调方法就是getter方法,而schedule方法就是设置计算属性为脏数据的匿名方法。* run方法run方法是重点,主要做了如下几件事。* 保存当前activeEffect对象和收集状态* 标记当前所有已经存在的依赖为已收集* 调用fn回调收集依赖,这些依赖被标记为新收集* 将被标记为已收集但不是新收集的依赖移除掉* 还原activeEffect对象和收集状态
- stop方法stop方法很简单,就是停止依赖收集,将ReactiveEffect对象设置为未激活,如果对象是activeEffect对象,则表明当前正在收集依赖,则转换为异步停止。### 3.单一ReactiveEffect简易流程图
针对计算属性而言,这个流程相对而言较为清晰了,主要流程如下:
在计算属性这种简单场景下,只存在一个ReactiveEffect对象,因此流程是比较好整理的。但在Vue3中,ReactiveEffect对象往往是会存在多个的,但激活的只能有一个。
4.嵌套的ReactiveEffect场景
在Vue3中,一个组件在被编译完后,会有一个render函数,这个render函数本身就是ReactiveEffect方法的fn参数,所以组件才会响应式变化。如果执行render时使用了计算属性,则表明在执行一个回调fn的同时,又创建了一个新的ReactiveEffect对象,此时便形成了ReactiveEffect链。
我以如下示例举例:
const data1 = ref(1)
const data2 = ref(2)
effect(/*fn1*/() => {// ReactiveEffect1console.log('调用fn1',data1.value)effect(/*fn2*/() => {// ReactiveEffect2console.log('调用fn2',data2.value)})
})
在这个示例中,ReactiveEffect1调用fn1,data1对应的依赖与ReactiveEffect1进行一个双向收集,ReactiveEffect2调用fn2,data2对应的依赖与ReactiveEffect2进行一个双向收集。简单来说,哪个ReactiveEffect调用了回调方法,那么回调里的响应式对象就只与这个ReactiveEffect对象进行依赖收集。
那么为什么要保存父ReactiveEffect对象和相应的依赖收集状态呢?看看下面的示例:
const data1 = ref(1)
const data2 = ref(2)
effect(/*fn1*/() => {// ReactiveEffect1console.log('调用fn1',data1.value)effect(/*fn2*/() => {// ReactiveEffect2console.log('调用fn2',data2.value)})console.log('调用fn1',data2.value) // 只新加一行代码
})
在ReactiveEffect2依赖收集结束后,又继续执行fn1,此时还是要继续进行依赖收集的,所以在执行完成ReactiveEffect2的依赖收集后,需要还原ReactiveEffect1的收集状态及激活的ReactiveEffect对象,此时ReactiveEffect1与data2所对应依赖进行双向依赖收集。
5. 如何避免无限递归调用
如果理解了上述嵌套ReactiveEffect对象的执行,那么可以得到如下的一个模型:
现在以D对象为例,当执行D对象的run方法时,进行依赖收集,则d与D进行关联,d变更时,D需要执行对应回调。
那么考虑如下一个场景,读取d对象的同时,修改了d对象,是否会进行无限递归调用?比如如下场景:
const data1 = ref(1)
effect(/*fn1*/() => {// ReactiveEffect1console.log('调用fn1',data1.value)// 修改值data1.value++
})
答案是不会触发,因为在调用D对象的run方法时,会首先判断D对象是否在ReactiveEffect链中,在则直接退出。相关代码如下:
// 保存前一个激活的activeEffect和收集状态
let parent: ReactiveEffect | undefined = activeEffect// 此时activeEffect是D
let lastShouldTrack = shouldTrack
// 遍历激活的reactiveEffect链,如果自身已经存在于链中,则退出,避免无限递归
while (parent) {if (parent === this) {判断满足,退出return}parent = parent.parent
}
这儿使用了循环判断,这是由于父ReactiveEffect的响应式调用,会导致子ReactiveEffect的响应式调用,所以当前activeEffect对象的所有祖先元素都不能触发依赖更新。参考如下模型:
在执行D的run方法导致d修改时,原本应该触发D和A执行响应式回调,D在前文说了,不会触发,那么A如果执行响应式回调,最终会导致D执行响应式回调,所以activeEffect对象的所有递归父级ReactiveEffect都不能执行响应式回调。
三、依赖收集与依赖更新
在上文的介绍中,依赖收集与依赖更新本质上就是让响应式对象与ReactiveEffect对象进行关联,这样当响应式对象修改时,就能触发对应ReactiveEffect对象的响应回调方法。
1.依赖收集核心源码
收集依赖,简单来说就是针对响应式对象创建一个依赖并且存储起来。收集依赖分为2类,下面依次介绍。
- reactive对象的依赖收集在介绍Vue3响应式对象-reactive时,我提到过reactive对象是使用代理实现的,它是一个普通对象的封装。在开发中,使用reactive对象时,只有在读写其对应属性才能被代理拦截到,因此本质是针对属性去创建依赖。接下来分析其核心源码:* 创建依赖或查找依赖
// 查找或创建依赖export function track(target: object, type: TrackOpTypes, key: unknown) {// 是否可以收集以及是否存在activeEffect对象if (shouldTrack && activeEffect) {// 以target对象为key从targetMap中depsMaplet depsMap = targetMap.get(target)if (<img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c4bd44f67956491689dfa0c5f6bad7b7~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)通过这种结构,就可以轻松的通过对象及属性找到一个关联的Dep* **收集依赖**// 收集依赖export function trackEffects( dep: Dep,debuggerEventExtraInfo?: DebuggerEventExtraInfo ) {let shouldTrack = false// 收集轮次bit值是否在最大值之下if (effectTrackDepth <= maxMarkerBits) {if (!newTracked(dep)) {// 当前依赖打上新增标识dep.n |= trackOpBit // set newly tracked// 判断是否还需要收集,因为之前可能已经收集过shouldTrack = !wasTracked(dep)}} else {// Full cleanup mode.shouldTrack = !dep.has(activeEffect!)}// 满足条件则双向收集if (shouldTrack) {dep.add(activeEffect!)activeEffect!.deps.push(dep)if (DEV && activeEffect!.onTrack) {activeEffect!.onTrack({effect: activeEffect!,…debuggerEventExtraInfo!})}}} ```当找到一个依赖后,便需要进行收集,核心便是activeEffect与Dep对象进行一个双向添加。理论上只需要Dep对象添加activeEffect便可,双向添加和上面获取shouldTrack标识有关,在后续的依赖收集优化做介绍。* ref系列对象的依赖收集和reactive对象不一致的地方是ref系列对象都直接把Dep依赖保存到对象内,省去了查找依赖的那一步,而收集依赖都是一致的。ref系列对象包括ref对象,计算属性和异步计算属性。那么为什么要这么设计呢?早期的设计其实并非如此,是和reactive对象的收集为同一套逻辑,将ref对象作为target,value属性作为key,这样从逻辑上讲也不存在问题,但现实是可能存在性能上的隐患。当一个ref对象是针对一个大对象的包装时,此时的value属性就会比较大。上面说过,reactive对象的收集通过DepsMap和key获取Dep,当key很大时,会导致DepsMap哈希表会特别大,因此可能存在浪费内存的隐患。### 2.依赖更新核心源" style=“margin: auto” />
依赖更新的本质就是当响应式对象变更后,找到对应的Dep对象,使得其中所有的ReactiveEffect对象触发响应回调。
核心代码如下:
export function triggerEffects( dep: Dep | ReactiveEffect[],debuggerEventExtraInfo?: DebuggerEventExtraInfo ) {// spread into array for stabilizationconst effects = isArray(dep) ? dep : [...dep]for (const effect of effects) {if (effect.computed) {// 计算属性要提前置为脏数据triggerEffect(effect, debuggerEventExtraInfo)}}for (const effect of effects) {if (!effect.computed) {triggerEffect(effect, debuggerEventExtraInfo)}}
}
function triggerEffect( effect: ReactiveEffect,debuggerEventExtraInfo?: DebuggerEventExtraInfo ) {// 如果激活对象是当前对象,除非允许递归,否则不触发if (effect !== activeEffect || effect.allowRecurse) {if (__DEV__ && effect.onTrigger) {effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))}// 有scheduler则调用if (effect.scheduler) {effect.scheduler()} else {// 否则调用runeffect.run()}}
}
触发依赖的代码省略掉了查找Dep依赖的过程,本质是保存依赖的逆查找过程,仅针对reactive对象。只不过新增一些额外依赖添加,比如一个数组的原长度是10,现在修改为5,不仅需要触发length属性的修改,还需要触发下标为5-9的数组元素的删除。当找到依赖后,就需要触发回调调用,计算属性要提前置为脏数据,保证数据的正确性,然后调用其余ReactiveEffect对象的schedule回调或者run回调。
四、依赖收集优化
如果细心一点,可能会发现触发依赖更新时,可能调用run方法。我们知道,收集依赖在调用run方法之后,触发依赖更新可能又会调用run方法,此时会存在一个问题,依赖的重复收集,依赖收集的优化就在于如何去处理这个重复收集的问题。
在前文介绍过,ReactiveEffect对象的依赖收集是链式的,因此Vue使用位操作来标记链中ReactiveEffect对象的依赖,层次每深一层,左移一位。
打上标记的地方在于run方法内。核心代码如下:
// 收集轮次bit值-每一轮左移一位
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {// 标记当前依赖initDepMarkers(this)} else {// 直接清空当前依赖cleanupEffect(this)
}
// 标记代码
export const initDepMarkers = ({ deps }: ReactiveEffect) => {if (deps.length) {for (let i = 0; i < deps.length; i++) {deps[i].w |= trackOpBit // set was tracked}
}
// 依赖定义
export type Dep = Set<ReactiveEffect> & TrackedMarkers
type TrackedMarkers = {w: numbern: number
}
在上述代码中,maxMarkerBits等于30,这是由于js在处理位操作时比较特殊,当1 << 31时便是负数,因此最大只能是30。initDepMarkers本质上就是给当前所有的依赖打上一个标记,表明这些依赖是已经被收集的。在依赖收集时,有一段代码如下:
// 收集轮次bit值是否在最大值之下
if (effectTrackDepth <= maxMarkerBits) {// 是否已经被新收集if (!newTracked(dep)) {// 当前依赖打上新增标识dep.n |= trackOpBit // set newly tracked// 判断是否还需要收集,因为之前可能已经收集过shouldTrack = !wasTracked(dep)}
} else {// Full cleanup mode.shouldTrack = !dep.has(activeEffect!)
}
这段代码首先判断依赖是否已经被新收集,这是防止重复访问同一个响应式对象导致依赖重复收集。如果没有则打上新收集的标识,然后判断是否是已经被收集的依赖,如果不是则表示需要收集。在依赖收集结束后,还有最后的清理无用依赖操作,在run方法的finally块内,如下:
if (effectTrackDepth <= maxMarkerBits) {// 处理无用依赖finalizeDepMarkers(this)
}
// bit值右移一位,还原
trackOpBit = 1 << --effectTrackDepth
// 处理方法
export const finalizeDepMarkers = (effect: ReactiveEffect) => {const { deps } = effectif (deps.length) {let ptr = 0for (let i = 0; i < deps.length; i++) {const dep = deps[i]// 被打上已收集,但不是新增收集的,则需要删除if (wasTracked(dep) && !newTracked(dep)) {dep.delete(effect)} else {deps[ptr++] = dep}// clear bitsdep.w &= ~trackOpBitdep.n &= ~trackOpBit}deps.length = ptr}
}
这段代码主要就是将上一次收集的依赖,但这一次没有收集的给移除掉。
接下来我以如下示例来解释上述依赖的优化:
let dep1 = ref(1)
let dep2 = ref(2)
let dep3 = ref(3)
let data = 0
let status = ref(false)
effect(() => {data = status.value ? dep2.value + dep3.value : dep1.value
})
expect(data).toBe(1)
status.value = true
expect(data).toBe(5)
当status是false时,此时ReactiveEffect读取了dep1,则dep1对应的Dep依赖被置为新收集,当收集结束后,由于初始依赖列表为空,所以没有需要移除的依赖。
当status是true时,此时dep1对应的Dep依赖被置为已收集,dep2和dep3对应的Dep依赖被置为新收集,收集结束后由于dep1对应的Dep依赖不是新收集,则需要移除。
简单来说就是ReactiveEffect对象内保存的Dep列表只和最新一次收集的依赖有对应关系,历史的Dep数据需要被清理掉。
当effectTrackDepth <= maxMarkerBits不满足时,则直接把现有的依赖列表清空,这样每次收集到的数据都是最新数据,且不用做移除处理,但这种方式不是推荐方式,因为每次清空收集会浪费性能。
最后
最近还整理一份JavaScript与ES的笔记,一共25个重要的知识点,对每个知识点都进行了讲解和分析。能帮你快速掌握JavaScript与ES的相关知识,提升工作效率。




有需要的小伙伴,可以点击下方卡片领取,无偿分享
本文深入探讨Vue3中的响应式原理,详细解析依赖与ReactiveEffect对象。从依赖的定义、ReactiveEffect的构造、核心源码分析,到依赖收集与更新的过程,再到优化策略,全面揭示Vue3响应式系统的实现机制。同时,文章通过实例说明如何避免无限递归调用,并探讨依赖收集的优化方法。

2514

被折叠的 条评论
为什么被折叠?



