[Vue3] 从 0 到 1 手写响应式原理

1,703 阅读12分钟

一、前言

上一篇文章给大家介绍了Vue3 中的虚拟DOM、 h() 函数,渲染函数,渲染器等知识点,这次给大家介绍一下 Vue3 的响应式原理。

Vue2 使用 Object.defineProperty 函数实现响应式,而 Vue3 改用了 Proxy 来处理响应式对象。接下来,笔者将会带着各位,一步一步了解 Vue3 的响应式原理,大纲如下:

image.png

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

在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数,向主调方的终端、管道输出字符或改变外部存储信息等。 ————维基百科

从定义可以得知,函数副作用会对函数外的变量进行改变

// 全局变量
let val = 1;

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

effect 函数在执行过程中修改了全局变量,产生了副作用,我们可以称 effect 为副作用函数。

在函数式编程中,函数副作用是需要消除的,而 Vue3 则利用了副作用函数的特点,在我们修改对象时,使得对应的副作用函数能够重新执行。例如:

const obj = { text: "hello Vue3" };

function effect() {
  document.body.innerTexxt = obj.text;
}

在我们修改了 obj.text 对象后,希望 effect 函数能够重新执行,读取最新的数据,从而更新页面信息。此时的 obj 对象,就是响应式对象,而 effect 函数就是对应的依赖。

为了使得 obj 成为响应式数据,有两个关键:

  • effect 函数执行时,会触发 obj 对象的操作;
  • obj.text 内容修改时,会触发 obj 对象的操作。

为此,我们通过 Proxy 来拦截该对象的读写操作,示例如下:

/** 存储副作用的桶 */
const bucket = new Set();

/** 当前正在执行的副作用函数 */
let activeEffect = null;

/** effect 用来注册副作用函数 */
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将 fn 赋值给 activeEffect
  activeEffect = fn;
  fn();
  activeEffect = null
}

/** 原始数据 */
const data = { text: "Hello Vue3!" };

/** 代理后的数据 */
const obj = new Proxy(data, {
  // 拦截读操作
  get(target, key) {
    // 将副作用 effect 添加到桶中
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    // 返回属性内容
    return target[key];
  },
  // 拦截写操作
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach((callback) => callback());
    return true;
  },
});

我们先用一个桶 bucket 来存放所有副作用函数,接着,用一个全局对象 activeEffect 表示当前正在执行的副作用函数,而为了能让匿名函数也能够响应,我们需要一个 effect 方法来注册副作用函数并执行。

然后我们通过 Proxy 代理 data 对象的 getset 操作:

  • 当触发操作时,将当前执行的副作用函数添加到桶中,即 bucket.add(activeEffect)
  • 当触发作用时,遍历执行桶中的副作用函数。

我们用以下代码来测试一下:

let testResult = "";
effect(() => {
  testResult = obj.text;
  console.log("副作用函数执行, testResult值为:" + testResult);
});

setTimeout(() => {
  obj.text = "Hello World";
});

image.png

至此,一个微型响应系统就实现了。

三、依赖收集过程

上一节我们使用了 Set 结构来存储副作用函数,但是存在一个问题,当我们设置一个不存在的属性时:

let testResult = "";
effect(() => {
  testResult = obj.text;
  console.log("副作用函数执行, testResult值为:" + testResult);
});

setTimeout(() => {
  obj.name = "Hello World";
});

image.png

可以看到,副作用函数内部读取了 obj.text 的值,因此该函数与 obj.text 建立了相应联系。

此时,我们在定时器中给对象添加了 name 属性,而副作用函数并没有读取该属性值。理论上,我们只希望在修改 obj.text 时,匿名副作用函数才执行。实际上,副作用函数在我们添加 name 属性时还是重新执行了,这是不正确的。为此,我们需要重新设计存储桶的数据结构。

在设计数据结构前,我们分析下面这段代码:

effect(function effectFn() {
  testResult = obj.text;
  console.log("副作用函数执行, testResult值为:" + testResult);
});

我们发现,需要建立联系的主要有三个角色:

  • 代理对象 obj
  • 字段属性 text
  • 副作用函数 effectFn

我们知道,一个对象可以拥有多个字段,一个字段可以在多个副作用函数中使用,因此需要一个树形结构要存储副作用函数。

image.png

分析完数据结构,我们来实现一下这个存储桶。首先,我们使用 WeakMap 代替 Set 作为桶的数据结构:

const bucket = new WeakMap();

接着,我们修改 Proxy 的代码:

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return target[key];
    // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()));
    }
    // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
    // 里面存储着所有与当前 key 相关联的副作用函数:effects
    let deps = depsMap.get(key);
    // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    // 最后将当前激活的副作用函数添加到“桶”里
    deps.add(activeEffect);

    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 根据 target 从桶中取得 depsMap,它是 key --> effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key);
    // 执行副作用函数
    effects && effects.forEach((fn) => fn());
  },
});

我们分别使用了 WeakMapMapSet

  • WeakMaptarget --> Map 构成;
  • Mapkey --> Set 构成。

其中 WeakMap 的键是原始对象 targetWeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 targetkeyMap 的值是一个由副作用函数组成的 Set

image.png

使用 WeakMap 原因是 WeakMapkey 是弱引用,不会造成内存泄漏。即一旦 target 对象没有任何引用时,垃圾回收器会回收对应内存。

另外,考虑到后续维护代码的便捷性,我们将 get 拦截函数中涉及副作用函数的部分提取到 track 函数中,把 set 拦截函数中涉及副作用函数的部分提取到 trigger 函数中:

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用函数从桶里取出并执行
    trigger(target, key);
  },
});

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect,直接 return
  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);
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((fn) => fn());
}

四、分支切换

接下来我们来看一个场景:

const data = { ok: true, text: "text" }
const obj = new Proxy(data, { ... })

effect(function effectFn() {
  const text = obj.ok ? obj.text : "hello vue3"
  console.log(text)
})

在执行上面的副作用函数后,我们建立了以下依赖关系:

image.png

data.ok 为 true 时,我们修改 data.text 的值,会触发 effectFn 函数重新执行。当我们将其修改为 false 时:

obj.ok = false

此时会触发副作用函数重新执行,此时 text 永远是 hello vue3 ,而 data.text 无论怎么修改都不会影响 text 的值。所以理论上,无论我们怎么修改 data.text 的值,都不应该触发 effectFn 函数重新执行。

实际上,由于 effectFndata.text 建立了依赖关系,所以当我们修改 data.text 的值,还是触发了 effectFn 副作用函数执行。这个问题是遗留的副作用函数导致的。

为了解决上述问题,我们需要在副作用函数执行前,将它从所有与之关联的依赖集合中删除,等副作用函数执行完毕后,再建立新的依赖集合。

要想将一个副作用函数从所有与之关联的依赖集合删除,我们需要知道该副作用函数存在于哪些依赖集合中,因此我们对 effect 注册函数做以下改造:

// 当前正在执行的副作用函数
let activeEffect;
function effect(fn) {
  const effectFn = () => {
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    fn();
  };
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

我们在 effect 内部定义了一个新的 effectFn 函数,并挂载一个数组,用来存储所有包含该副作用函数的依赖集合。依赖集合收集过程如下:

function track(target, key) {
  // 没有 activeEffect,直接 return
  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 中
  deps.add(activeEffect);
  // deps 就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 数组中
  activeEffect.deps.push(deps); // 新增
}

track 函数中,我们将当前正在执行的副作用函数 acticeEffect 添加到依赖集合 deps 中,因此 deps 是与当前副作用函数存在关联的依赖集合,于是我们将该集合添加到 activeEffect.deps 中,这样便完成了对依赖集合的反向收集。

image.png

接下来,我们需要在每次副作用函数执行之前,根据 effectFn.deps 获取所有相关联的依赖,并将当前副作用函数从中移除:

// 当前正在执行的副作用函数
let activeEffect;
function effect(fn) {
  const effectFn = () => {
    // 调用 cleanup 函数进行清除
    cleanup(effectFn) // 新增
    activeEffect = effectFn;
    fn();
  };
  effectFn.deps = [];
  effectFn();
}

cleanup 函数实现如下, 该函数接收需要清除的副作用函数作为参数,接着遍历该函数的依赖集合数组 deps,然后将该副作用函数从每一个依赖集合中移除,最后再重置 deps 数组:

function cleanup(effectFn) {
  // 遍历 effectFn.deps 数组
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn.deps 数组
  effectFn.deps.length = 0;
}

由于我们的 cleanup 是在副作用函数执行前执行,接着执行了副作用函数,这时候又会把该函数重新收集到集合中,而此时对 effects 集合的遍历依然在进行,因此会导致无限循环。(调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合中,若此时遍历没有结束,则该值会重新被访问。)

为了避免这种情况,我们需要对 trigger 函数中的遍历过程做改造:

function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  
  const newEffectsToRun = new Set() 
  effects && effects.forEach((effectFn) => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
      newEffectsToRun.add(effectFn)
    }
  }); 
  newEffectsToRun.forEach(effectFn => effectFn())
}

如上代码所示,我们新构造了 newEffectsToRun 集合代替 effects 进行遍历,并且在遍历 effects 的同时增加判断条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。这是为了避免响应式对象在自增操作 obj.text++ 时引发的无限递归调用问题,从而避免栈溢出。

五、嵌套依赖

我们在日常开发中,经常会引用组件,如:

// Child 组件
const Child = {
  render() {
    /** ... */
  },
};

// Parent 组件渲染了 Child 组件
const Parent = {
  render() {
    return <Child />; // jsx 语法
  },
};

Vue.js 的渲染函数是在一个 effect 中执行的,此时就会发生 effect 嵌套,而由于我们的全局变量 activeEffect 目前只能存储一个副作用函数,当遇到这种情况时,就会导致响应式对象的依赖集合只会存储最里层的副作用函数。

为了解决这个问题,我们引入一个副作用函数栈 effectStack。当副作用函数执行时,将当前副作用函数进行压栈,等该函数执行完毕后将其从栈中弹出,而 activeEffect 则一直指向栈顶的副作用函数:

// 当前正在执行的副作用函数
let activeEffect;
// effect 栈
const effectStack = []; // 新增

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffect = effectFn;
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn); // 新增
    fn();
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop(); // 新增
    activeEffect = effectStack[effectStack.length - 1]; // 新增
  };
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

我们定义了 effectStack 数组来模拟栈。当前执行的副作用函数会压入栈顶,当遇到嵌套的副作用函数时,栈底存储的是外层的副作用函数,而栈顶存储的是内层的副作用函数,如图所示:

image.png

当内层副作用函数 effectFn 2 执行完毕后,它会出栈,此时 activeEffect 会指向 effectFn 1

经过改造后,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。至此,我们已经实现了基本的响应式对象代理了。

六、总结

我们首先介绍了副作用函数和响应式数据的概念,以及它们之间的关系。一个响应式数据最基本的实现依赖于对读写操作的拦截,从而在副作用函数与响应式数据之间建立依赖关系。

接着,我们使用 WeakMapMapSet 来构造存储桶的数据结构,WeakMap 对 key 是弱引用,不会造成内存泄漏。

然后我们又处理了分支切换和循环依赖嵌套问题,避免了函数无限循环调用。至此,一个基本的响应式对象便完成了。