Vue3 源码系列文章会持续更新,全部文章请查阅我的掘金专栏。
本系列的文章 demo 存放于我的 Github 仓库,推荐大家下载和调试,进而加深理解。
一. 依赖清理
如下方代码所示,在副作用函数中使用了一个三元表达式,页面会根据 obj.isShowMsg
的变化展示 obj.msg
或默认文案:
const obj = reactive({ isShowMsg: true, msg: "It's message from obj." });
const defaultText = 'Message has been hidden :(';
setViewEffect(() => {
div.innerText = obj.isShowMsg ? obj.msg : defaultText;
});
obj.isShowMsg = false;
obj.msg = 'Changed message.' // 此处其实没必要再触发副作用函数
当 obj.isShowMsg
被更改为 false
时,obj.msg
的变更对于副作用函数的结果是没有任何影响的,没有必要触发 trigger
方法来执行一遍副作用函数,而我们目前的响应式实现并没有达成该预期。
这是因为每一轮副作用函数执行时,前一轮副作用函数执行过程所追踪的依赖未被清除,也会跟着被触发。
上面的示例只会导致执行多一次冗余的副作用函数,但在其它情况下,可能会导致严重的错误:
const arr = reactive(['a', 'b', 'c']);
setViewEffect(() => {
arr.push('d');
div.innerText = arr.reduce((prev, cur) => (prev + cur))
});
arr[2] = null; // Maximum call stack size exceeded
该示例会陷入死循环,其执行流程如下:
- 副作用函数首次执行时(第一轮),
reduce
方法会访问length
属性和数组元素索引(0
-3
),对它们进行依赖收集; - 副作用函数外部的
arr[2] = null
执行时会触发trigger
并查找凭证为2
的依赖(上一步已收集到),从而触发副作用函数的执行; - 副作用函数第二次执行(第二轮),
arr.push('d')
会触发trigger
并查找凭证为length
的依赖(第一轮已收集到),从而触发副作用函数的执行; - 副作用函数第三次执行(第三轮),
arr.push('d')
会触发trigger
并查找凭证为length
的依赖(第一轮已收集到),从而触发副作用函数的执行; - ...
要解决此问题,我们应当在每一轮副作用执行之前,清除上一轮执行中所收集的依赖,来避免副作用函数递归调用自身的情况发生。
对于存储对象依赖的容器 targetMap
来说,每个凭证(属性值)对应的 Set
集合中,可能包含有多个不同的副作用函数,如果需要反过来,通过副作用函数来获取 targetMap
中包含了自己的凭证依赖,进而解除依赖关系,那我们需要补充上这层映射关系。
在 Vue 中对此的处理是,赋予每个副作用函数一个 deps
属性,用来存储引用了自身的依赖,并在副作用函数执行前,遍历 deps
中存储的依赖集合,从中删除自己。
我们通过面向对象的形式来重构一下 setViewEffect
方法:
// 删除
// let viewEffect;
// export const setViewEffect = (fn) => {
// viewEffect = fn;
// fn();
// }
let activeEffect; // 新增,用于存储被激活的副作用函数实例
// 新增
class ReactiveEffect {
constructor(fn) {
this.fn = fn; // 存储副作用函数
this.deps = []; // 存储使用了该副作用函数的依赖
}
run() {
activeEffect = this;
shouldTrack = true;
cleanupEffect(this); // 执行副作用函数前,清除以前的引用
return this.fn();
}
}
// 重构原本的 setViewEffect,
// 因为不再只是单纯地替换 viewEffect,故更名为 effect
export const effect = (fn) => {
const _effect = new ReactiveEffect(fn);
_effect.run();
}
// 新增,遍历以前收集到的依赖,从中清除当前副作用函数
// 从而避免当前副作用函数触发了自己的调用,形成死循环
function cleanupEffect(effect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
}
track 和 trigger 中的相关改动:
export const track = (target, key) => {
// ...
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
// dep.add(viewEffect); // 删除
trackEffects(dep); // 新增
}
// 新增
export function trackEffects(dep){
let shouldTrack = !dep.has(activeEffect); // 判断依赖是否已添加过副作用函数实例
if (shouldTrack) {
dep.add(activeEffect);
activeEffect.deps.push(dep); // 将依赖存入 activeEffect.deps
}
}
export const trigger = (target, key, type, newValue) => {
// ...
// 删除
// viewEffects.forEach(effectFn => {
// shouldTrack = true;
// effectFn && effectFn()
// });
triggerEffects(viewEffects); // 新增
}
// 新增
export function triggerEffects(dep){
const depArray = isArray(dep) ? dep : [...dep];
for (const effect of depArray) {
effect.run();
}
}
这里的重点是,在 track
方法中会执行 trackEffects
,来将依赖存入其副作用函数实例的 deps
属性。
接着在副作用函数执行前(调用副作用函数实例的 run
方法之前),会调用 cleanupEffect
方法,遍历当前副作用函数实例的 deps
属性,取出依赖并从中移除自身,进而排除了当前副作用函数触发自身调用的可能性。
二. 迭代器方法拦截
在上一章我们使用了 ownKeys
拦截器,对 for...in
等方法进行了拦截处理,细心的读者会发现 for...of
方法并不会在该拦截器中被拦截。
这是因为 for...of
的作用是遍历可迭代对象,而非读取对象自身属性,这不符合 ownKeys
的拦截规则,自然也不会被拦截到。
for...of
所遍历的可迭代对象,指的是内部实现了 Symbol.iterator
迭代器方法的对象。
例如数组就内建了 Symbol.iterator
方法:
const arr = ['a', 'b'];
const itr = arr[Symbol.iterator]();
console.log(itr.next()); // { value: 'a', done: false }
console.log(itr.next()); // { value: 'b', done: false }
console.log(itr.next()); // { value: undefined, done: true }
在通过迭代器遍历可迭代对象的过程中,会访问该对象的 Symbol.iterator
属性,该属性是一个 symbol
值,实际上无需对其进行追踪处理,例如 for...of
这类的迭代器方法执行时,会访问数组的 length
属性和索引值(细节),它们已足够对依赖进行收集了。
因此从性能上考虑,我们应当屏蔽掉对 Symbol.iterator
的追踪。
拦截迭代器方法并屏蔽其 Symbol.iterator
的追踪,只需要在 get
拦截器中做简单的处理。
改动如下:
/** baseHandlers.js **/
const isSymbol = (val) => {
return typeof val === 'symbol';
}
// 新增,获取 Symbol 对象内建的所有 symbol 方法
const builtInSymbols = new Set(
Object.getOwnPropertyNames(Symbol)
.map(key => (Symbol)[key])
.filter(isSymbol)
)
function createGetter() {
return function get(target, key, receiver) {
// ...
const res = Reflect.get(target, key, receiver);
// 新增,判断是否原生内置的 symbol
if (isSymbol(key) && builtInSymbols.has(key)) {
return res; // 绕过 track
}
track(target, key);
// ...
}
}
💡
Object.getOwnPropertyNames(Symbol)
返回Symbol
对象的所有自身属性的属性名组成的数组,通过调用.map(key => (Symbol)[key])
会返回Symbol
对象的所有属性对应的值的数组:
[ 0, 'Symbol', Symbol, ƒ, ƒ, Symbol(Symbol.asyncIterator),
Symbol(Symbol.hasInstance), Symbol(Symbol.isConcatSpreadable),
Symbol(Symbol.iterator), Symbol(Symbol.match), Symbol(Symbol.matchAll),
Symbol(Symbol.replace), Symbol(Symbol.search), Symbol(Symbol.species),
Symbol(Symbol.split), Symbol(Symbol.toPrimitive),
Symbol(Symbol.toStringTag), Symbol(Symbol.unscopables) ]
三. 嵌套 effect
我们目前所实现的 effect
还无法支持嵌套的场景:
<body>
<div id="effect1"></div>
<div id="effect2"></div>
</body>
<script type="module">
import { reactive } from 'https://codepen.io/vajoy/pen/wvyWGjw.js';
import { effect } from 'https://codepen.io/vajoy/pen/qBxNZVx.js';
const div1 = document.querySelector('#effect1');
const div2 = document.querySelector('#effect2');
const data = reactive({
msg1: 'msg1',
msg2: 'msg2'
})
effect(function effectFn1() {
console.log('running effect1...')
effect(function effectFn2() {
console.log('running effect2...') // 打印了两次
div2.innerText = data.msg2;
});
div1.innerText = data.msg1;
});
data.msg1 = 'msg1 changes..'; // 没有触发 effectFn1 执行
</script>
上方示例会调用一次 effectFn1
、两次 effectFn2
,可以猜测到 data.msg1
的变更触发了 effectFn2
的执行,而非实际对其进行追踪的 effectFn1
。
这是因为在每一个副作用函数执行前,都会把全局性质的、存放当前激活态的副作用实例的 activeEffect
重写了:
/** effect.js **/
let activeEffect;
class ReactiveEffect {
constructor(fn) {
this.fn = fn;
this.deps = [];
}
run() {
activeEffect = this; // 问题处
shouldTrack = true;
cleanupEffect(this);
return this.fn();
}
}
这会导致 effectFn2
执行前,activeEffect
被指向了 effectFn2
副作用函数的实例。紧接着对 data.msg1
进行追踪时,所收集到的依赖就变成了 effectFn2
的实例,而非 effectFn1
的实例:
/** 收集依赖 **/
export function trackEffects(dep) {
let shouldTrack = !dep.has(activeEffect);
if (shouldTrack) {
dep.add(activeEffect); // 添加依赖项
activeEffect.deps.push(dep);
}
}
解决此问题的关键,是为 effect
实例加上父级引用的链式信息:
/** effect.js **/
let activeEffect;
class ReactiveEffect {
constructor(fn) {
this.fn = fn;
this.deps = [];
this.parent = undefined; // 新增
}
run() {
// 新增
let parent = activeEffect;
let lastShouldTrack = shouldTrack;
while (parent) {
// 如果自己内部嵌套了自己,退出 run 执行
// 确保当前副作用函数最多只执行一次
if (parent === this) {
return;
}
parent = parent.parent;
}
try {
this.parent = activeEffect; // 加上父级信息
activeEffect = this;
shouldTrack = true;
cleanupEffect(this);
return this.fn();
} finally {
activeEffect = this.parent; // 执行完重置 activeEffect 为父级实例
shouldTrack = lastShouldTrack; // 执行完恢复父级的 shouldTrack 状态
this.parent = undefined; // 执行完重置父级属性,避免污染未来的再次调用
}
// 删除
// activeEffect = this;
// shouldTrack = true;
// cleanupEffect(this);
// return this.fn();
}
}
我们可以把多层副作用函数执行的流程,看作切洋葱:
从父层进入子层时(例如 effectFn2
-> effectFn3
),需要存下父层的信息(父级的 parent
和 shouldTrack
),当子层执行完毕返回父层时(例如 effectFn3
-> effectFn2
),恢复父层的信息供父层执行。
另外此处的 try...finally
运用的很巧妙,它能确保在 return this.fn()
后执行 finally
代码块中的重置逻辑。
💡 在 Vue 中,组件的渲染方法是放在
effect
中调用的,因此父子组件的嵌套是最常见的effect
嵌套场景。