响应系统的作用与实现-2

103 阅读7分钟

回顾上篇文章 响应系统的作用与实现-1,完成的代码:

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

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

let activeEffect = null;
const bucket = new WeakMap();
function effect(fn) {
  activeEffect = fn;
  fn();
}
const proxy = new Proxy(obj, {
  get(target, key) {
    if (!activeEffect) {
      return target[key];
    }
    track(target, key);
    return target[key];
  },
  set(target, key, newValue) {
    target[key] = newValue;
    trigger(target, key);
    return true;
  },
});

再稍微封装一下成 reactive 方便后面创建响应式数据:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      if (!activeEffect) {
        return target[key];
      }
      track(target, key);
      return target[key];
    },
    set(target, key, newValue) {
      target[key] = newValue;
      trigger(target, key);
      return true;
    },
  });
}

我们的响应式系统还存在很多缺陷,比如如何合理触发副作用,触发的频率,嵌套副作用,解决无限循坏等问题,本篇文章继续完善...

合理触发响应

vue 框架对于响应数据何时更新的处理很巧妙和细致,如果对 vue2 熟悉的人可能会发现一些细节,用下面代码举例:

<template>
  <div>
    <div v-if="flag">
      {{msg}}
    </div>
    <div else>
      hello vue
    </div>
  </div>
</template>
<script>
  ...
  data() {
    return {
      flag: true,
      msg: 'hello world'
    }
  }
</script>

此时,flagtrue,页面显示 msg 的值 hello world。 若修改 msg 的值我们都知道视图会因此重新渲染。

this.msg = 'ok'; // 重新渲染,页面显示 ok

然后将 flag 设置为 false ,视图也将重新渲染显示 hello vue ,那么如果再次修改 msg 的值,视图会因此更新吗?

this.flag = false; // 重新渲染,页面显示 hello vue
...
this.msg = 'hi'; // 视图会因此更新?

按直觉来说,当 flag 修改成 false 时,视图将走到 else 分支显示 hello vue,此时视图已经不需要 msg 了,理论上 msg 的变化不影响视图。 答案确实如此,不会因此更新,因为视图重新渲染前会先清除所有依赖(cleanupDeps),然后再收集有效依赖,减少不必要的代码执行。

回到我们的响应式系统,该如何做到这点呢?也就是下面代码:

const obj = {
  flag: true,
  msg: 'hello world',
};
const proxy = reactive(obj);

effect(function effectFn() {
  console.log(proxy.flag ? proxy.msg : 'hello vue');
});

代码的执行情况希望是这样的:

  • flagtrue , 改变 msgeffectFn 重新执行
  • flag 改变时, effectFn 重新执行
  • flagfalse
    • 改变 msgeffectFn 不执行

以现在的代码不能达到这样的效果。

我们可以先分析一下,现在代码执行后会发生什么,当第一次 effect 执行时, 首先会读取 flagflagtrue , 然后读取 msg ,此时 '桶' 的数据关系是这样的:

bucket 数据关系1 bucket 数据关系1

从图中可以看出,当 flag 或者 msg 发生变化的时候对应的 effectFn 都会执行,当 flagfalse

proxy.flag = false;

此时读取常量 hello vue,关系还是如 bucket 数据关系1 所示。也就是说 当 flag 为 false 时, msg 发生变化对应的 effectFn 还是会执行。这明显不符合我们预期,所以理想的情况下, 当 flagfalse 时, effectFn 不应该被 msg 收集,如图所示:

bucket 数据关系2 bucket 数据关系2

正确的思路是当 flagfalse 时,在 effectFn 执行之前将所关联的依赖清除掉如 bucket 数据关系3 图所示,,执行后再重新收集相关的依赖

bucket 数据关系3 bucket 数据关系3

有关依赖的概念: 为了方便理解,可以将依赖理解成副作用函数对应的一系列 Set 集合

先看看如何收集与 effectFn 相关依赖,回到 effect 函数并且改造:

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

创建 effectFn 函数,将注册副作用函数和执行副作用函数放在里面,在 effectFn 上添加静态属性 deps ,当外面执行 effectFn 时,那么 activeEffect 会自带 deps 属性,接着改造 track

function track(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let effectFns = depsMap.get(key);
  if (!effectFns) {
    depsMap.set(key, (effectFns = new Set()));
  }
  effectFns.add(activeEffect);
  activeEffect.deps.push(effectFns); // 此步可以收集副作用函数里面的所有依赖
}

经过上面改造已经收集到了与 effectFn 相关依赖,如图 bucket 数据关系4 所示

bucket 数据关系4 bucket 数据关系4

再按思路,effectFn 执行之前将所关联的依赖清除掉

function effect(fn) {
  const effectFn = () => {
    for (let i = 0; i < effectFn.deps.length; i++) {
      const depsSet = effectFn.deps[i]; // 取出副作用函数所关联的依赖集合
      depsSet.delete(effectFn); // 切断该副作用函数所有依赖
    }
    effectFn.deps = [];
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

这样我们就完成了依赖的清除,把代码提出来封装一下:

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const depsSet = effectFn.deps[i]; // 取出副作用函数所关联的依赖集合
    depsSet.delete(effectFn); // 切断该副作用函数所有依赖
  }
  effectFn.deps = [];
}
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

但是试着 将 flag 改为 false 发现代码进入死循坏

proxy.flag = false; // effectFn 死循坏

这是什么原因?先看看下面的例子:

const seen = new Set([1]);
seen.forEach((v) => {
  seen.delete(1);
  seen.add(1);
  console.log('代码运行中');
});

将上面的例子贴到浏览器控制台运行发现代码死循坏了,怎么做才能解决这个问题呢?答案是新建一个 Set ,如:

const seen = new Set([1]);
const seenTorun = new Set(seen);
seenTorun.forEach((v) => {
  seen.delete(1);
  seen.add(1);
  console.log('代码运行中');
});

接着分析我们的代码,可以发现副作用函数的执行行为和上卖弄例子是一样的,在 trigger 中:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectFns = depsMap.get(key);
    effectFns && effectFns.forEach((fn) => fn()); // 此代码有问题
  }
}

因为 trigger 中的 effectFns 是一个副作用函数的 Set 集合, 而当 foreEach 执行的时候会触发 effect 函数中的 effectFn ,执行 cleanup 清除依赖,而之后再执行副作用函数重新收集依赖,这样就造成了死循环,为了方便理解过程如图所示:

cleanup cleanup.png

所以我们需要改造 trigger 函数,新建一个新的 Set 存放:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectFns = depsMap.get(key);
    const effectFnsToRun = new Set(effectFns);
    effectFnsToRun && effectFnsToRun.forEach((fn) => fn()); // 此代码有问题
  }
}

这样就成功清除了遗留副作用,合理的触发响应。

effect 嵌 effect 问题

既然我们的响应式系统是基于 vue 的,vue 中的组件是可以嵌套使用的,那么我们就不得不讨论嵌套问题。

effect(function () {
  /*...*/
  effect(function () {
    /*...*/
  });
});

也先稍微提一下 effectvue render 怎么联系,举个简单渲染组件的例子:

Foo 组件

const Foo = {
  render() {
    return; /*...*/
  },
};
const Bar = {
  render() {
    return; /*...*/
  },
};
effect(() => {
  Foo.render();
});

假设 Foo 内部数据已经是响应式数据,当数据发生变化时 Foo.render 将会重新执行,这和我们之前学习的一样。让 FooBar 嵌套, Foo 渲染 Bar 组件:

effect(() => {
  Foo.render();
  effect(() => {
    Bar.render();
  });
});

上面的例子说明了为什么要将 effect 设计成可嵌套的?那我们现在的代码算不算支持嵌套呢?又怎么算是嵌套的合理设计? 我们用现在的代码用下面例子测试一下会发生什么:

const data = reactive({ foo: true, bar: true });
let temp1, temp2;
effect(function effectFn1() {
  console.log('effectFn1 执行');

  effect(function effectFn2() {
    console.log('effectFn2 执行');
    temp2 = data.bar; // 在 effectFn2 读取 bar
  });
  temp1 = data.foo; // 在 effectFn1 读取 foo
});

当上面代码执行后打印:

effectFn1 执行
effectFn2 执行

这样看起来好像没问题,但是看看改变 foo 会发生了什么:

data.foo = false;

结果

effectFn2 执行

思考一下这个结果对不对,先分析一下:因为 foo 是被 effectFn1 收集,理论上当 foo 发生改变时将触发 effectFn1effectFn2effectFn1 里面,也会间接触发 effectFn2,所以这个结果对于响应系统来说是不合理的

那么这是为什么?原因出现在 effectactiveEffect 上:

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

可以看得出来全局变量 activeEffect 在同一时刻只能存储一个副作用函数,观察上面嵌套代码,当 effect 发生嵌套时,内部 effect 的执行会覆盖 activeEffect , 当全部执行完, activeEffect 永远都取不到原先的副作用函数,这就时问题所在。

为了解决这个问题,我们引入一个 activeEffect 的栈 effectStack ,与函数的调用保持一致:

let activeEffect = null;
const effectStack = [];
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    effectStack.push(effectFn);
    activeEffect = effectFn;
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1]; // 取栈顶
  };
  effectFn.deps = [];
  effectFn();
}

利用 effectStack 来模拟栈, activeEffect 不变,不同的是,当副作用执行的时候当前函数会压入栈,将 activeEffect 指向栈顶 。这样当发生嵌套的时候以上面的嵌套为例,栈底是外层副作用函数 effectFn1 ,而栈顶是内层副作用函数 effectFn2 ,栈顶函数 effectFn2 先执行,读取 data.bar ,完之后弹出, effectFn1 成为栈顶, 并将刷新 activeEffect 指向,如图所示,此时代码执行到 data.foo 读取, effectFn1 可以正常收集其依赖。

efeffectStack

efeffectStack.png

现在再试试改变 foo 会发生了什么:

data.foo = false;

结果在预期之中:

effectFn1 执行
effectFn2 执行

查看原文: 响应系统的作用与实现-2

参考文献: Vue.js设计与实现 - 霍春阳