实现Vue3响应式系统核心-MVP模型

62 阅读19分钟

简介

2023年12月31日,Vue2已经停止维护了。你还不会Vue3的源码吗?

手把手带你实现Vue3响应式系统,你将获得:

  • Vue3的响应式的数据结构是什么样?为什么是这样?如何形成的?
  • Proxy为什么要配合Reflect使用?如果不配合会有什么问题?
  • Map与WeakMap的区别
  • 响应式数据以及副作用函数
  • 响应式系统基本实现
  • 依赖收集
  • 派发更新
  • 依赖清理
  • 支持嵌套
  • 实现执行调度
  • 实现computed
  • 实现watch
  • TDD测试驱动开发
  • 重构
  • vitest的使用
  • 如何使用 ChatGPT编写单元测试
  • excalidraw画图工具

代码地址:github.com/SuYxh/share…

代码并没有按照源码的方式去进行组织,目的是学习、实现vue3响应式系统的核心,用最少的代码去实现最核心的能力,减少我们的学习负担,并且所有的流程都会有配套的图片,图文+代码,让我们学习更加轻松、快乐。

每一个功能都会提交一个commit,大家可以切换查看,也顺便练习练习git的使用。

如果想看和vue源码结构类似的请看:github.com/SuYxh/mini-…

环境搭建

  1. 使用vite创建一个空模板
pnpm init vite
  1. 然后安装vitest
pnpm add -D vitest

为什么要安装vitest?

测试驱动开发(TDD)是一种渐进的开发方法,它结合了测试优先的开发,即在编写足够的产品代码以完成测试和重构之前编写测试。目标是编写有效的干净代码。

配置测试命令:

"scripts": {  
  "test""vitest"  
},
  1. 安装vscode插件 Vitest Runner

2ea99e1713f8370eb549d988c7a6ae25.jpg

  1. 编写测试代码

新建一个main.js,然后编写:

export function add(a: number, b: number) {  
  return a + b;  
}

创建 /src/_ _ tests _ _ /main.spec.ts 测试文件,写入:

测试文件的文件名最好包含spec

import { describe, it, expect } from 'vitest';  
import { add } from '../main';  
  
describe('add function'() => {  
    it('adds two numbers'() => {  
        expect(add(12)).toBe(3);  
    });  
});

点击运行单测:

341c9f250976fbb6969412c7d3668702.jpg

注意:我这边因为还安装了 jest runner,所以此处会有2对

运行结果:

43861d563b5cf946d675ee8724de6979.jpg

到此,环境搭建结束!

相关代码在 commit:(3af5e60)环境搭建, git checkout 3af5e60 即可查看。

响应式数据以及副作用函数

副作用函数指的是会产生副作用的函数,如下:

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

当effect函数执行时,它会修改val的值,但除了effect函数之外的任何函数都可以修改val的值。也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时我们说effect函数产生了副作用。

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

const obj = { age18 }  
  
function effect() {  
 console.log(obj.age)  
}

当obj.age的值发生变化时,我们希望副作用函数effect会重新执行,如果能实现这个目标,那么对象obj就是响应式数据。但很明显,以上面的代码来看,我们还做不到这一点,因为obj是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。

响应式系统基本实现

如何将obj变成一个响应式对象呢?大家肯定都想到了Object.defineProperty 和Proxy。

  • 当副作用函数effect执行时,会触发字段 obj.age 的读取操作;
  • 当修改 obj.age 的值时,会触发字段 obj.age 的设置操作。

我们可以把副作用函数effect存储到一个“桶”里,如下图所示。

30bbe4bd9ee34f222236861a8db3cf5b.jpg

接着,当设置 obj.age时,再把副作用函数effect从“桶”里取出并执行即可。

ff520978a2e403855e488a80fda9c5e6.jpg

代码实现

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

// 原始数据
const data = { name: 'dahuang', age: 18 };

// 对原始数据的代理
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());
    return true;
  }
});

// 以下为测试代码
// 副作用函数
function effect() {
  console.log(obj.age);
}

// 执行副作用函数,触发读取
effect();

// 1 秒后修改响应式数据
setTimeout(() => {
  obj.age = 23;
}, 1000);

在浏览器中直接运行,我们可以得到期望的效果。

但目前的实现还存在一些问题:

  • 直接通过名字effect来获取副作用函数,如果名称变了怎么办?
  • 当我们在修改name的时候,副作用函数依然会执行

后续会逐步解决这些问题,这里大家只需要理解响应式数据的基本实现和工作原理即可。

相关代码在 commit:(5fc5489)响应式系统基本实现, git checkout 5fc5489 即可查看。

完善的响应系统

解决硬编码副作用函数名字问题

为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如以下代码所示:

// 当前激活的副作用函数
let activeEffect = null;

// 定义副作用函数
export function effect(fn) {
  // 设置当前激活的副作用函数
  activeEffect = fn;
  // 执行副作用函数
  fn();
  // 重置当前激活的副作用函数
  activeEffect = null;
}

首先,定义了一个全局变量 activeEffect,初始值是null,它的作用是存储被注册的副作用函数。接着重新定义了effect函数,它变成了一个用来注册副作用函数的函数,effect函数接收一个参数fn,即要注册的副作用函数。我们可以按照如下所示的方式使用effect函数:

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

如上面的代码所示,由于副作用函数已经存储到了activeEffect中,所以get拦截函数内应该把activeEffect收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。

get(target, key) {  
  // 将 activeEffect 添加到存储副作用函数的桶中  
  if (activeEffect) {  
    bucket.add(activeEffect);  
  }  
  // 返回属性值  
  return target[key];  
},

相关代码在 commit:(80c9898)解决硬编码副作用函数名字问题,git checkout 80c9898 即可查看。

解决副作用函数会执行多次的问题

effect(() => {  
  console.log(obj.age);  
})  
  
setTimeout(() => {  
  obj.name = 'zhuanzhuan'  
}, 2000);

在匿名副作用函数内并没有读取obj.name 属性的值,所以理论上,字段 obj.name并没有与副作用建立响应联系,因此,修改obj.name属性的值不应该触发匿名副作用函数重新执行。但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。为了解决这个问题,我们需要重新设计“桶”的数据结构。

原因

没有在副作用函数与被操作的目标字段之间建立明确的联系。之前我们使用一个Set数据结构作为存储副作用函数的“桶”。无论读取的是哪一个属性,都会把副作用函数收集到“桶”里,当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。

解决

重新设计“桶”的数据结构,做副作用函数与被操作的字段之间建立联系

“桶”结构设计

我们需要先仔细观察下面的代码:

effect(function effectFn() {  
  console.log(obj.age);  
})

在这段代码中存在三个角色:

  • 被操作(读取)的代理对象obj
  • 被操作(读取)的字段名age
  • 使用effect函数注册的副作用函数 effectFn

如果用target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

4147500398058149e12d115d64838ce5.jpg

这是一种树型结构,下面举几个例子来对其进行补充说明。

如果有两个副作用函数同时读取同一个对象的属性值:

effect(function effectFn1() {  
  console.log(obj.age);  
})  
  
effect(function effectFn2() {  
  console.log(obj.age);  
})

那么关系如下:

47995e68e4aee1b43c2b11e2551d2eee.jpg

如果一个副作用函数中读取了同一个对象的两个不同属性

effect(function effectFn1() {  
  console.log(obj.age);  
  console.log(obj.name);  
})

那么关系如下:

d1b9502b5066c5bb5992a412b3221eb7.jpg

如果在不同的副作用函数中读取了两个不同对象的不同属性:

effect(function effectFn1() {  
  console.log(obj1.age1);  
})  
  
effect(function effectFn2() {  
  console.log(obj2.age2);  
})

那么关系如下:

f1f0fc2332d268f931c1ab4d93a7b263.jpg

其实就是一个树型数据结构。这个联系建立起来之后,如果我们设置了obj2.text2的值,就只会导致effectFn2函数重新执行,并不会导致effectFn1函数重新执行,之前的问题就解决了。

代码实现

const bucket = new WeakMap();

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 没有 activeEffect,直接返回
    if (!activeEffect) return

    // 根据 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());
    
    return true
  }
});

从这段代码可以看出构建数据结构的方式,我们分别使用了WeakMap、Map和Set:

WeakMap 由 target --> Map构成;

Map 由 key -->Set构成。

其中WeakMap的键是原始对象target,WeakMap的值是一个Map实例,而Map的键是原始对象 target的key,Map的值是一个由副作用函数组成的Set。它们的关系下图所示:

b36c2d887d9754240850b7d27716b268.jpg

我们把上图中的Set数据结构所存储的副作用函数集合称为key的依赖集合。

单元测试

为什么这里才开始写单元测试?

先来看看我们写单元测试的目的:

  • 验证代码功能:确保每个组件或模块按预期工作。单元测试通常针对特定功能或代码路径,验证它们在各种输入和条件下的表现。
  • 提早发现错误:通过单元测试可以在代码集成到更大的系统之前发现问题,这有助于减少未来的调试和维护工作量。
  • 文档和示例:良好的单元测试不仅验证功能,还可以作为代码的使用示例,帮助其他开发人员理解代码的预期行为。
  • 促进重构:具有良好单元测试覆盖率的代码库使得重构变得更加安全和容易,因为可以迅速验证重构后的代码是否仍按预期工作。
  • 确保代码质量:定期运行单元测试有助于确保代码质量随时间的变化保持稳定,尤其是在多人协作的项目中。
  • 持续集成和部署:单元测试是持续集成(CI)和持续部署(CD)流程的重要组成部分,有助于自动化这些流程,确保代码改动不会引入新的错误。
  • 提高开发效率:单元测试可以减少手动测试的需要,从而加快开发过程,并使开发者更有信心进行代码更改。

之前的代码都比较简单,没有写的必要,也不能为了写而写,现在功能在慢慢增加,后续我们会对代码进行重构,需要单元测试来保证我们的功能没有问题。

编写单测

如果你不会写,或者之前没有写过,可以尝试使用ChatGPT帮我们写,以下是一个使用案例:

推荐一个ChatGPT:ask.vuejs.news/ ,也是笔者之前为了方便使用而开发。可以免费供小伙伴们使用,有使用问题可以联系笔者

我想使用 vitest 进行单元测试,以下是测试代码是我的测试代码,我会从main.ts导出一个 effect 函数 和 一个用于创建响应式对象的 reactive 函数。我们期待effect会被立即执行一次,打印出 18, 当age 改变的时候 effect 函数会被再次执行一次,打印出 23,当改变 address 的时候,effect  不会执行,我的响应式数据为:const data = { name: 'dahuang', age: 18 }。 请你帮我编写一个单元测试:

effect(function effectFn1() {
  console.log(obj.age);
})


setTimeout(() => {
  obj.age = 23;
}, 1000);

setTimeout(() => {
  obj.address = 'beijing'
}, 2000);

以下就是生成的单元测试代码:

import { describe, it, expect, vi } from 'vitest';
import { effect, reactive } from './main';  // 确保从您的 main.ts 文件中导入

describe('reactivity system', () => {
  it('should run the effect function when reactive properties change', async () => {
    const consoleSpy = vi.spyOn(console, 'log'); // 创建一个监视 console.log 调用的 spy

    // 创建响应式对象
    const obj = reactive({ name: 'dahuang', age: 18 });

    // 定义 effect 函数
    effect(() => {
      console.log(obj.age);
    });

    // 检查 effect 是否立即执行
    expect(consoleSpy).toHaveBeenCalledWith(18);

    // 更改 age 属性并等待
    setTimeout(() => {
      obj.age = 23;
    }, 1000);
    await new Promise((r) => setTimeout(r, 1100)); // 等待上述 setTimeout 完成

    // 检查 effect 是否在 age 改变时再次执行
    expect(consoleSpy).toHaveBeenCalledWith(23);

    // 更改 address 属性并等待
    setTimeout(() => {
      obj.address = 'beijing';
    }, 2000);
    await new Promise((r) => setTimeout(r, 2100));

    // 验证 effect 没有因 address 改变而执行
    expect(consoleSpy).toHaveBeenCalledTimes(2); 

    consoleSpy.mockRestore(); // 清除 mock
  });
});

解释一下这些内容:

describe:这是一个用来定义一组相关测试的函数。它通常用于将测试用例组织成逻辑分组,使测试更加结构化和易于管理。每个describe块可以包含多个测试用例。

it:这是一个定义单个测试用例的函数。每个it块通常描述了一个具体的行为或功能的期望结果。它是实际执行测试和断言的地方。

expect:这是一个用于编写测试断言的函数。测试断言是用来验证代码的行为是否符合预期的表达式。expect函数通常与一系列的匹配器(如 toBe,toEqual等)结合使用,以检查不同类型的期望值。

vi:vi是Vitest中的一个全局对象,提供了一系列的工具函数,特别是用于监视(spy)、模拟(mock)和突变(stub)函数的行为。它是Vitest特有的,用于创建更加复杂和控制的测试场景。

运行单测

那么我们就要从main.js中导出这2个函数。

相关代码在 commit:(8362dd3)设计一个完善的响应系统, git checkout 8362dd3 即可查看。

73ef305003067c9ba04b35a12ab2670f.jpg

点击即可运行,如果有问题,请看第一节环境搭建。

单测执行结果

494ff8dff328c070219d36aa2ce6705b.jpg

一个响应式系统就完成了,接下来我们还会对这个响应式系统进行增强。

下一步我们会对代码进行重构,先来体验一下单测的快乐。同时我们也来思考几个问题:

  • 存储副作用函数的桶为什么使用了WeakMap?
  • 在Proxy中的Set函数中直接返回了true,应该怎么写?不返回会有什么问题?

响应式系统代码重构

在重构代码之前,先把思考问题先解决掉,扫清障碍

分析思考问题

存副作用函数的桶为什么使用了WeakMap?

其实涉及WeakMap和Map的区别,我们用一段代码来讲解:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    const map = new Map();
    const weakmap = new WeakMap();

    (function () {
      const foo = { foo: 1 };
      const bar = { bar: 2 };

      map.set(foo, 1);
      weakmap.set(bar, 2);
    })();

    console.log(map);
    console.log(weakmap);
  </script>
</body>

</html>

当该函数表达式执行完毕后,对于对象foo来说,它仍然作为map的key被引用着,因此垃圾回收器(grabage collector)不会把它从内存中移除,我们仍然可以通过map.keys打印出对象foo。然而对于对象bar来说,由于WeakMap的key是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象bar从内存中移除,并且我们无法获取WeakMap的key值,也就无法通过WeakMap取得对象bar。

简单地说,WeakMap对key是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦key被垃圾回收器回收,那么对应的键和值就访问不到了。所以WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息,例如上面的场景中,如果target对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。但如果使用Map来代替WeakMap,那么即使用户侧的代码对target没有任何引用,这个target也不会被回收,最终可能导致内存溢出。

我们看下打印的结果,会有一个更加直观的感受,可以看到WeakMap里面已经为空了。

6fb0b357ac7cb5e1cb8e65779d661133.jpg

Proxy的使用问题

在Proxy中的set函数中直接返回了true,这样写规范吗?会有什么问题?如果不写返回值会有什么问题?

根据ECMAScript规范,set方法需要返回一个布尔值。这个返回值有重要的意义:

  1. 返回true:表示属性设置成功。
  2. 返回false:表示属性设置失败。在严格模式(strict mode)下,这会导致一个TypeError被抛出。

如果在set函数中不返回任何值(或返回undefined),那么默认情况下,它相当于返回false。这意味着:

  • 在非严格模式下,尽管不返回任何值可能不会立即引起错误,但这是不符合规范的行为。它可能导致调用代码错误地认为属性设置失败。
  • 在严格模式下,不返回true会导致抛出TypeError异常。

正确的应该这样写:

set(target, key, newVal, receiver) {  
  const res = Reflect.set(target, key, newVal, receiver)  
   
  // ...  
  
  return res  
}

那么问题又来了,为什么要配合Reflect使用呢?

我们添加一个case看看:

it('why use Reflect', () => {
  const consoleSpy = vi.spyOn(console, 'log'); // 捕获 console.log
  const obj = reactive({
    foo: 1,
    get bar() {
      return this.foo
    }
  })
  effect(() => {
    console.log(obj.bar);
  })
  expect(consoleSpy).toHaveBeenCalledTimes(1); 
  obj.foo ++
  expect(consoleSpy).toHaveBeenCalledTimes(2); 
})

当effect注册的副作用函数执行时,会读取obj.bar属性,它发现obj.bar是一个访问器属性,因此执行getter函数。由于在getter函数中通过this.foo读取了foo属性值,因此我们认为副作用函数与属性foo之间也会建立联系。当我们修改p.foo的值时应该能够触发响应,使得副作用函数重新执行才对,但是实际上effect并没有执行。这是为什么呢?

我们来看一下bucket中的收集结果:(你可以把这个case的内容直接放在main.js中运行一下,然后在浏览器中查看)

a35e4da6b7b4c092c8ed52017973408e.jpg

很明显,没有收集到foo,这是为什么呢?

我们是用的this.foo获取到的bar值,打印一下this:

be1a07faa7827cd7d3b5e16d728c7ed1.jpg

this是这个obj对象本身,并不是我们代理后的对象,自然就无法被收集到。那么如何改变这个this指向呢?就需要使用到Reflect.get函数的第三个参数receiver,可以把它理解为你函数调用过程中的this。

将代码做如下修改:

get(target, key) {
  consnt res = Reflect.get(target, key, receiver)
  // ... 其他不变
  return res
},
set(target, key, newVal) {
  const res = Reflect.set(target, key, newVal, receiver)
  // ... 其他不变
  return res
},

然后我们再次运行单测,就可以看到通过了

d4cb436e4210f012d3e32a6bf31036c8.jpg

相关代码在 commit:(c90c1ac)增加Reflect的使用,git checkout 8362dd3 即可查看。

代码重构

在目前的实现中,当读取属性值时,我们直接在get拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个track函数中,函数的名字叫track是为了表达追踪的含义。同样,我们也可以把触发副作用函数重新执行的逻辑封装到trigger函数中:

function track(target, key) {
  // 没有 activeEffect,直接返回
  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);
}

function trigger(target, key) {
  // 根据 target 从桶中取得 depsMap,它是 key --> effects
  const depsMap = bucket.get(target);

  if (!depsMap) return;

  // 根据 key 取得所有副作用函数 effects
  const effects = depsMap.get(key);

  // 执行副作用函数
  effects && effects.forEach((fn) => fn());
}

// 对原始数据的代理
export function reactive(target) {
  return new Proxy(target, {
    // 拦截读取操作
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      // 依赖收集
      track(target, key);
      return res;
    },

    // 拦截设置操作
    set(target, key, newVal, receiver) {
      // 设置属性值
      const res = Reflect.set(target, key, newVal, receiver);
      // 派发更新
      trigger(target, key);
      return res;
    },
  });
}

分别把逻辑封装到track和trigger函数内,这能为我们带来极大的灵活性。

我们来运行一下pnpm test命令:

94d82f3bd331ba87c652f9df2a68461d.jpg

可以看到,我们的case全部通过。单测,让我们的重构没有压力,这下再也不怕把代码改坏了!这里我们也可以感受出来,单测的一些好处。

当我们在写单测的时候,最好一个功能写一个case,一组有关联的逻辑放在一个测试文件中,这样当功能改动的时候,需要改动的case会最少。

相关代码在 commit:(afbaff0)响应式系统代码重构,git checkout afbaff0 即可查看。

总结

响应式系统核心逻辑流程图,如下:

1341d609b4069211f4cc31356c7ff08c.jpg