上章问题分析
// vue/examples/reactivity/problem.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="../../dist/vue.js"></script>
<style>
</style>
</head>
<body>
</body>
<script>
const { reactive, effect } = Vue
const obj = reactive({
name: 'echo',
ok: true,
})
effect(() => {
// 打印三次
console.log('effect run')
document.body.innerText = obj.ok ? obj.name : 'not'
})
obj.ok = false
obj.name = 'yahaa'
</script>
</html>
分支切换
要解决上一章的问题,我首先需要引入一个概念——分支切换。在上面的代码中,我们可以看到effectFn内部存在着一个三元表达式,会根据字段obj.ok值的不同执行不同的代码分支,这就是所谓的分支切换。
而分支切换就为我们的问题埋下的伏笔。拿上面的代码来说,字段obj.ok的初始值为true,这时会读取字段obj.text的值,所以当effectFn函数执行时会触发字段obj.ok和字段obj.text这个两个属性的读取操作,副作用函数也就与这个两个属性建立了联系。但是当obj.ok的值改为false后,并触发副作用函数执行后,此时字段obj.text不会被读取,理想状态下副作用函数effectFn不应该与字段obj.text建立联系,但是我们知道程序并不如我们所预期的那样,obj.ok被改为false后,我们继续修改obj.text的值,由于前一次obj.text读取操作与副作用函数effectFn建立的关联还存在于“桶”中,仍会导致副作用函数被重新执行,这时就产生了遗留的副作用函数。这也就是为什么effect run会被打印三次。
cleanup
解决这个问题的方法就是每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。
ReactiveEffect
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此需要我们重新设计副作用函数。如下面代码所示,我们定义了一个ReactiveEffect类,并为其添加了deps属性,用来存储所有包含当前副作用函数的依赖集合:
// reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined
export class ReactiveEffect<T = any> {
// 用于存储所有与该副作用函数相关联的依赖集合
deps: Dep[] = []
constructor(public fn: () => T) {
}
run() {
// 当调用effect注册副作用函数时,将这个类赋值给activeEffect
activeEffect = this!
// 执行副作用函数
return this.fn()
}
}
// 用于注册副作用函数
export function effect<T = any>(fn: () => T) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
trackEffects
ReactiveEffect中的deps依赖集合我们在track函数收集:
// reactivity/src/effect.ts
export function track(target: object, key: unknown) {
if (!activeEffect)
return
let depsMap = targetMap.get(target)
if (!depsMap)
targetMap.set(target, (depsMap = new Map()))
// 参照vue源码修改为dep
let dep = depsMap.get(key as string)
if (!dep)
depsMap.set(key as string, (dep = createDep())) // 修改
trackEffects(dep)
}
export function trackEffects(dep: Dep) {
// 将当前激活的副作用函数收集到桶中
dep.add(activeEffect!)
// dep就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到activeEffect.deps数组中
activeEffect!.deps.push(dep)
}
// reactivity/src/dep.ts
import type { ReactiveEffect } from './effect'
export type Dep = Set<ReactiveEffect>
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
return dep
}
在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合dep中,这说明dep是一个与当前副作用函数存在联系的一来就集合。于是我们也把dep添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。
有了对依赖集合的收集,我们就可以在每次副作用函数执行时,在ReactiveEffect对象属性deps中获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:
// reactivity/src/effect.ts
export class ReactiveEffect<T = any> {
// 用于存储所有与该副作用函数相关联的依赖集合
deps: Dep[] = []
constructor(public fn: () => T) {
}
run() {
// 调用cleanupEffect函数完成清除工作
cleanupEffect(this)
activeEffect = this!
return this.fn()
}
}
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++)
deps[i].delete(effect)
deps.length = 0
}
}
export function trigger(target: object, key: unknown) {
const depsMap = targetMap.get(target)
if (!depsMap)
return
const effects = depsMap.get(key as string)
effects && effects.forEach(effect => effect.run()) //修改
}
死循环
至此我们响应系统已经可以避免副作用函数产生遗留了。但是当我们尝试运行代码的时候,会发现当前的实现会导致死循环,问题出现在trigger中:
export function trigger(target: object, key: unknown) {
const depsMap = targetMap.get(target)
if (!depsMap)
return
const effects = depsMap.get(key as string)
effects && effects.forEach(effect => effect.run()) //问题在这一行
}
在trigger函数内部,我们遍历effects集合,它是一个Set集合,里面存储着副作用函数。当副作用函数执行的时候,会调用cleanupEffect从effects集合中将当前执行的副作用函数剔除,但是副作用的执行会导致其被重新被收集到集合中,而此时effects集合的遍历仍会继续进行。在语言规范中对此情况有说明,在调用forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。而想要解决这个问题,只需要我们构造另外一个Set集合并遍历它就可以了。
export function trigger(target: object, key: unknown) {
const depsMap = targetMap.get(target)
if (!depsMap)
return
// 修改
const deps = depsMap.get(key as string)
// 新增
const effects: ReactiveEffect[] = []
deps?.forEach((dep) => {
if (dep)
effects.push(dep)
})
effects.forEach(effect => effect.run())
}
编译后再运行文章开头的代码,我们就可以看到effect run只被打印了两次。