Vue3响应式系统原理:副作用函数嵌套处理

115 阅读4分钟

问题

父组件包含子组件的模型,其实就是副作用函数的嵌套。 我们用一个简单的例子来说明这个问题。

// 原始对象,包含两个属性
const data = {
  text1: "hello world 1",
  text2: "hello world 2",
};


// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, ...);

////////////////////////////////////

myEffect(function effectOuter() {
  myEffect(function effectInner() {
    console.log('effectInner', obj.text2)
  })
  console.log('effectOuter', obj.text1)
});

// 模拟2s后修改数据
setTimeout(() => {
  obj.text1 = 'HELLO WORLD 1';
}, 2000)

上面的例子中,副作用函数effectOuter包含另一个副作用函数effectInner。effectOuter关联的响应式对象属性是obj.text1,effectInner关联的响应式对象属性为obj.text2。 当修改obj.text1,我们期望能够触发effectOuter的执行,同时由于effectInner在effectOuter内部,因此也会触发effectInner的执行。正常的输出顺序为:

// effectInner, hello world 2
// effectOuter, HELLO WORLD 1

这里我们给出完整示例代码。 effect.js代码如下:

// effect.js

// 存放副作用函数的集合容器,用Set数据结构,是为了防止相同的副作用函数重复收集
const bucket = new WeakMap();

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 用于执行副作用函数的函数
export function myEffect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 每次运行副作用函数,清空和其他对象属性的关联关系
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = []; // 用于保存该副作用函数所关联的对象属性
  effectFn();
}

// 响应式对象。响应式对象为原始对象的Proxy代理
export const myReactive = (data) =>
  new Proxy(data, {
    get(target, key) {
      if (!activeEffect) return target[key];
      track(target, key);
      return target[key];
    },
    set(target, key, val) {
      target[key] = val;
      trigger(target, key);
      return true;
    },
  });

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  deps.add(activeEffect);

  // 将deps增加到activeEffect的deps中
  activeEffect.deps.push(deps);
}

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const effectsToRun = new Set(effects); // 拷贝一份新的Set快照数据

  effectsToRun && effectsToRun.forEach((fn) => fn());
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]; // deps为对象属性关联的deps
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

debug.js代码如下:

// debug.js

import { myEffect, myReactive } from './effect.js'
// 原始对象,包含两个属性
const data = {
  ok: true,
  text1: "hello world 1",
  text2: "hello world 2",
};

// 响应式对象
const obj = myReactive(data);

// 副作用嵌套
myEffect(function effectOuter() {
  myEffect(function effectInner() {
    console.log('effectInner', obj.text2)
  })
  console.log('effectOuter', obj.text1)
});

// 模拟2s后修改数据
setTimeout(() => {
  obj.text1 = 'HELLO WORLD 1';
}, 2000)

// 初始化输出内容如下:
// effectInner hello world 2
// effectOuter hello world 1
// 2s之后输出如下:
// effectInner hello world 2

上面的代码可以发现,当我们修改obj.text1的时候,只会触发内部的副作用函数effectInner,而没有触发外部的副作用函数effectOuter。这个和期望不符合。

分析

  1. 当我们修改obj.text1的时候,会触发trigger函数执行。
  2. trigger内部会运行obj.text1关联的deps中的副作用函数的执行,也就是myEffect(effectOuter)函数。
  3. 在执行myEffect(effectOuter)的内部,activeEffect会指向effectOuter;
  4. 由于effectOuter函数内部又会运行myEffect(effectInnter)函数,而在执行myEffect(effectInner)的内部,activeEffect️变为指向effectInner;
  5. 当myEffect(effectInner)函数执行完之后,会输出hello world 2。此时由于activeEffect变为了null,没有回退指向为effectOuter,从而导致后续没有输出了。

根据上面的分析,可以发现,当副作用函数出现嵌套的时候,activeEffect从指向外部副作用函数,变为指向内部的副作用函数后,但没有机制保证activeEffect可以回退指向。

解决的思路使用用一个全局栈保存有所有的当前正在运行的副作用函数,activeEffect永远只指向栈顶的那个副作用函数。

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 副作用栈
let effectStack = [];

// 用于执行副作用函数的函数
function myEffect(fn) {
  const effectFn = () => {
    // 清除依赖
    cleanup(effectFn);
    // 执行副作用函数
    activeEffect = effectFn;

    effectStack.push(activeEffect)
    fn();

    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  };
  // 存储该副作用哦函数相关联的依赖
  effectFn.deps = []
  effectFn();
}

上述代码中,在运行副作用函数之前,将当前的封装的副作用函数push栈中,执行完之后再pop出来,同时将activeEffect重新指向栈顶的元素。

完整代码见这里

结论

副作用函数的嵌套是一个普遍的情况,对应在项目中就是组件嵌套的情况。源码中我们通过再副作用函数的运行前后进行push/pop栈的操作,实现了activeEffect指向重新定位的问题,从而实现要求。

代码

github.com/wdskuki/js-…

参考

  1. 《Vue设计与实现》,作者:霍春阳,ISBN: 9787115583864