响应式原理:副作用函数探秘
我们说到了 [Reactive]{.mark} 会在 [proxy
getter]{.mark} 的时候收集 [effect]{.mark} 依赖,在 [proxy
setter]{.mark} 的时候触发 [effect]{.mark} 的执行。那么 [effect]{.mark} 副作用函数到底是个什么?以及是如何被收集起来的呢?
前言
我们说到了 [Reactive]{.mark} 会在 [proxy
getter]{.mark} 的时候收集 [effect]{.mark} 依赖,在 [proxy
setter]{.mark} 的时候触发 [effect]{.mark} 的执行。那么 [effect]{.mark} 副作用函数到底是个什么?以及是如何被收集起来的呢?
effect
找到源码中关于 [effect]{.mark} 部分的定义:
export function effect (fn, options) {
// 如果 fn 已经是一个 effect 函数了,则指向原始函数
if (fn.effect) {
fn = fn.effect.fn
}
// 构造 _effect 实例
const _effect = new ReactiveEffect(fn)
// options 初始化
if (options) {
extend(_effect, options)
if (options.scope) recordEffectScope(_effect, options.scope)
}
// 如有 options 或者 不是懒加载,执行 _effect.run()
if (!options || !options.lazy) {
_effect.run()
}
// 返回 _effect.run
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
这个 [effect]{.mark} 函数内部核心是通过 [ReactiveEffect]{.mark} 类创建了一个 [_effect]{.mark} 实例,从代码来看,[_effect]{.mark} 上包含了一个 [run]{.mark} 函数。默认 [effect]{.mark} 是没有传入 [options]{.mark} 参数的,所以这里直接执行了 [_effect.run()]{.mark}。我们知道,[fn]{.mark} 函数是在 [effect]{.mark} 函数中的一个入参,比如:
const state = reactive({a: 1})
effect(() => console.log(state.a))
根据上一小节,我们知道因为这里我们访问了 [state.a]{.mark} 所以收集了副作用函数,但是需要知道的是这里的 [effect]{.mark} 传入的是一个 [fn]{.mark},所以要想访问 [state.a]{.mark} 那这个 [fn]{.mark} 必须要执行才可以。那是在哪里执行的呢?接下来看一下 [ReactiveEffect]{.mark} 的实现:
// 用于记录位于响应上下文中的effect嵌套层次数
let effectTrackDepth = 0
// 二进制位,每一位用于标识当前effect嵌套层级的依赖收集的启用状态
export left trackOpBit = 1
// 表示最大标记的位数
const maxMarkerBits = 30
// 当前活跃的 effect
let activeEffect;
export class ReactiveEffect {
// 用于标识副作用函数是否位于响应式上下文中被执行
active = true
// 副作用函数持有它所在的所有依赖集合的引用,用于从这些依赖集合删除自身
deps = []
// 指针为,用于嵌套 effect 执行后动态切换 activeEffect
parent = undefined
// ...
run() {
// 若当前 ReactiveEffect 对象脱离响应式上下文
// 那么其对应的副作用函数被执行时不会再收集依赖
if (!this.active) {
return this.fn()
}
// 缓存是否需要收集依赖
let lastShouldTrack = shouldTrack
try {
// 保存上一个 activeEffect 到当前的 parent 上
this.parent = activeEffect
// activeEffect 指向当前的 effect
activeEffect = this
// shouldTrack 置成 true
shouldTrack = true
// 左移操作符 << 将第一个操作数向左移动指定位数
// 左边超出的位数将会被清除,右边将会补零。
// trackOpBit 是基于 1 左移 effectTrackDepth 位
trackOpBit = 1 << ++effectTrackDepth
// 如果未超过最大嵌套层数,则执行 initDepMarkers
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
cleanupEffect(this)
}
// 这里执行了 fn
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
// 用于对曾经跟踪过,但本次副作用函数执行时没有跟踪的依赖采取删除操作。
// 新跟踪的 和 本轮跟踪过的都会被保留
finalizeDepMarkers(this)
}
// << --effectTrackDepth 右移动 effectTrackDepth 位
trackOpBit = 1 << --effectTrackDepth
// 返回上个 activeEffect
activeEffect = this.parent
// 返回上个 shouldTrack
shouldTrack = lastShouldTrack
// 情况本次的 parent 指向
this.parent = undefined
}
}
}
大致看一眼,我们可以看到在 [ReactiveEffect]{.mark} 中是执行了 [this.fn()]{.mark} 的,这也就解释了 [effect]{.mark} 中的回调函数 [fn]{.mark} 是在这里被调用的。接下来详细研究一下这个 [ReactiveEffect]{.mark}。
但这段代码看起来不是很长,但涉及了好几个概念,我们来一个个看。
1. parent 的作用
为什么 [ReactiveEffect]{.mark} 要设计一个 [parent]{.mark} 这样一个看似没啥用的变量指针来存储上一次的 [activeEffect]{.mark} 呢?如果改成下面这样不是更简单吗?
run() {
if (!this.active) {
return this.fn();
}
// 初始化
shouldTrack = true;
activeEffect = this;
const result = this.fn();
// 重置
shouldTrack = false;
return result;
}
其实对于下面这样的代码:
const state = reactive({a: 1})
effect(() => console.log(state.a))
state.a++
[effect]{.mark} 函数内调用 [ReactiveEffect]{.mark} 实例的 [run]{.mark} 函数。[run]{.mark} 函数执行的时候,把 [activeEffect]{.mark} 指向 [this]{.mark}。然后执行 [effect]{.mark} 传入的 [fn]{.mark} 函数,函数在执行的时候访问了 [state.a]{.mark} 触发了 [getter]{.mark} 钩子。回顾一下上一节的内容,[getter]{.mark} 的时候有触发添加 [activeEffect]{.mark} 的功能:
// 把 activeEffect 添加到 dep 中
dep.add(activeEffect!)
而 [activeEffect]{.mark} 正是这里的 [this]{.mark}。当执行 [state.a++]{.mark} 时,访问了[state.a]{.mark} 的 [setter]{.mark}。上一节也说了,[setter]{.mark} 的执行会调用 [effect.run]{.mark} 函数:
// triggerEffects
effect.run();
所以又会执行 [fn]{.mark}。
到这里看似很完美,那么我们再来看另外一个例子🌰:
const state = reactive({
a: 1,
b: 2
});
// ef1
effect(() => {
// ef2
effect(() => console.log(`b: ${state.b}`))
console.log(`a: ${state.a}`)
});
state.a ++
按照上面的逻辑,在第一次 [effect]{.mark} 执行的时候,[activeEffect =
ef1]{.mark} 然后再执行内部的 [effect]{.mark}, 此时 [activeEffect =
ef2]{.mark} 然后 [ef2]{.mark} 执行完成回到 [ef1]{.mark} 函数体内,此时再访问 [state.a]{.mark} 触发对 [a]{.mark} 的依赖收集,但收集到的却是 [ef2]{.mark}。那么最终打印的是:
b: 2
a: 1
b: 2
很明显不符合我们的要求,我们期望的是输出:
b: 2
a: 1
b: 2
a: 2
这时候 [parent]{.mark} 就排上用场了,当为 [effect]{.mark} 加上 [parent]{.mark} 属性后,我们再来捋一下整体的流程。
执行
ef1 的时候,
activeEffect 指向
ef1,此时
parent 是
undefined。
执行
ef1 fn 遇到了
ef2,调用
ef2 此时
ef2 的
parent 指向
ef1,
activeEffect 指向
ef2。然后执行
ef2 的 fn。
ef2 的 fn 执行的时候,访问了
state.b 依赖收集
ef2。执行完成后,
activeEffect = this.parent 又把
activeEffect 指向了
ef1。
返回
ef1 的
fn 体继续执行,此时访问
state.a 依赖收集
activeEffect 为
ef1。
触发
state.a 的
setter,调用
a 的副作用
ef1,依次打印……
到这里相信各位小伙伴已经清楚了 [parent]{.mark} 的作用了,那就是通过 [parent]{.mark} 这个标记,来回切换 [activeEffect]{.mark} 的指向,从而完成对嵌套 [effect]{.mark} 的正确的依赖收集。
2. 依赖清理
在说依赖清理之前,再来看一个有意思的例子:
const state = reactive({
a: 1,
show: true
});
effect(() => {
if (state.show) {
console.log(`a: ${state.a}`)
}
});
state.a ++
setTimeout(() => {
state.show = false
state.a ++
}, 1000)
上面的例子中,我们在 [effect]{.mark} 中完成了对 [show]{.mark} 和 [a]{.mark} 的依赖收集,然后 [1s]{.mark} 后,我们改变了 [show]{.mark} 的状态为 [false]{.mark}。此时 [effect]{.mark} 内的函数中的 [console.log]{.mark} 将永远不会执行,然后再触发 [state.a++]{.mark} 的动作,访问 [a]{.mark} 的 [getter]{.mark},如果没有依赖清理,那么按照之前的做法,测试也会触发 [effect.fn]{.mark} 的执行,但这个执行其实没意义的,因为 [a]{.mark} 已经没有被使用了,是一个永远不会被访问到的变量,造成了性能浪费。所以我们需要删除 [a]{.mark} 的副作用函数,让它不要执行。
接下来一起来看看 [Vue]{.mark} 是怎么做的吧!这里涉及到的内容有点多,我们先一个个解释,首先补习一下关于 [js]{.mark} 的一些操作符的基础知识。
1. 左移(<<)
左移操作符 ([<<]{.mark}) 将第一个操作数转换成 2
进制后向左移动指定位数,左边超出的位数将会被清除,右边将会补零。
const a = 1; // 00000000000000000000000000000001
const b = 1;
console.log(a << b); // 00000000000000000000000000000010
// expected output: 2
2. 位或操作(|)
位或操作符(|), 如果两位之一为 1,则设置每位为 1。
const a = 5; // 00000000000000000000000000000101
const b = 3; // 00000000000000000000000000000011
console.log(a | b); // 00000000000000000000000000000111
// expected output: 7
3. 按位与(&)
按位与运算符 ([&]{.mark})
在两个操作数对应的二进位都为 [1]{.mark} 时,该位的结果值才为 [1]{.mark},否则为 [0]{.mark}。
const a = 5; // 00000000000000000000000000000101
const b = 3; // 00000000000000000000000000000011
console.log(a & b); // 00000000000000000000000000000001
// expected output: 1
4. 按位非(~)
按位非运算符(~),反转操作数的位。
const a = 5; // 00000000000000000000000000000101
const b = -3; // 11111111111111111111111111111101
console.log(~a); // 11111111111111111111111111111010
// expected output: -6
console.log(~b); // 00000000000000000000000000000010
// expected output: 2
有了这些基础的知识点后,再来认识几个变量。
1. effectTrackDepth
用于记录位于响应上下文中的 [effect]{.mark} 嵌套层次数,默认值为 [0]{.mark}。
// effectTrackDepth = 0
effect(() => {
// effectTrackDepth = 1
effect(() => {})
})
2. trackOpBit
二进制位,每一位用于标识当前 [effect]{.mark} 嵌套层级的依赖收集的启用状态。默认值为 [1]{.mark},即 [00000000000000000000000000000001]{.mark}。
3. maxMarkerBits
表示最大的 [effect]{.mark} 嵌套的层次数,最大值为 [30]{.mark}。
好了,搞懂了这些操作符之后,我们来看看 [Vue]{.mark} 的依赖清理是如何实现的,先来看不超过 [maxMarkerBits]{.mark} 层级数的嵌套 [effect]{.mark} 的依赖收集的过程,还以上面那个 [demo]{.mark} 作为示例:
const state = reactive({
a: 1,
show: true
});
effect(() => {
if (state.show) {
console.log(`a: ${state.a}`)
}
});
state.a ++
setTimeout(() => {
state.show = false
state.a ++
}, 1000)
Step 1:[run]{.mark} 函数执行的时候,[trackOpBit = 1 <<
++effectTrackDepth]{.mark} 这个语句执行完成后,得到 [effectTrackDepth =
1]{.mark};[trackOpBit.toString(2) =
00000000000000000000000000000010]{.mark}。
Step 2:因为 [effectTrackDepth <
maxMarkerBits]{.mark} ,所以执行 [initDepMarkers]{.mark} 函数,因为这里的 [deps]{.mark} 在初始化的时候还是个空数组,所以此函数未执行。
export const initDepMarkers = ({ deps }) => {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit // set was tracked
}
}
}
Step
3:执行 [this.fn]{.mark} 函数,先访问 [state.show]{.mark},触发了 [trackEffects]{.mark}。
export function trackEffects(dep) {
let shouldTrack = false
if (effectTrackDepth <= maxMarkerBits) {
// 如果本轮副作用函数执行过程中已经访问并收集过,则不用再收集该依赖
if (!newTracked(dep)) {
// 设置 dep.n
dep.n |= trackOpBit
shouldTrack = !wasTracked(dep)
}
} else {
// Full cleanup mode.
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
}
}
这里需要额外了解 2
个函数:[wasTracked]{.mark}(已经被收集过,缩写是 [w]{.mark})
和 [newTracked]{.mark}(新收集的依赖,缩写是 [n]{.mark})。
export const wasTracked = dep => (dep.w & trackOpBit) > 0
export const newTracked = dep => (dep.n & trackOpBit) > 0
进入 [trackEffects]{.mark} 时,因为此时还没有为 [dep.n]{.mark} 进行或运算赋值,所以 [state.show]{.mark} 的 [newTracked
= false]{.mark},[wasTracked = false]{.mark}。
所以计算得到 [shouldTrack =
true]{.mark},最后将 [activeEffect]{.mark} 收集进入 [dep]{.mark} 中,同时执行了 [activeEffect.deps.push(dep)]{.mark} 将 [dep]{.mark} 存入了 [activeEffect]{.mark} 的 [deps]{.mark} 中。然后访问 [state.a]{.mark} 重复上述操作。上述步骤执行完成后的 [activeEffect.deps]{.mark} 如下:
[
{"w":0,"n": 00000000000000000000000000000010, [effect]},
{"w":0,"n": 00000000000000000000000000000010, [effect]}
]
Step 4:最后执行 [finalizeDepMarkers]{.mark} 函数,根据第 3
步,此时 [effect]{.mark} 中的 [deps]{.mark} 包含了 2
个 [dep]{.mark},分别是 [state.show]{.mark} 和 [state.a]{.mark}。 [finalizeDepMarkers]{.mark} 函数内部执行了 [wasTracked]{.mark}(已经被收集过,缩写是 [w]{.mark})
和 [newTracked]{.mark}(新收集的依赖,缩写是 [n]{.mark})
函数,因为 [dep.w = 0]{.mark} 所以 [wasTracked = false]{.mark}。
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
}
}
因为 [wasTracked =
false]{.mark},因此 [finalizeDepMarkers]{.mark} 处理后仍然将副作用函数保留在这两个属性对应的依赖集合中,同时把 [dep.w]{.mark} 和 [dep.n]{.mark} 重置回
0。
[{"w":0, "n":0, [effect]},{"w":0, "n":0,
[effect]}]
Step 5:当执行 [state.show =
false]{.mark} 的时候,触发 [effect.run]{.mark} 的执行,此时执行 [initDepMarkers]{.mark} 时,因为已经存在了 [dep]{.mark},所以先访问 [state.show]{.mark}。
当执行到 [trackEffects]{.mark} 时,此时的 [newTracked =
false]{.mark},执行逻辑和之前一致。只不过因为 [state.show =
false]{.mark},所以没有触发 [state.a]{.mark} 的这一部分逻辑的处理,最后得到的结果为:
[
{
"w": 00000000000000000000000000000010,
"n": 00000000000000000000000000000010,
[effect]
},
{
"w": 00000000000000000000000000000010,
"n": 0,
[effect]
}
]
Step 6:最后执行 [finalizeDepMarkers]{.mark} 时,如下。
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(effect)
}
因为这里的 [state.a]{.mark} 的 [wasTracked =
true]{.mark} 且 [newTracked]{.mark} 为 [false]{.mark},所以执行了 [dep.delete(effect)]{.mark} 将 [effect]{.mark} 从 [dep]{.mark} 中踢掉。
Step
7:[1s]{.mark} 后执行 [state.a++]{.mark} 的操作,由于 [state.a]{.mark} 中没有 [effect]{.mark} 了,所以不会执行副作用函数。
总结: [Vue]{.mark} 在组件的 [effect]{.mark} 执行之前,会根据 [dep]{.mark} 的收集标记位 [w]{.mark} 和 [n]{.mark} 来进行清理依赖,删除之前 [state.a]{.mark} 收集的 [effect]{.mark} 依赖。这样当我们修改 [state.a]{.mark} 时,由于已经没有依赖了,就不会触发 [effect]{.mark} 重新执行。
另外,为了更容易帮助小伙伴们理解上述的流程,我也精心制作了一个动画演示上述流程和过程:
注意,当 [effectTrackDepth]{.mark} 大于 [30]{.mark} 时,会调用 [cleanup]{.mark} 来清理依赖,其实 [cleanup]{.mark} 的原理就是依赖收集前全部删除所有的 [dep]{.mark},依赖收集时再一个个加进来,这个性能其实是比较差的,所以 [Vue
3.2]{.mark} 改成了通过二进制标记位的方式来选择性删除和添加,提升了性能。关于这部分更多的细节,可以参考[这个PR]{.mark}。
总结
到这里,我们基本上讲完了 [Vue
3]{.mark} 的响应式原理基础,如果有小伙伴了解 [Vue
2]{.mark} 的响应式原理,应该清楚 [Vue2]{.mark} 的响应式原理可以理解成如下一幅图:
在 [Vue
2]{.mark} 中,[Watcher]{.mark} 就是依赖,有专门针对组件渲染的 [render
watcher]{.mark}。
依赖收集:组件在
render 的时候会访问模板中的数据,触发
getter 把
watcher 作为依赖收集。
触发渲染:当修改数据时,会触发
setter,通知
watcher 更新,进而触发了组件的重新渲染。
相应地,在 [Vue 3]{.mark} 中的响应式流程如下:
可以看到,[Vue 3]{.mark} 相对于 [Vue
2]{.mark} 的响应式差别不大,主要就是劫持数据的方式改成用 [Proxy]{.mark} 实现,以及收集的依赖由 [watcher]{.mark} 实例变成了组件副作用函数 [effect]{.mark}。另外,值得一提的是 [Vue
3]{.mark} 在响应式设计上又多考虑了层级嵌套的依赖收集问题和不必要的依赖清理问题。