本文已参与「新人创作礼」活动,一起开启掘金创作之路
本篇是在前面实现了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);
// ...
}