Vue 3 源码解析(3)reactive 的实现(中)

3,204 阅读7分钟

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 的依赖(第一轮已收集到),从而触发副作用函数的执行;
  • ...

image.png

要解决此问题,我们应当在每一轮副作用执行之前,清除上一轮执行中所收集的依赖,来避免副作用函数递归调用自身的情况发生。

对于存储对象依赖的容器 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>

点击查看 codepen 线上示例

上方示例会调用一次 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();
    }
}

我们可以把多层副作用函数执行的流程,看作切洋葱:

image.png

从父层进入子层时(例如 effectFn2 -> effectFn3),需要存下父层的信息(父级的 parent 和 shouldTrack),当子层执行完毕返回父层时(例如 effectFn3 -> effectFn2),恢复父层的信息供父层执行。

另外此处的 try...finally 运用的很巧妙,它能确保在 return this.fn() 后执行 finally 代码块中的重置逻辑。

💡 在 Vue 中,组件的渲染方法是放在 effect 中调用的,因此父子组件的嵌套是最常见的 effect 嵌套场景。