这篇博客需要对响应式对象有一定的理解。如果不熟悉响应式对象,可以先看Vue3响应式对象-reactive,Vue3响应式对象-ref,Vue3响应式对象-计算属性和异步计算属性
在看一些关于Vue的资料时,经常都能看到依赖收集和依赖更新的字样,那么什么是依赖?
在Vue3中,关于依赖的定义如下:
export type Dep = Set<ReactiveEffect> & TrackedMarkers // 依赖定义
type TrackedMarkers = {
w: number
n: number
}
可以看出来,依赖本质上就是一个ReactiveEffect的Set集合。关于TrackedMarkers参数,在介绍依赖收集优化时分析。
上面提到,一个Dep依赖就是一个ReactiveEffect集合。那么ReactiveEffect对象到底是什么?接下来我将详细介绍这个对象以及响应式系统的实现原理。
在上面描述依赖时,完全没有提到响应式对象。而每次提及依赖更新和依赖收集,都是在读写响应式对象的时候。它们之间的关系如下图:
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 = activeEffect
let lastShouldTrack = shouldTrack
// 遍历激活的reactiveEffect链,如果自身已经存在于链中,则退出,避免无限递归
while (parent) {
if (parent === this) {
return
}
parent = parent.parent
}
try {
// 设置父activeEffect对象
this.parent = activeEffect
// 设置当前激活activeEffect对象
activeEffect = this
// 收集状态置为true
shouldTrack = true
// 收集轮次bit值-每一轮左移一位
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
// 标记当前依赖
initDepMarkers(this)
} else {
// 直接清空当前依赖
cleanupEffect(this)
}
// 调用回调fn
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
// 处理最终依赖
finalizeDepMarkers(this)
}
// bit值右移一位
trackOpBit = 1 << --effectTrackDepth
// 激活activeEffect还原为父对象
activeEffect = this.parent
// 还原父对象的收集状态
shouldTrack = lastShouldTrack
// 父对象值空
this.parent = undefined
if (this.deferStop) {
// 是否异步停止
this.stop()
}
}
}
stop() {
if (activeEffect === this) {
// 异步停止收集依赖
this.deferStop = true
} else if (this.active) {
// 清理依赖
cleanupEffect(this)
if (this.onStop) {
// 调用设置的回调
this.onStop()
}
// 激活状态置为false
this.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对象,则表明当前正在收集依赖,则转换为异步停止。
针对计算属性而言,这个流程相对而言较为清晰了,主要流程如下:
在计算属性这种简单场景下,只存在一个ReactiveEffect对象,因此流程是比较好整理的。但在Vue3中,ReactiveEffect对象往往是会存在多个的,但激活的只能有一个。
在Vue3中,一个组件在被编译完后,会有一个render函数,这个render函数本身就是ReactiveEffect方法的fn参数,所以组件才会响应式变化。如果执行render时使用了计算属性,则表明在执行一个回调fn的同时,又创建了一个新的ReactiveEffect对象,此时便形成了ReactiveEffect链。
我以如下示例举例:
const data1 = ref(1)
const data2 = ref(2)
effect(/*fn1*/() => {
// ReactiveEffect1
console.log('调用fn1',data1.value)
effect(/*fn2*/() => {
// ReactiveEffect2
console.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*/() => {
// ReactiveEffect1
console.log('调用fn1',data1.value)
effect(/*fn2*/() => {
// ReactiveEffect2
console.log('调用fn2',data2.value)
})
console.log('调用fn1',data2.value) // 只新加一行代码
})
在ReactiveEffect2依赖收集结束后,又继续执行fn1,此时还是要继续进行依赖收集的,所以在执行完成ReactiveEffect2的依赖收集后,需要还原ReactiveEffect1的收集状态及激活的ReactiveEffect对象,此时ReactiveEffect1与data2所对应依赖进行双向依赖收集。
如果理解了上述嵌套ReactiveEffect对象的执行,那么可以得到如下的一个模型:
现在以D对象为例,当执行D对象的run方法时,进行依赖收集,则d与D进行关联,d变更时,D需要执行对应回调。
那么考虑如下一个场景,读取d对象的同时,修改了d对象,是否会进行无限递归调用?比如如下场景:
const data1 = ref(1)
effect(/*fn1*/() => {
// ReactiveEffect1
console.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对象的响应回调方法。
收集依赖,简单来说就是针对响应式对象创建一个依赖并且存储起来。收集依赖分为2类,下面依次介绍。
reactive对象的依赖收集
在介绍Vue3响应式对象-reactive时,我提到过reactive对象是使用代理实现的,它是一个普通对象的封装。在开发中,使用reactive对象时,只有在读写其对应属性才能被代理拦截到,因此本质是针对属性去创建依赖。
接下来分析其核心源码:
创建依赖或查找依赖
// 查找或创建依赖
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 是否可以收集以及是否存在activeEffect对象
if (shouldTrack && activeEffect) {
// 以target对象为key从targetMap中depsMap
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 以属性为key从depsMap中获取依赖Dep
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined
// 收集依赖
trackEffects(dep, eventInfo)
}
}
如下示例代码阐述:
let data = reactive({
name:'demo'
})
data.name
当访问data.name时,会被代理的get方法拦截,get方法能获取到data这个代理对象的原始对象,这个原始对象也就是入参的target对象,name属性也就是参数key。这样就构成了如下的数据存储结构:
通过这种结构,就可以轻松的通过对象及属性找到一个关联的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标识有关,在后续的依赖收集优化做介绍。
依赖更新的本质就是当响应式对象变更后,找到对应的Dep对象,使得其中所有的ReactiveEffect对象触发响应回调。
核心代码如下:
export function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const 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 {
// 否则调用run
effect.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: number
n: 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 } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// 被打上已收集,但不是新增收集的,则需要删除
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(effect)
} else {
deps[ptr++] = dep
}
// clear bits
dep.w &= ~trackOpBit
dep.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不满足时,则直接把现有的依赖列表清空,这样每次收集到的数据都是最新数据,且不用做移除处理,但这种方式不是推荐方式,因为每次清空收集会浪费性能。
Vue3响应式系统的总结到此结束,后续会逐步更新关于Vue3的其他系统模块。
作者:sunsetFeng
链接:https://juejin.cn/post/7170969266814976007
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。