「mini-vue3」实现 reactive & effect 的「收集依赖/触发依赖」功能
背景
本文记录笔者实现 mini-vue3 项目的 reactive、effect 模块 收集依赖、触发依赖功能的过程,中间涉及Proxy,Reflect,Set, Map等知识。
思路
最基本的思路还是以测试来驱动开发,但是要分 2 个部分考虑,一个是收集依赖部分,一个是触发依赖部分。
由于项目高度模拟 vue3 源码,所以在开始之前,需要先有一个 reactiviy 模块来创建响应式对象。
初始化 reactivity 模块
函数签名
根据 vue3 源码,reactive 函数接收一个原始对象,返回一个响应式对象。
// src/reactivity/reactive.ts
export function reactive(target: any) {
return new Proxy(target, {})
}
测试驱动开发
编写测试用例
先把 reactive 最基本的功能列一下,也就是 happy path:
原始对象和响应式对象是不相等的。 (target !== proxy)- 调用
响应式对象的属性时,能得到原对象的同名属性值。 (proxy.key === target.key)
现在可以编写测试文件了。
// src/reactivity/tests/reactive.spec.ts
import { reactive } from '../reactive'
/*
describe 定义 reactive 组,可以包含多条测试
*/
describe('reactive', () => {
/*
it 定义单个测试
happy path 指的是 `模块要处理的最基本逻辑点`,比如下面要测试的 reactive 的 happy path:
1. 原对象 和 响应式对象 是不相等的。 (target !== proxy)
2. 调用 响应式对象 的属性时,能得到 原对象 的同名属性值。 (proxy.age === target.age)
*/
it('happy path', () => {
const target = { age: 10 };
const proxy = reactive(target);
expect(proxy).not.toBe(target);
expect(proxy.age).toBe(target.age);
})
})
实现 reactive 函数
现在,以测试来驱动开发,实现 reactive 函数。
// src/reactivity/reactive.ts
export function reactive(target: any) {
return new Proxy(target, {
get(target, key) {
return Reflect.get(target, key);
},
set(target, key, value) {
return Reflect.set(target, key, value)
}
})
}
这里通过 Proxy + Reflect 来实现,Reflect 可以和 Proxy 很好地结合使用,参考 阮一峰老师的博文:
Reflect 对象的方法与 Proxy 对象的方法一一对应,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。这就让 Proxy 对象可以方便地调用对应的 Reflect 方法,完成默认行为,作为修改行为的基础。也就是说,不管 Proxy 怎么修改默认行为,你总可以在 Reflect 上获取默认行为。
比如,如果 get 函数接受了第 3 个参数 receiver,可以直接调Reflect.get(target,key,receiver),这是 target[key] 做不到的。
执行测试,验证效果
执行 jest 测试 reactive 模块,效果如下:

测试通过✅,基础 reactivity 模块已实现。
实现「收集依赖」
本功能需要:
- 借助
effect来收集依赖函数。 - 在
依赖函数中触发响应式对象的GET操作。 - 在
GET操作中,把依赖函数封装成结点,加入该属性的容器中。
实现 effect
编写测试用例
待测试的功能有:
effect函数接收一个依赖函数fn。- 首次调用
effect时执行1次fn。
我们结合刚刚实现的 reactive 模块来编写测试代码:
import { effect } from "../effect"
import { reactive } from "../reactive";
describe('effect', () => {
it('happy path', () => {
const user = reactive({ age: 10 });
let nextAge = 0;
effect(() => {
nextAge = user.age + 1;
})
expect(nextAge).toBe(11);
})
})
当 fn 执行时,nextAge 得到的值应该是 user.age + 1,也就是 11。
通过 effect 执行依赖函数
主要就是暴露一个 effect 函数,接收 fn,执行 1 次 fn。
根据 vue3源码,我们要把 fn 封装成一个结点,所以还需要定义一个 ReactiveEffect 类,通过它暴露的方法来执行 fn。
// src/reactivity/effect.ts
class ReactiveEffect {
private _fn: Function;
constructor(fn: Function) {
this._fn = fn;
}
public run(): void {
if (!this._fn) return;
this._fn();
}
}
export function effect(fn: Function) {
const _effect = new ReactiveEffect(fn);
_effect.run();
}
执行测试,验证效果
编写完成,测试 effect 结果如下,已通过✅

收集依赖结点到属性容器
effect 收集到当前依赖结点后,响应式对象的 GET 操作应该把这个结点收集到当前属性的容器中。
保存当前依赖结点
通过一个全局变量activeEffect保存当前依赖结点:
// src/reactivity/effect.ts
let activeEffect: ReactiveEffect;
class ReactiveEffect {
private _fn: Function;
constructor(fn: Function) {
this._fn = fn;
}
public run(): void {
if (!this._fn) return;
activeEffect = this;
this._fn();
}
}
收集结点到容器
收集结点的容器需要一些数据结构的设计,具体如下:
targetMap(对象表)保存所有对象和它们的属性表,形如Map<target,keyMap>。keyMap(属性表)保存所有属性和它们的依赖集,形如Map<key, ReactiveEffectSet>。ReactiveEffectSet(依赖集)保存这个属性的所有依赖结点,形如Set<ReactiveEffect>,使用Set有一个好处,就是一个结点被添加了以后,再添加它不会加入了,这样依赖结点就不会重复。
接下来实现它:
// src/reactivity/effect.ts
let activeEffect: ReactiveEffect;
class ReactiveEffect {
private _fn: Function;
constructor(fn: Function) {
this._fn = fn;
}
public run(): void {
if (!this._fn) return;
activeEffect = this;
this._fn();
}
}
export function effect(fn: Function) {
const _effect = new ReactiveEffect(fn);
_effect.run();
}
// 完整的容器
const targetMap = new Map<Object, Map<string, Set<ReactiveEffect>>>();
// 收集依赖结点
export function track(target: Object, key: string) {
let keyMap = targetMap.get(target);
if (!keyMap) {
keyMap = new Map();
targetMap.set(target, keyMap);
}
let effectSet = keyMap.get(key);
if (!effectSet) {
effectSet = new Set();
keyMap.set(key, effectSet);
}
effectSet.add(activeEffect);
}
通过 GET 接入收集流程
当依赖函数执行时,会触发响应式对象的GET逻辑,此时接入收集结点到容器的流程,这样整个过程就连起来了。
import { track } from "./effect";
export function reactive(target: any) {
return new Proxy(target, {
get(target: Object, key: string) {
const res = Reflect.get(target, key);
// 增加 track 函数,接入收集流程
track(target, key);
return res;
},
// ...
})
}
实现「触发依赖」
编写测试用例
测试触发依赖,需要先收集依赖,再测试响应式对象属性值变化时,依赖函数是否执行。
测试用例编写如下:
// src/reactivity/tests/effect.spec.ts
import { effect } from "../effect"
import { reactive } from "../reactive";
describe('effect', () => {
it('track and trigger', () => {
const user = reactive({ age: 10 });
let nextAge = 0;
effect(() => {
// 收集依赖
nextAge = user.age + 1;
})
// 测试 fn 是否首次执行
expect(nextAge).toBe(11);
// 触发依赖
user.age++;
// 测试触发依赖
expect(nextAge).toBe(12);
})
})
测试驱动开发
有了收集依赖的基础,触发依赖非常简单。
当响应式对象的属性值被设置时,触发 SET 操作,此时遍历该属性容器的所有依赖结点,都执行一遍依赖函数即可。
遍历结点触发依赖
通过 targetMep -> keyMap -> ReactiveEffectSet 获取到依赖集,遍历其中的依赖结点,触发所有的依赖,这样就实现了触发依赖功能,这个流程在 effect 模块中处理:
// src/reactivity/effect.ts
// ...
export function trigger(target: Object, key: string) {
let keyMap = targetMap.get(target);
if (!keyMap) return;
let effectSet = keyMap.get(key);
if (!effectSet) return;
for (const effect of effectSet) {
effect.run();
}
}
通过 SET 接入触发流程
当响应式对象的属性被设置时,执行 SET 操作,接入 effect 模块提供的 触发该属性的所有依赖的流程:
// src/reactivity/reactive.ts
import { track, trigger } from "./effect";
export function reactive(target: any) {
return new Proxy(target, {
// ...
set(target: Object, key: string, value: unknown) {
const res = Reflect.set(target, key, value);
trigger(target, key);
return res;
}
})
}
验证效果
通过 jest 运行 effect 测试用例,结果如下:

测试通过✅,触发依赖功能已完成 🎉
原理示意图
笔者针对 mini-vu3 的实现,绘制了一张原理图帮助思考🍺

总结
本文高度模仿 vue3源码 的 reactive 和 effect 模块,实现了 收集依赖/触发依赖 功能,项目完整代码可在笔者的远程库 ---- mini-vue3 完成收集依赖与触发依赖 中查看。
转载自:https://juejin.cn/post/7196517835380506685