什么是依赖关系的增量更新?考虑下面的代码
const isValid = ref(true)
const count1 = ref(0)
const count2 = ref(0)
watchEffect(() => {
if(isValid.value) {
console.log(count1.value)
}else {
console.log(count2.value)
}
})
从上面的代码可以看到,当isValid === true时,此时有两个link
- link1: isValid-watch
- link2: count1-watch
当isValid变更为false时,此时还是有两个link
- link1: isValid-watch
- link3: count2-watch
可以看到,当isValid变更时,需要将link2从原来的链表中删除,并且将link3加到链表中。版本计数的主要作用就是实现这个变更链表的逻辑。
为什么要删除link2?当count1不再与watch有关联,那么count1变更时就不要再执行watch,减少不必要的副作用函数执行。
整个版本计数的逻辑中涉及到的version主要有四个:
- Dep.version
- Link.version
- 全局的globalVersion
- Computed.globalVersion
前置知识:new Link时,构造函数初始化link.version = dep.version。
export class Link {
constructor(
public sub: Subscriber,
public dep: Dep,
) {
this.version = dep.version
}
}
从实际代码出发,当isValid被变更为false时,触发响应式数据的setter拦截:
// packages/reactivity/src/ref.ts
class RefImpl {
set value(newValue) {
this.dep.trigger()
}
}
// packages/reactivity/src/dep.ts
trigger(debugInfo?: DebuggerEventExtraInfo): void {
this.version++
globalVersion++
this.notify(debugInfo)
}
此时dep.version+1,globalVersion+1,接着调用notify。
// packages/reactivity/src/dep.ts
notify(debugInfo?: DebuggerEventExtraInfo): void {
startBatch()
try {
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
;(link.sub as ComputedRefImpl).dep.notify()
}
}
} finally {
endBatch()
}
}
上述代码包裹在startBatch和endBatch之间,这里面使用了一个batchDepth的概念,主要作用是考虑下面的场景:
statBatch() // batchDepth = 1
statBatch() // batchDepth = 2
statBatch() // batchDepth = 3
endBatch() // batchDepth = 2
statBatch() // batchDepth = 3
endBatch() // batchDepth = 2
endBatch() // batchDepth = 1
endBatch() // batchDepth = 0
当出现上面的场景时,延迟endBatch的逻辑,只有最外围的endBatch才开始正式的逻辑,点进endBatch可以看到
if (--batchDepth > 0) { return }
目前出现嵌套batchDepth的场景主要是嵌套computed,考虑以下场景:
const count1 = ref(0)
const c1 = computed(() => {
return count1.value + 1
})
const c2 = computed(() => {
return c1.value + 1
})
const c3 = computed(() => {
return c2.value + 1
})
const c4 = computed(() => {
return c3.value + 1
})
function increase() {
count1.value++
}
出现这样的场景就会将c1,c2,c3,c4都添加到batchedSub后才会执行endBatch逻辑。
startbatch后循环当前的所有sub并执行其notify函数,notify函数的作用是将当前subs链表上的内容添加到batchedSub这个链表中,batchedSub是全局的,并且考虑上述batchDepth的情况,batchedSub接入的不止一个响应式数据的subs。
如果notify返回true,说明执行的是ComputedRefImpl.notify,也就是当前sub是一个computed。执行ComputedRefImpl.notify会将当前ComputedRefImpl添加到全局的batchedComputed链中。进入if执行ComputedRefImpl.dep的notify函数,将ComputedRefImpl的dep链中的副作用函数添加到batchedSub链表中。考虑以下场景:
const count = ref(0)
const computed1 = computed(() => {
return count + 1
})
watchEffect(() => {
console.log(count)
})
watchEffect(() => {
console.log(computed1)
})
当count变更时,computed1作为count对应的sub会执行,此时computed1的结果被更改,那么computed1对应的watchEffect也需要被执行。
endBatch遍历batchedSub执行Subscriber的trigger函数,trigger函数执行runIfDirty。
// packages/reactivity/src/effect.ts
runIfDirty(): void {
if (isDirty(this)) {
this.run()
}
}
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true
}
}
if (sub._dirty) {
return true
}
return false
}
isDirty判断当前副作用函数是否应该执行。判断方式是遍历sub.deps,判断link.versin和link.dep.version是否相同,只要有一个不同,就说明有一个响应式数据是有变更的,那么就需要执行。上面dep.trigger函数已经把dep.version+1,那么此时对于ref创建的响应式数据来说,link.dep.version !== link.version
副作用函数中不仅有使用ref,还可能使用computed,对于判断computed数据是否有改变,需要额外的逻辑:
export function refreshComputed() {
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
try {
prepareDeps(computed)
const value = computed.fn(computed._value)
if (dep.version === 0 || hasChanged(value, computed._value)) {
computed._value = value
dep.version++
}
} catch (err) {
dep.version++
throw err
} finally {
cleanupDeps(computed)
}
}
首先,computed.globalVersion一开始被赋值为globalVersion-1,所以没进if,如果computed.globalVersion === globalVersion说明没有响应式数据发生变化,那么computed肯定也没有变化。
- prepareDeps(computed)将所有关联的link都置为-1;
- 然后执行computed函数,将触发了getter的响应式数据对应的link.version重置;
- 如果返回值与旧值不一致,那么computed.dep.version++;
- 执行cleanupDeps,将等于-1的link从链表中移除。
(refreshComputed(link.dep.computed) || link.dep.version !== link.version
如果computed.dep.version++,那么外层的link.dep.version !== link.version成立。
isDirty返回true,然后执行this.run()
run(): T {
if (!(this.flags & EffectFlags.ACTIVE)) {
return this.fn()
}
this.flags |= EffectFlags.RUNNING
cleanupEffect(this)
prepareDeps(this)
const prevEffect = activeSub
const prevShouldTrack = shouldTrack
activeSub = this
shouldTrack = true
try {
return this.fn()
} finally {
cleanupDeps(this)
activeSub = prevEffect
shouldTrack = prevShouldTrack
this.flags &= ~EffectFlags.RUNNING
}
}
这个函数是实现依赖关系的增量更新的关键,主要代码在于
- prepareDeps
- this.fn
- cleanupDeps
function prepareDeps(sub: Subscriber) {
for (let link = sub.deps; link; link = link.nextDep) {
link.version = -1
link.prevActiveLink = link.dep.activeLink
link.dep.activeLink = link
}
}
首先prepareDeps将sub.deps链表中的所有link.version置为-1。
紧接着执行this.fn:
watchEffect(() => {
if(isValid.value) {
console.log(count1.value)
}else {
console.log(count2.value)
}
})
当isValid = false,上述代码link1,link2的version被置为-1,紧接着再次执行watchEffct内的副作用函数,此时isValid,count2的响应式getter被触发:
// packages/reactivity/src/dep.ts
track() {
if (link === undefined || link.sub !== activeSub) {}
else if(link.version === -1) {
link.version = this.version
}
}
此时isValid对应的link1.version === -1,link1.version再被重置为dep.version。link2.version === -1,没有触发getter,还是-1,link3之前没有创建,此时会新建一个Link。
最后执行cleanupDeps
function cleanupDeps(sub: Subscriber) {
let tail = sub.depsTail
let link = tail
while (link) {
const prev = link.prevDep
if (link.version === -1) {
removeSub(link)
removeDep(link)
}
}
}
遍历sub.deps,将所有link.version === -1的找出来,这些就是在新一轮watchEffect执行后,没有触发getter的响应式数据,也就是此时这些响应式数据是没有作用的,那么就需要将关联到这些dep的link从链表中移除,之前了解过,link是一个双链表的结构,所以这里也需要处理两条链表的删除:
- removeSub(link)
- removeDep(link)
这么做的好处不言而喻,比如上面的例子,count1与watchEffect的关系link被解除,那么当count1发生变更时就不会再次执行watchEffect的副作用函数,减少了不必要的函数执行。