vue3响应式系统(2)

48 阅读5分钟

前言

上一篇我们实现了一个基本的响应式系统,但是其实还有诸多不足之处,这一篇我们来继续完善。

track和trigger

首先为了更好地维护代码,我们把上一篇对象的gettersetter里面地逻辑分别封装为tracktrigger函数。点此查看代码

遗留的副作用函数

我们提供以下测试例子:

effect(()=> {
    console.log('effect')
    document.body.innerHTML = data.isVue ? data.name : 'not' // data.isVue初始值为true
})

setTimeout(() => {
    data.name = 'name is changed at the first time'
}, 500);

setTimeout(() => {
    data.isVue = false
}, 1000);

setTimeout(() => {
    data.name = 'name is changed at the second time'
}, 2000);

据上:

  1. data.isVuetrue时,我们很容易得到属性isVuename都有一个副作用函数(也叫依赖,古下面的依赖也是这个意思),我们设为fn
  2. 500msdata.name发生改变会执行fn,页面的结果为data.name的值
  3. 1000msdata.isVue变为false,这个时候结果总是为not,已经与data.name无关了
  4. 然而,当我们2000ms修改data.name时,虽然页面的结果仍为not,但实际上也执行了依赖fn,实际上我们希望这个时候fn是不被执行的。

这就是遗留的副作用函数问题

解决这个问题其实很简单,我们可以把该依赖从所有引用到它的依赖集合中删除,然后执行依赖时让它重新被重新收集。梳理一下思路:

  1. 最开始每个对象的每个属性的依赖集合为空
  2. 依赖执行时,会被它里面的响应式对象收集(我们将这些对象假设叫集合A),A集合里面的对象属性一旦发生变化,就会重新执行依赖,很好。问题是这个A集合里面不是一成不变的,就像上面的测试例子,2000ms后集合A就不包括data.name了,所以我们这么处理:在执行依赖之前,先把集合A对应的所有依赖集合里收集到的该依赖删除掉,执行时重新收集它。所以我们设计代码如下:
const effect = (fn) => {
  const effectFn = () => {
    // 将当前的副作用函数赋给activeEffect
    activeEffect = effectFn;
    // 清除上一次所有对象属性对该依赖的收集
    cleanUp();
    // 执行副作用函数
    fn();
  };
  effectFn();
};

const cleanUp = () => {
  for (let i = 0; i < activeEffect?.deps?.length; i++) {
    activeEffect.deps[i].delete(activeEffect);
  }
  // 保存哪些依赖集合包含该依赖,即文章中A集合对应的所有依赖集合
  activeEffect.deps = [];
};
const trigger = (target, key) => {
  const deps = bucket.get(target)?.get?.(key);
  const depsToRun = new Set(deps) // 这一句是避免无限循环
  depsToRun.forEach((fn) => fn());
};
const track = (target, key) => {
  if (!activeEffect) return;
  // 获取该对象的所有依赖映射
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 获取对应key的依赖集合
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  // 收集包含该依赖的依赖集合
  activeEffect.deps.push(deps); // 新增
};

这样就可以解决遗留的副作用函数问题了,查看代码

effect嵌套和effect栈

为什么要实现这个功能呢?我们知道Vue.js的组件渲染函数正是在一个effect里面执行的,且组件可以嵌套组件。

比如现在我们定义2个组件:


    const bar = {
        render() {
            return ....
        }
    }
    const bar = {
        render() {
            return ...
        }
    }

则可以这么使用


    <Bar>
        <Foo/>
    </Bar>

实际上执行的是:

effect(() => {
    bar.render()
    // 嵌套
    effect(() => {
        foo.render()
    })
})

这就要求我们把effect设计成可嵌套的,但是前面我们的代码并没有实现这个功能,测试如下

    effect(function effectFn1() {
        console.log('effectFn1执行', )
        effect(function effectFn12() {
            console.log('effectFn2执行')
            document.body.innerHTML = data.name
        })
        document.body.innerHTML = data.version
    })
    setTimeout(() => {
        data.version = 'version is changed'
    }, 1000)

我们满心期待:上面的data.nameeffectFn2绑定,data.versioneffectFn1绑定。然而理想很美满,现实很骨感,1000ms后,data.version发生改变,执行的却是effectFn2,分析如下:

当我们执行当前依赖时,会把该依赖赋给activeEffect,这样当effectFn1还没执行完,已经开始执effectFn2,则activeEffect的值变为effectFn2,因此当执行到document.body.innerHTML = data.version时,data.version收集的依赖就变成effectFn2了。

要解决这个问题,可以把当前执行的effect压入栈,让activeEffect总是指向栈顶,于是我们设计代码如下

    let effectStack = [] // 新增
    const effect = (fn) => {
      const effectFn = () => {
        // 将当前的副作用函数赋给activeEffect
        activeEffect = effectFn;
        effectStack.push(effectFn) // 新增
        // 清除上一次所有对象属性对该依赖的收集
        cleanUp();
        // 执行副作用函数
        fn();
        effectStack.pop() // 当前依赖执行完后则弹出
        activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶

      };
      effectFn();
    };

但是以上代码存在以下问题:(后面再回来解决)

  1. 就算嵌套的effect(或子组件)没有发生改变也会重新执行(或更新)
  2. 外层effect每次执行,内层effect实际总是一个新的变量(effect函数里面会执行const effectFn = () => {...} , effectFn才是真正被收集的依赖,但它此时总是一个新的变量),会被重复添加进依赖集合。

上述完整代码查看

无限递归循环

我们再提供以下测试例子:

effect(() => {
    data.version++
    document.body.innerHTML = data.version
})

打开控制台,发现

image.png 这是因为我们在一个effect里面同时进行了读取和设置操作,

    data.version++
    等价于
    data.version = data.version + 1

于是我们分析深层原因如下:

  1. 当读取data.version时,触发track函数,收集当前依赖
  2. 同时设置data.version,触发trigger函数,执行当前依赖
  3. 问题就出在当前的依赖还没执行完就得再次执行当前依赖,于是会无限调用自己,最后发生栈溢出。

为了解决这个问题,我们在trigger函数里面增加一个守卫条件:trigger触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行:


    const trigger = (target, key) => {
      const deps = bucket.get(target)?.get?.(key);
      const depsToRun = new Set() // 这一句是避免无限循环
      deps && deps.forEach(fn => {
        if (fn !== activeEffect) { // `trigger`触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行
            depsToRun.add(fn)
        }
      })
      depsToRun.forEach((fn) => fn());
    };

上面代码还是会有一个无法避免的问题,我们稍微交换一下副作用函数里面语句的顺序

effect(() => {
    document.body.innerHTML = data.version
    data.version++
})

那么页面上仍是data.version前一次的值

完整代码查看

下一篇我们继续响应式系统的调度执行,computed/watch实现原理等。