Vue3 - 响应系统

80 阅读6分钟

本文为《Vue.js设计与实现》的笔记。

1. 响应式数据与副作用函数

副作用函数指的是会产生副作用的函数,副作用函数的执行会直接或间接影响其他函数的执行。

let val = 1;
function effect(){
    val = 2; // 修改全局变量,产生副作用
}
function fn(){
    console.log(val);
}

effect函数修改了val,会影响fn函数的行为。

const obj = {text: '123'};
function effect(){
    document.body.innerText = obj.text;
}

effect函数的执行会读取obj.text,我们希望当其值变化时,effect这个副作用函数会自动重新执行,如果能实现这个目标,则obj就是响应式数据。

2. 响应式数据的基本实现

以上的流程涉及两个操作:

  • effect执行时,会进行obj.text的读取操作
  • 修改obj.text时,会进行设置操作

要完成上述需求,我们便需要劫持数据的读取与设置操作,vue2中使用的是Object.defineProperty,Vue3中则使用proxy来实现。

const bucket = new Set();

let activeEffect;

const data = { text: "hello, world" };
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach((fn) => fn());
    return true;
  },
});

function effect(fn) {
  activeEffect = fn;
  fn();
}

effect(() => {
  console.log(obj.text);
});

obj.text = "123";

以上代码使用 bucket 来收集副作用函数,当被代理对象执行读取操作时,进行收集,进行设置操作时,执行副作用函数。结果也按设计一般,打印出了新设置的“123”。

hello, world
123

但以上代码仍存在问题,若此时我们执行一下代码

obj.xxx = 'xxx'

该副作用函数同样会被触发执行,但该函数并不依赖xxx这一属性。问题就出在bucket的设计上,任意读取了obj上字段数据的函数均会被收集到,而我们需要的是将副作用函数的收集细分到不同属性上。

image.png

以上结构为两个层级:

  • 由于会有很多响应式数据(对象),WeakMap由target---》Map构成,不同对象对应不同的Map
  • 由于一个对象会有多个属性,Map由key---》Set构成,不同属性(键)对象不同集合,集合中存储的就是键对应的副作用函数集合。
const bucket = new WeakMap();

let activeEffect;

const data = { text: "hello, world" };
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
    return true;
  },
});

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

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((fn) => fn());
}

function effect(fn) {
  activeEffect = fn;
  fn();
}

effect(() => {
  console.log(obj.text);
});

obj.text = "123";
obj.xxx = "xxx";

以上代码修改了bucket的结构,将收集副作用函数封装为track,将触发副作用函数封装为trigger。

3. 分支切换与cleanup

const data = { text: "hello, world", ok: true };
const obj = new Proxy(data, ...);
effect(() => {
  console.log(obj.ok ? obj.text : "not");
});

代码中存在三元表达式,根据字段obj.ok的值的不同会执行不同的代码分支。 当首次执行时,此时OK的值为true,所以依赖收集的结果会是如下所示:

data
    --- ok
        --- fn
    --- text
        --- fn

当我们修改obj.ok=false时,此时副作用函数会重新执行,但此时由于其值为false,所以不会读取obj.text,理想情况下,之后修改obj.text,副作用函数不会执行,但事实上,上边所展现的依赖收集的结果仍然存在,此时修改obj.text,仍会导致副作用函数执行。

解决此问题的思路为,每次副作用函数执行时,先将它从所有与其关联的依赖集合中删除。

按照前面所描述的bucket的结构,我们只能单向地从target找到对应的Map,从key找到对应的Set,Set中可能包含这个副作用函数,将所有的set遍历一遍并不现实,所以我们应该建立一个从副作用函数到包含它的Set的联系。

重新设计effect函数

function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

在effect函数中定义一个effectFn函数,并为其添加effectFn.deps属性,该属性时一个数组,用来存储所有包含当前副作用函数的依赖集合。

修改track函数,添加将依赖集合添加至副作用函数的deps的逻辑

function track(target, key) {
    // ...
  deps.add(activeEffect);
  // 新增
  activeEffect.deps.push(deps);
}

前面提到的解决思路为,每次副作用函数执行时,先将它从所有与其关联的依赖集合中删除。继续修改effect,进行删除操作:

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 删除操作
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

其中cleanup的实现如下:

function cleanup(effectFn) {
  // 遍历删除
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0; // 清空
}

以上代码已经可以避免副作用函数产生遗留,但实际运行会导致无限循环执行,问题在trigger函数中:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((fn) => fn()); // 问题所在
}

遍历effects集合执行副作用函数时,会先调用cleanup进行清除,当前执行的副作用函数从集合中删去,但副作用函数执行时又会导致其重新被收集到集合中,这样导致集合中的内容永远无法遍历执行完。

修改trigger函数:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
    // 修改
  const effectToRun = new Set(effects);
  effectToRun && effectToRun.forEach((fn) => fn());
}

上述代码重新构造了一个effectToRun集合用于遍历执行,避免直接遍历effects集合。

4. 嵌套effect与effect栈

考虑如下的effect嵌套情况:

const data = { a: "a", b: "b" };
const obj = new Proxy(data, ...);

effect(() => {
  console.log("fn1");
  effect(() => {
    console.log("fn2");
    temp2 = obj.b;
  });
  temp1 = obj.a;
});

理想情况下,我们希望副作用函数与对象属性的联系如下:

data
    --- a
        --- fn1
    --- b
        --- fn2

实际修改obj.a的值,结果为:

fn1
fn2
fn2

分析一下,前两个打印结果分别为fn1和fn2首次执行的结果,而修改a的值,反而导致了fn2的执行。

问题出在activeEffect上,原有的代码在副作用函数执行时将activeEffect赋值为当前的函数,这就导致了内部的函数的执行会覆盖 activaEffect的值,且不会恢复。

为解决这个问题,引入副作用函数栈 effectStack

  • 副作用函数执行时,当前副作用函数入栈
  • 执行完毕后,将其从栈中弹出
  • 始终让activeEffect指向栈顶的副作用函数

修改effect:

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn); // 新增
    fn();
    effectStack.pop(); // 新增
    activeEffect = effectStack[effectStack.length - 1]; // 新增
  };
  effectFn.deps = [];
  effectFn();
}

此时修改obj.a的值,结果为:

fn1
fn2
fn1
fn2

5. 避免无限递归循环

考虑如下代码

const data = { a: 1, b: "b" };
const obj = new Proxy(data, ...);

effect(() => {
  obj.a = obj.a + 1;
});

以上代码会引起无限递归循环,分析effect中的代码,可以发现该函数先读取了obj.a的值,又设置了obj.a的值。读取时,触发了track,副作用函数收集,而设置时,又触发了trigger,执行副作用函数,这就导致了该函数无限递归地调用了自己。

解决问题的思路为,在trigger时增加守卫条件,如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

修改trigger:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 修改
  const effectToRun = new Set();
  effects &&
    effects.forEach((effectFn) => {
      if (effectFn !== activeEffect) {
        effectToRun.add(effectFn);
      }
    });
  effectToRun && effectToRun.forEach((fn) => fn());
}

以上代码修改了effects中的函数放入effectToRun的逻辑,只有非activeEffect的副作用函数才可加入其中并执行。

6. 完整代码

const bucket = new WeakMap();

let activeEffect;
const effectStack = [];

const data = { a: 1, b: "b" };
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
    return true;
  },
});

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

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 修改
  const effectToRun = new Set();
  effects &&
    effects.forEach((effectFn) => {
      if (effectFn !== activeEffect) {
        effectToRun.add(effectFn);
      }
    });
  effectToRun && effectToRun.forEach((fn) => fn());
}

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn); // 新增
    fn();
    effectStack.pop(); // 新增
    activeEffect = effectStack[effectStack.length - 1]; // 新增
  };
  effectFn.deps = [];
  effectFn();
}

function cleanup(effectFn) {
  // 遍历删除
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0; // 清空
}

effect(() => {
  obj.a = obj.a + 1;
});