「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