如何实现一个响应式系统(一)

28 阅读16分钟

该系列文章为《Vue.js设计与实现》这本书的读书笔记,若想了解更详细的内容可以阅读原书。

示例代码:Github

一、响应式数据与副作用函数

什么是副作用

副作用函数指的是会产生副作用的函数,如下面的代码所示:

function effect() {
  document.body.innerText = 'hello vue3'
}

effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:

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

什么是响应式数据

假设在一个副作用函数中读取了某个对象的属性:

  const obj = { text: "hello world" };
  function effect() {
    // effect 函数的执行会读取 obj.text
    document.body.innerHTML = obj.text;
  }

如上面示例,副作用函数 effect 会读取 obj.text 的值来设置 body 元素的内容。当 obj.text 的值发生变化时,我们希望副作用函数 effect 会自动重新执行:

obj.text = 'hello vue3'; // 修改 obj.text 的值,同时希望 effect 函数会自动重新执行

如果能实现上面的目标,那么对象的 obj 就是响应式数据。

二、响应式数据的基本实现

如何才能让 obj 变成响应式数据呢?我们可以发现:

  1. effect 函数执行时,会触发 obj.text读取操作
  2. 当修改 obj.text 时,会触发 obj.text设置操作

所以只要我们可以拦截数据的读取和设置操作,事情就很简单了:

  1. 在读取数据的时候,把 effect 函数存起来;
  2. 在设置数据的时候,将 effect 函数取出来,并执行。

那如何拦截数据的读取和设置呢?

在 ES2015 之前,我们可以用 Object.defineProperty 实现,这是 Vue2 中采用的方式。

在 ES2015+ 中,我们可以用 Proxy 实现,这是 Vue3 中采用的方式。

简单实现的思路:

  1. 用一个对象来保存副作用函数,我们可以称为 bucket (桶) ,暂时先用 Set ,后面再调整;

  2. 定义一个原始数据对象 data

  3. 创建 Proxy 对象 obj,对 data 进行代理:

    a. 设置 get 拦截函数,在读取操作时,将副作用函数 effect 保存到 bucket 中;

    b. 设置 set 拦截函数,在赋值操作时,将副作用函数从 bucket 中取出来,并依次执行;

  4. 定义 effect 函数,并在函数中访问 obj.text

  5. 手动执行一次 effect 函数,触发读取操作;

  6. 后面再对 obj.text 进行更改时,便可以自动执行 effect 函数了。

注意:effect 函数必须要执行一次,这样才能触发读取操作

完整代码如下:

// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { text: "hello world" };
// 对原始数据进行代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用函数从桶中取出来并执行
    bucket.forEach((fn) => fn());
    // 返回 true 代表设置操作成功
    return true;
  },
});
// 副作用函数
function effect() {
  document.body.innerHTML = obj.text;
}

// 执行副作用函数,触发读取
effect();
// 1秒后修改响应式数据
setTimeout(() => {
  obj.text = "hello world 3";
}, 1000);

上面的代码存在很明显的问题:

  1. 直接硬编码了副作用函数的名字(effect) ,导致改了函数名就不能用了;
  2. 调用 obj.text2 = "123" 时,怎么也会触发 effect 函数的执行?这个不合理。

下面我们先完善一下这两个问题。

三、副作用函数的注册

我们希望副作用函数名可以随便定义,哪怕是匿名函数也可以,所以我们需要提供一个注册副作用函数的机制

函数注册机制的思路:

  1. 提供一个全局变量 activeEffect,它的作用是存储注册的副作用函数;
  2. effect 函数变成注册副作用函数的函数;
  3. get 操作时,将当前 activeEffect 保存到 bucket
  4. set 操作时,从 bucket 中取出副作用函数并执行。

完整代码如下:

// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { text: "hello world" };
// 对原始数据进行代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将 activeEffect 中存储的副作用函数添加到桶中
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用函数从桶中取出来并执行
    bucket.forEach((fn) => fn());
    // 返回 true 代表设置操作成功
    return true;
  },
});

effect(() => {
  console.log("effect run"); // 会打印 3 次
  document.body.innerHTML = obj.text;
});

setTimeout(() => {
  obj.text = "hello world 3";
}, 1000);

setTimeout(() => {
  // 设置 obj 中并不存在的属性,也会触发副作用函数的执行
  obj.no_text = "hello world 4";
}, 2000);

四、使用 WeakMap 收集副作用函数

现在设置 obj 中并不存在的属性,也会触发副作用函数的执行。这是因为使用 Set 作为存储副作用函数的 bucket。这是不合理,因为它没办法在副作用函数和操作的目标字段之间建立明确的联系。所以我们需要想办法在 target - key - effectFn 之间建立联系。

因此整体上我们可以使用 WeakMap 作为 bucket

  • WeakMaptarget -> Map 构成
  • Mapkey -> Set 构成
  • Set 中存储副作用函数

同时,我们把 Set 数据结构中存储的副作用函数集合称为 key 的依赖集合

完整代码如下,这里面顺便把 get 函数中的逻辑封装为 track 函数,set 函数中的逻辑封装为 trigger 函数:

// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数fn赋值给 activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}
// 存储副作用函数的桶
// WeakMap: target -> Map
// Map: key -> Set
const bucket = new WeakMap();
// 原始数据
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)
  },
});

// 在 get 拦截函数内调用 track 函数,追踪变化
function track(target, key) {
  // 没有 activeEffect 直接返回对应的值
  if (!activeEffect) return;

  // 根据 target 从桶中获取 depsMap
  let depsMap = bucket.get(target);
  // 如果不存在,那么新建一个 Map 和 target 进行关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }

  // 再根据 key 从 depsMap 中获取 deps
  let deps = depsMap.get(key);
  // 若 deps 不存在,则创建一个 Set 和 key 进行关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 将当前副作用函数添加桶里
  deps.add(activeEffect);
}

// 在 set 拦截函数内调用 trigger 函数,触发变化
function trigger(target, key) {
  // 根据 target 从桶中获取 depsMap
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  // 根据 key 取得所有副作用函数 effets
  const effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.forEach((fn) => fn());
}
effect(() => {
  console.log("effect run"); // 会打印 2 次
  document.body.innerHTML = obj.text;
});

setTimeout(() => {
  obj.text = "hello world 3";
}, 1000);

setTimeout(() => {
  // 设置 obj 中并不存在的属性,也会触发副作用函数的执行
  obj.no_text = "hello world 4";
}, 2000);

五、分支切换和 cleanup

什么是分支切换?

const data = { ok: true, text: "hello world" };
const obj = new Proxy(data, { /* 省略 */ });
effect(() => {
  document.body.innerHTML = obj.ok ? obj.text : "not";
});

setTimeout(() => {
  obj.ok = false;
}, 1000);

上述代码中 effectFn 内部存在三元表达式,当 obj.ok 的值发生变化时,代码执行的分支就会跟着变化,这就是分支切换。

若不处理会出现什么问题?

可能会产生遗留的副作用函数,而遗留的副作用函数会导致不必要的更新。

为什么会出现这种情况?

初始状态 obj.oktrueeffectFn 运行以后,会触发 obj.okobj.text 的读取操作,所以 effectFn 和响应式数据的关系为

data
├── ok
│   └── effectFn
└── text
    └── effectFn

obj.ok = false 执行以后,并不会触发 obj.text 的读取操作,只会触发 obj.ok 的读取操作,所以理想状态下,effectFn 不应该被 obj.text 的依赖集合收集。理想状态下 effectFn 和响应式数据的关系为

data
└── ok
    └── effectFn

但是实际上,响应式数据的关系没有发生变化,这样就产生了遗留的副作用函数。

后面执行 obj.ok = false 后,innerHtml 的值一直是 “not”,不论 obj.text 怎么改变,其实都影响不到 innerHtml 的值,理想的结果是不执行副作用函数。但是在这时候我们执行 obj.text = 'hello world' 时,副作用函数依然会执行,虽然没有什么作用。这样就产生了不必要的更新

怎么解决这个问题?

每次执行副作用函数时,我们可以把它从所有与之相关联的依赖集合中删除。当副作用函数执行完毕以后,会重新建立联系,这样新的联系中就不会存在遗留的副作用函数了。

重新设计副作用函数

要将一个副作用函数从所有与之关联的依赖集合中删除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数。

  1. effect 中新定义一个 effectFn 函数,并为其添加 deps 属性,用来存储所有与该副作用函数相关联的依赖集合:
let activeEffect;

function effect(fn) {
  const effectFn = () => {
    // 完成清除工作(函数实现在后面)
    cleanup(effectFn);
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    fn();
  };
  // effectFn.deps,也就是 activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}
  1. track 函数中我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们把它添加到 activeEffect.deps 数组中,这样就完成了对依赖集合的收集:
  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);

    // deps 就是一个与当前副作用函数存在联系的依赖集合
    // 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps); // 新增
  }
  1. 将当前副作用函数从依赖集合中移除:
function cleanup(effectFn) {
  // 遍历 effectFn.deps
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 移除
    deps.delete(effectFn);
  }
  //重置数组
  effectFn.deps.length = 0;
}
  1. 优化 trigger 函数
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  
  // effects && effects.forEach(fn => fn()) 删除,该方法存在问题,会导致无限循环
  const effectsToRun = new Set(effects); // 新增
  effectsToRun.forEach((fn) => fn()); // 新增
}

不对 trigger 函数进行优化话,会导致无限循环。

trigger 函数内部,我们遍历了 effects 集合,它是一个 Set,里面存储着副作用函数。当副作用函数执行的时候,会调用 cleanup 进行清除,本质上就是将当前执行的副作用函数从 effects 集合中剔除,但是副作用函数的执行会导致它本身被重新收集到集合中,实际上就是又添加进 effects 集合中了,而此时对于 effects 集合的遍历还在进行中,这就会导致无限循环。

解决办法就是构造另外一个 Set 集合并遍历它。

用简短的代码来表述就是,在遍历一个 Set 时,先删除其中的元素 1,然后再添加元素 1,执行时就会发现它会无限执行下去:

const set = new Set([1]);
set.forEach((item) => {
  set.delete(1);
  set.add(1);
  console.log("遍历中");
});

语言规范中对此有明确的说明: 在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。

六、嵌套的 effect 和 effect 栈

什么场景下需要支持嵌套?

实际上 Vue 的渲染函数就是在一个 effect 中执行的:

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

当组件发生嵌套的话,比如 Foo 组件渲染了 Bar 组件,就会发生 effect 嵌套:

effect(() => {
  Foo.render()
  //嵌套
  effect(() => {
    Bar.render()
  })
})

不支持嵌套会发生什么?

我们看下下面这个例子:

// 原始数据
const data = { foo: true, bar: true };
// 对原始数据进行代理
const obj = new Proxy(data, { /* 省略 */ })

let temp1, temp2;
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
  console.log("effectFn1");
  effect(function effectFn2() {
    console.log("effectFn2");
    temp2 = obj.bar;
  });
  temp2 = obj.foo;
});

// 执行修改
obj.foo = false;

已知:

  • effectFn1 嵌套了 effectFn2
  • effectFn1 中读取 obj.foo
  • effectFn2 中读取 obj.bar
  • effectFn1 的运行会导致 effectFn2 的执行
  • effectFn2 的执行先于读取 obj.foo

那么当我们修改 obj.foo 时,希望触发 effectFn1 的执行;修改 obj.bar 时,希望触发 effectFn2 的执行。但修改 obj.foo 的值后,完整的运行结果是:

effectFn1
effectFn2
effectFn2

修改了 obj.foo ,发现运行的是 effectFn2 ,这是不符合预期的。

问题出现在哪里?

我们发现在 effect 函数里面有这样一行代码 activeEffect = effectFn

let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    fn();
  };

  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这也就意味着同一时刻 activeEffect 存储的副作用函数只能有一个。当 effect 嵌套时,内层的副作用函数会覆盖掉 activeEffect 的值,并且永远不会恢复原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数。

使用栈去解决

为了存储副作用函数,我们需要一个副作用函数栈 effectStack。在副作用函数执行的时候,将当前的副作用压入栈中,待副作用函数执行完成,便把它从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。

这样一个响应式数据就只会收集直接读取其值的副作用函数,不会出现互相影响的情况。

let activeEffect;
// effect 栈
const effectStack = []; // 新增
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    // 调用之前压栈
    effectStack.push(activeEffect);  // 新增
    fn();
    // 执行完成后还原 activeEffect 的值
    effectStack.pop();  // 新增
    activeEffect = effectStack[effectStack.length - 1];  // 新增
  };

  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

七、避免无限递归

我们再来看一个例子

const data = { foo: 1 };
const obj = new Proxy(data, { /* 省略 */ });

effect(() => obj.foo = obj.foo + 1);

运行以后会报错:

Uncaught RangeError: Maximum call stack size exceeded

为什么会报错?

示例里,既会读取了 obj.foo 的值,又会设置 obj.foo 的值,这个就是导致报错的根本原因:

  1. 读取 obj.foo 的值,会触发 track 操作,将当前副作用函数收集到 bucket 中;
  2. obj.foo1 后再赋值给 obj.foo,会触发 trigger 操作,这时会把 bucket 中的副作用函数取出来,并执行;
  3. 执行副作用函数,便会又一次读取和设置 obj.foo 的值,触发 tracktrigger,这样无限递归便开始了。

解决方法

我们可以知道,读取和设置操作是在同一个副作用函数内进行的。此时,track 时收集的副作用函数和 trigger 时触发执行的副作用函数,都是 activeEffect

所以,我们便可以在 trigger 动作发生的时候增加条件判断:如果 trigger 触发执行的副作用函数与当前执行的副作用函数相同,则不触发执行。

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

  const effectsToRun = new Set();
  effects && effects.forEach(effectFn => {
    // 如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数相同,则不触发执行
    if(effectFn !== activeEffect) { // 新增
      effectsToRun.add(effectFn)
    }
  })
  // 执行副作用函数
  effectsToRun.forEach((fn) => fn());
}

八、总结

首先,响应式数据最基本的实现,它依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应系统的根本实现原理。

接着,我们完善响应系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。

然后,分支切换会导致冗余的副作用,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用问题,从而解决了问题。但在此过程中,我们还遇到了遍历 Set 数据结构导致无限循环的新问题,解决方案是建立一个新的 Set 数据结构用来遍历。

而后,在实际场景中,嵌套的副作用函数发生在组件嵌套的场景中,即父子组件关系。这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱,我们需要使用副作用函数栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈中弹出。当读取响应式数据的时候, 被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系,从而解决问题。

最后,我们遇到了副作用函数无限递归地调用自身,导致栈溢出的问题。该问题的根本原因在于,对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法很简单, trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行


系列文章:

如何实现一个响应式系统(一)

如何实现一个响应式系统(二)

如何实现一个响应式系统(三)