[Vue3] 从 0 到 1 实现响应式原理

一、前言

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

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

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

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

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

js 复制代码
// 全局变量
let val = 1;

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

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

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

js 复制代码
const obj = { text: "hello Vue3" };

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

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

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

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

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

js 复制代码
/** 存储副作用的桶 */
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)
  • 当触发作用时,遍历执行桶中的副作用函数。

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

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

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

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

三、依赖收集过程

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

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

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

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

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

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

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

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

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

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

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

js 复制代码
const bucket = new WeakMap();

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

js 复制代码
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

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

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

js 复制代码
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());
}

四、分支切换

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

js 复制代码
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)
})

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

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

js 复制代码
obj.ok = false

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

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

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

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

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

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

js 复制代码
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 中,这样便完成了对依赖集合的反向收集。

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

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

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

js 复制代码
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 函数中的遍历过程做改造:

js 复制代码
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++ 时引发的无限递归调用问题,从而避免栈溢出。

五、嵌套依赖

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

js 复制代码
// Child 组件
const Child = {
  render() {
    /** ... */
  },
};

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

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

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

js 复制代码
// 当前正在执行的副作用函数
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 数组来模拟栈。当前执行的副作用函数会压入栈顶,当遇到嵌套的副作用函数时,栈底存储的是外层的副作用函数,而栈顶存储的是内层的副作用函数,如图所示:

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

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

六、总结

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

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

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

相关推荐
旧林84310 分钟前
第八章 利用CSS制作导航菜单
前端·css
程序媛-徐师姐21 分钟前
Java 基于SpringBoot+vue框架的老年医疗保健网站
java·vue.js·spring boot·老年医疗保健·老年 医疗保健
yngsqq22 分钟前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing1 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风1 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave1 小时前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟1 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm2 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j