梳理 effect
effect 的作用可以先简单理解成:建立响应式数据和副作用函数之间的关系。
当函数执行时读取了某个响应式属性,就把这个函数记录到该属性的依赖集合里。等这个属性后续发生变化时,再把对应的函数重新执行。
建立依赖关系
先从一个最简单的结构开始:
const data = {
a: 1,
b: 2,
c: 3
}
function track(target, key) {
}
function trigger(target, key) {
}
const state = new Proxy(data, {
get(target, key) {
track(target, key)
return Reflect.get(target, key)
},
set(target, key, value) {
trigger(target, key)
return Reflect.set(target, key, value)
}
})
function effect(fn) {
fn()
}
effect(() => {
console.log(state.a)
})
这段代码里,get 拦截可以拿到 target 和 key,也就是知道读取的是哪个对象上的哪个属性。
但依赖关系还差另一半:当前这个属性是被哪个函数读取的?
比如 state.a 是在 effect 传入的函数里读取的,所以在执行 track 时,需要知道当前正在执行的副作用函数是谁。
解决方式也很直接:定义一个全局变量 activeEffect,用它记录当前正在执行的副作用函数。
let activeEffect = null
const depsMap = new Map()
function track(target, key) {
if (activeEffect) {
let deps = depsMap.get(target)
if (!deps) {
deps = new Set() // 可能会依赖多个函数
depsMap.set(key, deps)
}
// 将依赖的函数添加到集合中
deps.add(activeEffect)
}
}
function trigger(target, key) {
const deps = depsMap.get(key)
if (deps) {
deps.forEach(effect => effect())
}
}
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
effect(() => {
console.log('执行了')
console.log(state.a)
})
state.a = 2
这里用 depsMap 保存依赖关系。
每个属性对应一个 Set 集合,集合里保存所有依赖这个属性的函数。修改某个属性时,只需要从 depsMap 中找到对应的集合,再依次执行里面的函数。
像上面的示例,读取的是 state.a,所以收集到的依赖是这样:
// depsMap:
// Map(1) { 'a' => Set(1) { [Function (anonymous)] } }
// Map(1) { 'a' => Set(1) { [Function (anonymous)] } }

分支切换的问题
effect(() => {
if (state.a === 1) {
state.b
}
else {
state.c
}
})
第一次执行时,会读取 state.a 和 state.b,所以依赖关系里会记录 a -> effect 和 b -> effect。
问题出现在后续重新执行时。
如果直接把原始的回调函数放进 depsMap,那么 trigger 执行这个函数时,并不会重新设置 activeEffect
。函数虽然重新执行了,但这次执行过程中读取到的新属性无法被重新收集。
所以不能只把传入的 fn 当作依赖函数。这里需要再包一层执行环境,让这个环境函数负责在每次执行前设置 activeEffect
,执行完成后再清空。
function effect(fn) {
const environment = () => {
activeEffect = environment
fn()
activeEffect = null
}
environment()
}
包一层之后,重新执行时确实可以继续收集依赖。
但新的问题也随之出现:旧依赖没有被清理。
比如第一次走 if 分支时收集了 b,后面切到 else 分支后又收集了 c。如果不清理旧依赖,b 仍然会留在 depsMap 里,后续修改
b 时,这个副作用函数还是会被触发。
打印出来的结果里,就会同时出现 b 和 c:
// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] } }
// Map(2) {
// 'a' => Set(1) { [Function: environment] },
// 'b' => Set(1) { [Function: environment] }
// }
// effect 函数执行了
// Map(2) {
// 'a' => Set(1) { [Function: environment] },
// 'b' => Set(1) { [Function: environment] }
// }
// Map(3) {
// 'a' => Set(1) { [Function: environment] },
// 'b' => Set(1) { [Function: environment] },
// 'c' => Set(1) { [Function: environment] }
// }
// 进程已结束,退出代码为 0
因此,每次执行副作用函数之前,都需要先把它上一次收集到的依赖清理掉,然后再根据本次执行结果重新收集。
要做到这一点,需要让环境函数知道自己曾经被收集到哪些集合中。
先在环境函数上挂一个 deps 数组,用来记录它关联过的所有依赖集合:
function effect(fn) {
const environment = () => {
activeEffect = environment
cleanup(environment)
fn()
activeEffect = null
}
environment.deps = [] // 记录该环境函数在哪些集合中
environment()
}
然后在 track 中,每次把 activeEffect 收集到某个 deps 集合时,也反向把这个 deps 记录到 activeEffect.deps 中。
function track(target, key) {
// 在进行依赖收集的时候,建立数据与函数的一个关系
if (activeEffect) {
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
console.log(depsMap)
}
有了这层反向记录,清理时就可以直接遍历 environment.deps,把当前环境函数从每个集合中删除。
如果某个集合删完后已经没有任何函数了,也可以把这个属性从 depsMap 中移除。
function cleanup(environment) {
const deps = environment.deps
if (deps.length) {
deps.forEach((dep) => {
dep.delete(environment)
if (dep.size === 0) {
for (const [key, value] of depsMap) {
if (value === dep) {
depsMap.delete(key)
}
}
}
})
deps.length = 0
}
}
清理之后,再执行同样的分支切换逻辑,依赖关系就会变成当前分支真正需要的样子:
// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
// Map(2) {
// 'a' => Set(1) { [Function: environment] { deps: [Array] } },
// 'b' => Set(1) { [Function: environment] { deps: [Array] } }
// }
// effect 函数执行了
// Map(1) { 'a' => Set(1) { [Function: environment] { deps: [Array] } } }
// Map(2) {
// 'a' => Set(1) { [Function: environment] { deps: [Array] } },
// 'c' => Set(1) { [Function: environment] { deps: [Array] } }
// }
无限循环的问题
继续看下面这个例子:
effect(() => {
console.log('effect1 函数执行了')
if (state.a === 1) {
return state.b
}
else {
return state.c
}
})
effect(() => {
state.a
state.c
})
state.a = 2
这里修改 state.a 时,会触发依赖 a 的副作用函数重新执行。
问题在于,副作用函数执行前会先清理旧依赖,执行过程中又会重新收集依赖。也就是说,在遍历原来的依赖集合时,这个集合本身可能又被删除和重新添加。
如果直接遍历原始的 deps,就可能在同一次触发过程中反复执行同一个副作用函数,最终造成无限循环。
解决方式是:触发更新前,先复制一份当前要执行的副作用函数集合。后面真正遍历执行的是这份拷贝,而不是原始的 deps。
function trigger(target, key, value) {
// 设置的时候将对应关联的函数重新执行
const deps = depsMap.get(key)
if (deps) {
const effectToRun = new Set(deps)
effectToRun.forEach(effect => effect())
}
}
这样即使副作用函数执行过程中重新修改了原始依赖集合,也不会影响当前这轮更新的遍历。
effect 嵌套
最后再看一个嵌套 effect 的场景:
effect(() => {
effect(() => {
state.a
console.log('effect1')
effect(() => {
state.c
})
})
state.b
console.log('effect2')
})
这里会出现依赖收集错乱的问题。
原因是 activeEffect 只有一个。执行到内部 effect 时,内部函数会覆盖外层的 activeEffect;内部执行完成后,又会把
activeEffect 清空。这样回到外层继续执行时,外层后续读取到的属性就收集不到正确的依赖了。
这个问题和函数调用栈很像:进入一个函数时入栈,函数执行完成后出栈,然后恢复到上一个正在执行的函数。
所以这里也可以维护一个 effectStack,用它记录当前正在执行的 effect 栈。
const effectStack = []
function effect(fn) {
const environment = () => {
activeEffect = environment
effectStack.push(environment)
cleanup(environment)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
environment.deps = []
environment()
}
这样内部 effect 执行完成后,可以从栈中恢复外层的 effect,后续依赖收集也就能回到正确的上下文。