本文已参与「新人创作礼」活动,一起开启掘金创作之路
本篇是在前面实现了effect
的基础功能之上做出的额外扩展,扩展了以下几点:
effect
返回runner
函数,也就是传入的副作用函数,可以用于单独执行副作用函数- 扩展
scheduler
功能,首次调用effect.run()
时会执行副作用函数,而后续随着响应式对象变更而触发依赖时,会执行scheduler
- 扩展
stop
功能,能够停止一个runner
依赖,当不再需要函数被响应式触发时,就可以使用stop
函数去将它从响应式对象的依赖中移除,变成普通函数 - 扩展
onStop
功能,当stop
执行完毕后,就会执行onStop
,可以理解为是一个钩子,在stop
之后触发
以上扩展的这四个功能都是vue3
源码中的,它们让整个reactivity
包更加灵活,下面就让我们来开始逐一实现吧!
1. 扩展返回runner函数的功能
现在增加了一个需求,调用effect
函数后要返回执行的副作用函数runner
,并且调用runner
后会要能够返回副作用函数的返回值,就好像直接执行副作用函数一样,这样一来,调用者就可以手动执行副作用函数
仍然是先编写测试用例
it('should return runner when call effect', () => {
let foo = 10;
const runner = effect(() => {
foo++;
return 'foo';
});
expect(foo).toBe(11);
const res = runner(); // runner will return the return value of effect wrapper fn
expect(foo).toBe(12);
expect(res).toBe('foo');
});
要实现该功能很简单,无非就是给effect
函数添加返回值,返回一个runner
函数而已
export function effect(fn) {
const _effect = new ReactiveEffect(fn);
_effect.run();
// 返回 runner 函数 -- 需要显式绑定 this 为 _effect 对象
const runner = _effect.run.bind(_effect);
return runner;
}
需要注意不能直接返回_effect.run
,因为_effect.run
中使用了this
,且this
指向的是_effect
实例对象自身,这样才能够正常访问到副作用函数this._fn
,因此需要显式绑定一下
然后还需要修改effect
对象的run
方法,从原来的单纯执行变成返回执行结果,这就是为什么前面需要封装一个ReactiveEffect
类,这样才能方便我们进行扩展
class ReactiveEffect {
private _fn: any;
constructor(fn) {
this._fn = fn;
}
run() {
activeEffect = this;
return this._fn();
}
}
现在跑一下测试用例,通过后该功能就算完成了
2. 扩展 scheduler 功能
新增需求:
effect
函数调用的时候可以接收第二个参数,传入一个options
配置对象,里面可以配置一个scheduler
- 如果配置了
scheduler
,那么首次执行effect
中的副作用函数时,scheduler
不会被执行,而之后触发依赖时,副作用函数不会被执行,而是改为执行scheduler
编写测试用例:
it('scheduler', () => {
let dummy;
let run;
const foo = reactive({ bar: 1 });
const scheduler = jest.fn(() => (run = runner));
const runner = effect(
() => {
dummy = foo.bar;
},
{ scheduler }
);
// scheduler should not be called on first run effect
expect(scheduler).not.toHaveBeenCalled();
expect(dummy).toBe(1);
// scheduler should be called on first trigger
foo.bar++;
expect(scheduler).toBeCalledTimes(1);
// effect fn should not run yet
expect(dummy).toBe(1);
// manually run
run();
// effect fn should have run
expect(dummy).toBe(2);
});
根据需求,我们首先去扩展一下effect
的参数,允许传入options
配置项
export function effect(fn, options: any = {}) {
const { scheduler } = options;
const _effect = new ReactiveEffect(fn, scheduler);
_effect.run();
// 返回 runner 函数 -- 需要显式绑定 this 为 _effect 对象
const runner = _effect.run.bind(_effect);
return runner;
}
并且由于要让trigger
知道依赖触发时应当执行副作用函数还是执行scheduler
函数,需要让它能够从effect
对象中获取到scheduler
属性,如果该属性存在就执行scheduler
而不执行副作用函数
因此我们需要在创建ReactiveEffect
实例的时候给他传入,因此需要修改它的构造函数,传入scheduler
constructor(fn, public scheduler?) {
this._fn = fn;
}
scheduler
是可选参数,因此要加上?
,其次,为了能够在trigger
中访问到scheduler
属性,我们可以给它加上一个public
修饰符,这样就不需要将其赋值为实例属性,而又能够在外部访问到它了,因为它没有必要赋值为对象的属性,对象中用不到它,它仅仅是起到一个传递的作用
/**
* @description 触发依赖
* @param target 依赖的对象
* @param key 对象的属性
*/
export function trigger(target, key) {
const depsMap = targetMap.get(target);
const deps = depsMap.get(key);
for (const effect of deps) {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
}
现在再去跑一下单元测试,通过则该功能已完成
3. 扩展 stop 功能
需求:
- 可以将副作用函数
runner
停止,让其失去响应式,即依赖的数据发生变化时,副作用函数不会被执行
编写测试用例
it('stop', () => {
let dummy;
const foo = reactive({ bar: 1 });
const runner = effect(() => {
dummy = foo.bar;
});
foo.bar = 2;
expect(dummy).toBe(2);
// stop effect
stop(runner);
foo.bar = 3;
expect(dummy).toBe(2);
// stopped effect should still be manually callables
runner();
expect(dummy).toBe(3);
});
实现思路很简单,只要调用了stop
,就到存放了runner
的deps
集合中将runner
删除,即一个runner
对应多个deps
集合,我们需要到每个deps
集合中将runner
删除,实际上删除的是effect
,因为deps
中存放的是effect
对象而不是runner
函数
但是有一个问题,stop
函数能拿到的只有runner
函数,那么它怎么获得target
,key
从而去得到相应的deps
集合呢?
其实这是一个反向依赖的过程,收集依赖的时候,是从target -> key -> deps -> runner
而现在我们要做的就是runner -> deps -> key -> target
,既然只靠runner
无法做到,那么我们是否可以将这个逻辑转交给effect
对象去实现呢?事实上完全可以
依赖收集的时候,我们将activeEffect
加入到deps
中了,那么反过来,可以将deps
放入到activeEffect
中,在effect
对象中维护一个数组,专门用来存放这个副作用函数所属的集合,因为一个副作用函数可能属于多个集合,因此我们需要用数组去存放
/**
* @description 依赖收集
* @param target 对象
* @param key 属性名
*/
const targetMap = new Map(); // target -> key 的映射
export function track(target, key) {
// ...
// 依赖收集 -- 将当前激活的 fn 加入到 dep 中
dep.add(activeEffect);
// 反向收集 effect 给 dep
activeEffect.deps.push(dep);
}
在ReactiveEffect
类中添加stop
方法
class ReactiveEffect {
deps = [];
// ...
stop() {
this.deps.forEach((dep: any) => dep.delete(this));
}
}
但是现在整个stop
的逻辑还是没有转交给effect
对象的stop
方法,因为stop
中只能访问到runner
,而访问不到effect
但是别忘了,runner
是放在effect
中的,这就意味着,我们可以将effect
自身挂载到runner
上,因为在js
中函数也是对象,因此这是完全可行的,那么stop
中就可以访问到对应的effect
实例了
export function stop(runner) {
runner.effect.stop();
}
现在只需要将effect
挂载到runner
上,这点可以在effect
函数中做到
export function effect(fn, options: any = {}) {
const { scheduler } = options;
const _effect = new ReactiveEffect(fn, scheduler);
_effect.run();
// 返回 runner 函数 -- 需要显式绑定 this 为 _effect 对象
const runner: any = _effect.run.bind(_effect);
// 将 effect 实例挂载到 runner 函数对象上
runner.effect = _effect;
return runner;
}
现在运行一下单元测试,却发现报错了
这是因为我们的
track
有问题,我么只考虑到了有副作用函数时的track
场景,但实际上如果外部是单纯访问一下响应式对象的变量,而不是通过effect
函数传递副作用函数的话,那么activeEffect
实际上会是undefined
的,为了解决这个问题,我们可以在收集依赖之前判断一下,如果activeEffect
不存在,说明当前并不是副作用函数在访问,不需要进行依赖收集,直接返回即可
export function track(target, key) {
// target -> key -> deps
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
// activeEffect 对象不存在时不需要收集依赖 -- 不是副作用函数
if (!activeEffect) return;
deps.add(activeEffect);
activeEffect.deps.push(deps);
}
现在再跑单元测试,就会发现通过了
接下来需要重构一下,考虑到性能问题,外界如果多次调用
stop
函数,我们应当只去删除一次依赖,而不是每次调用都去删除,这就需要一个状态变量去标记是否有删除过依赖了
定义一个属性active
,用于表示当前实例是否是激活状态,如果被stop
了,就应当不是激活状态了,也就不会被重复删除依赖了
class ReactiveEffect {
private active = true; // 标记当前 effect 对象是否被 stop 了
stop() {
if (this.active) {
cleanupEffect(this);
this.active = false;
}
}
}
function cleanupEffect(effect) {
effect.deps.forEach((dep: any) => dep.delete(effect));
}
为了代码逻辑更加清晰,将清空依赖的逻辑封装到一个函数中,这样别人一看函数名就知道意思
4. 扩展 onStop 功能
允许接收一个onStop
回调,用于执行完stop
后就执行,相当于是一个钩子
首先编写测试用例
it('onStop', () => {
const onStop = jest.fn();
const foo = reactive({ bar: 1 });
let dummy;
const runner = effect(
() => {
dummy = foo.bar;
},
{ onStop }
);
stop(runner);
expect(onStop).toHaveBeenCalledTimes(1);
});
需要给ReactiveEffect
加上一个onStop
属性,用于接收回调,并在调用effect
的时候从options
中读取onStop
回调,将其赋值给effect
对象
export function effect(fn, options: any = {}) {
const { scheduler, onStop } = options;
const _effect = new ReactiveEffect(fn, scheduler);
_effect.onStop = onStop;
_effect.run();
// 返回 runner 函数 -- 需要显式绑定 this 为 _effect 对象
const runner: any = _effect.run.bind(_effect);
// 将 effect 实例挂载到 runner 函数对象上
runner.effect = _effect;
return runner;
}
然后在effect
的stop
方法中执行onStop
回调
class ReactiveEffect {
onStop: any;
// ...
stop() {
if (this.active) {
cleanupEffect(this);
this.onStop && this.onStop();
this.active = false;
}
}
}
function cleanupEffect(effect) {
effect.deps.forEach((dep: any) => dep.delete(effect));
}
现在测试用例就可以通过了
考虑到之后可能还会给
ReactiveEffect
对象扩展更多属性,这样一来每次添加一个属性都要在effect
函数中手动赋值新属性,比较麻烦,可以利用Object.assign
来将新的属性合并给effect
对象
export function effect(fn, options: any = {}) {
const _effect = new ReactiveEffect(fn);
// _effect.onStop = onStop;
Object.assign(_effect, options); // 改为用 Object.assign 合并配置项给 effect 对象
_effect.run();
// 返回 runner 函数 -- 需要显式绑定 this 为 _effect 对象
const runner: any = _effect.run.bind(_effect);
// 将 effect 实例挂载到 runner 函数对象上
runner.effect = _effect;
return runner;
}
并且修改一下构造函数,把之前的scheduler
作为属性设置,而不是构造函数中设置,这样一来以后我们添加新的配置项的时候,就不需要再手动去赋值属性了
class ReactiveEffect {
private _fn: any;
private active = true; // 标记当前 effect 对象是否被 stop 了
deps? = [];
onStop?: any;
scheduler?: any;
// ...
}
为了让可读性更强,可以给Object.assign
起一个别名,这样能够起到见名知意的作用,由于合并对象这样的操作属于比较公共的功能,在别的模块中也可能用到,因此创建一个shared
目录,在里面存放一些公共用到的东西
// src/shared/index.ts
export const extend = Object.assign;
// src/reactivity/effect.ts
export function effect(fn, options: any = {}) {
const _effect = new ReactiveEffect(fn);
// 合并配置项到 effect 对象中
extend(_effect, options);
// ...
}