基于上一篇文章中实现的effect
方法,根据 Vue3 源码中单测,完善该方法的三点功能,分别是:
- runner:
effect
可以返回自执行的入参runner
函数 - scheduler:
effect
支持添加第二个参数选项中的scheduler
功能 - stop:
effect
添加stop
功能
runner
单测
在effect.spec.ts
文件中添加关于runner
的测试用例。
it("should be return runner when call effect", () => {
let foo = 1;
const runner = effect(() => {
foo++;
return "foo";
});
expect(foo).toBe(2);
const r = runner();
expect(foo).toBe(3);
expect(r).toBe("foo");
});
上面测试用例的意思是,effect
内部的函数会自执行一次,foo
的值变成2。effect
是一个可执行函数runner
,执行runner
时effect
内部函数也会执行,因此foo
的值会再次自增变成3,并且runner
的返回值就是effect
内部函数的返回值。
实现
effect
函数需要可以返回它的入参执行函数,且内部执行函数可以返回。
class ReactiveEffect {
private _fn: any;
constructor(fn) {
this._fn = fn;
}
run() {
reactiveEffect = this;
return this._fn();
}
}
export function effect(fn) {
let _effect = new ReactiveEffect(fn);
_effect.run();
const runner = _effect.run.bind(_effect)
return runner;
}
需要注意的是,这里存在this
指向的问题,在返回_effect.run
函数时需要绑定当前实例。
验证
执行yarn test effect
scheduler
单测
it("scheduler", () => {
let dummy;
let run: any;
const scheduler = jest.fn(() => {
run = runner;
});
const obj = reactive({ foo: 1 });
const runner = effect(
() => {
dummy = obj.foo;
},
{
scheduler,
}
);
expect(scheduler).not.toHaveBeenCalled();
expect(dummy).toBe(1);
// should be called on first trigger
obj.foo++;
expect(scheduler).toHaveBeenCalled();
// should not run yet
expect(dummy).toBe(1);
// manually run
run();
// should have run
expect(dummy).toBe(2);
});
上面测试用例代码的意思是:effect
方法接收第二个参数,是一个选项列表对象,其中有一个是scheduler
,是一个函数。这里用jest.fn
模拟了一个函数将变量run
赋值成runner
函数。在第一次执行的时候,scheduler
函数不调用执行,effect
的第一个参数函数自执行,所以dummy
赋值为1;当响应式对象变化时,也就是obj.foo++
时,scheduler
会被执行,但是dummy
的值还是1,说明第一个参数函数并没有执行;run
执行,也就是effect
返回函数runner
执行时,第一个参数函数执行,因为obj.foo++
,所以dummy
变成2。
可以总结出scheduler
包含的需求点:
- 通过
effect
的第二个参数给定一个scheduler
的fn
effect
第一次执行的时候,执行第一个参数function
- 当响应式对象触发
set
操作时,不会执行function
,而执行scheduler
- 当执行
runner
时,会再次执行function
实现
首先是effect
函数可以接收第二个对象参数。
export function effect(fn, options: any = {}) {
let _effect = new ReactiveEffect(fn, options.scheduler);
_effect.run();
const runner = _effect.run.bind(_effect)
return runner;
}
Class类中也要相应的接收scheduler
class ReactiveEffect {
private _fn: any;
public scheduler: Function | undefined;
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
reactiveEffect = this;
return this._fn();
}
}
当响应式对象触发set
操作时,也就是触发依赖时,在trigger
方法中,执行scheduler
,只需要判断是否存在scheduler
,存在即执行。
export function trigger(target, key) {
let depMap = targetMap.get(target);
let dep = depMap.get(key);
for (const effect of dep) {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
}
验证
stop
单测
import { effect, stop } from "../reactivity/effect";
it("stop", () => {
let dummy;
const obj = reactive({ prop: 1 });
const runner = effect(() => {
dummy = obj.prop;
});
obj.prop = 2;
expect(dummy).toBe(2);
stop(runner);
obj.prop = 3;
expect(dummy).toBe(2);
// stopped effect should still be manually callable
runner();
expect(dummy).toBe(3);
});
it("onStop", () => {
const onStop = jest.fn();
const runner = effect(() => {}, { onStop });
stop(runner);
expect(onStop).toHaveBeenCalled();
});
stop
功能有两个测试用例,对应不同的功能,我们逐个分析。
"stop"
中,effect
内函数自执行一次,所以第一次断言dummy
为上面赋值的2;执行stop
方法,stop
方法是来自effect
对外暴露的方法,它接收runner
函数作为参数,即便再更新响应式对象,effect
内函数也不执行,dummy
仍然是2;再次执行runner
,恢复执行effect
内函数,dummy
变成了3。
总结来说,stop
可以阻止effect
内函数执行。
"onStop"
中,effect
函数接收第二个参数对象中有个属性是onStop
,且接收一个函数,当执行stop
时,onStop
函数会被执行。
实现
触发依赖时,trigger
方法中循环执行了dep
中所有的effect
内方法,那需要阻止执行,就可以从dep
中删除该项。
首先stop
方法接收runner
函数作为参数。
export function stop(runner) {
runner.effect.stop();
}
在runner
函数上挂载一个effect
实例,就可以获取到 Class 类中定义的stop
方法。
class ReactiveEffect {
private _fn: any;
public scheduler: Function | undefined;
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
reactiveEffect = this;
return this._fn();
}
stop() {}
}
export function effect(fn, options: any = {}) {
let _effect = new ReactiveEffect(fn, options.scheduler);
extend(_effect, options);
_effect.run();
const runner: any = _effect.run.bind(_effect);
runner.effect = _effect; // 挂载effect实例
return runner;
}
那如何从dep
中删除需要阻止执行的一项呢?
在track
方法中dep.add(reactiveEffect)
建立了dep
这个Set
结构和effect
实例的关系,但是在 Class
类中并没有实例和dep
的映射关系,因此可以Class
类中定义一个deps
数组用来存放该实例的所有dep
,在需要调用stop
方法时将删除dep
中的该effect
实例方法。
class ReactiveEffect {
private _fn: any;
public scheduler: Function | undefined;
deps = [];
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
reactiveEffect = this;
return this._fn();
}
stop() {
this.deps.forEach((dep: any) => {
dep.delete(this);
});
}
}
export function track(target, key) {
...
dep.add(reactiveEffect);
reactiveEffect.deps.push(dep); // 存放deps
}
验证
优化
虽然单测通过了,但是代码是有优化空间的,我们来重构一下。
stop
方法中逻辑可以抽离成一个单独函数。
class ReactiveEffect {
...
stop() {
cleanupEffect(this);
}
}
function cleanupEffect(effect) {
effect.deps.forEach((dep: any) => {
dep.delete(effect);
});
}
性能上的优化,当用户一直调用stop方法,会导致这儿一直无故循环遍历,因此可以设置一个标志位来判断是否已经调用过执行了删除操作。
class ReactiveEffect {
private _fn: any;
public scheduler: Function | undefined;
deps = [];
active = true;
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
reactiveEffect = this;
return this._fn();
}
stop() {
if (this.active) {
cleanupEffect(this);
this.active = false;
}
}
}
重构后需要再次执行单测,确保没有破坏功能。
实现
来实现stop
的第二个功能onStop
。
首先将onStop
方法挂载effect
实例上。
export function effect(fn, options: any = {}) {
let _effect = new ReactiveEffect(fn, options.scheduler);
_effect.onStop = options.onStop
_effect.run();
const runner: any = _effect.run.bind(_effect);
runner.effect = _effect;
return runner;
}
当执行stop
时,onStop
函数会被执行。
class ReactiveEffect {
private _fn: any;
public scheduler: Function | undefined;
deps = [];
active = true;
onStop?: () => void;
constructor(fn, scheduler) {
this._fn = fn;
this.scheduler = scheduler;
}
run() {
reactiveEffect = this;
return this._fn();
}
stop() {
if (this.active) {
cleanupEffect(this);
if (this.onStop) {
this.onStop();
}
this.active = false;
}
}
}
验证
优化
effect
方法的第二个参数options
可能存在很多选项,那每次都通过_effect.onStop = options.onStop
挂载到实例上是不优雅的,因此可以抽离这块的逻辑,作为一个公共的方法。
在 src 下新建文件夹 shared,新建index.ts
export const extend = Object.assign;
那在effect
中就可以使用extend
方法更语义化表达。
export function effect(fn, options: any = {}) {
let _effect = new ReactiveEffect(fn, options.scheduler);
extend(_effect, options);
_effect.run();
const runner: any = _effect.run.bind(_effect);
runner.effect = _effect;
return runner;
}
重构完再次执行yarn test effect
验证是否破坏功能。
验证
最后需要执行全部的单测,验证新增功能对原有代码是否有破坏,执行yarn test
在执行reactive
单测时,出现了如上的报错,提示reactiveEffect
可能是undefined
不存在deps
。
在reactive.spec.ts
中只是单纯的测试了reactive
的核心功能,此时还没有涉及到effect
方法,reactiveEffect
的赋值是在effect
自执行时触发的,因此是初始undefined
状态。
export function track(target, key) {
...
if (!reactiveEffect) return; // 边界处理
dep.add(reactiveEffect);
reactiveEffect.deps.push(dep);
}
最后再次验证,测试通过,功能完善成功。
2023/11/13更新
修改stop单测
在原本的基础上,修改effect
中stop
测试用例。
it("stop", () => {
let dummy;
const obj = reactive({ prop: 1 });
const runner = effect(() => {
dummy = obj.prop;
});
obj.prop = 2;
expect(dummy).toBe(2);
stop(runner);
// obj.prop = 3;
obj.prop++;
expect(dummy).toBe(2);
// stopped effect should still be manually callable
runner();
expect(dummy).toBe(3);
});
运行单测yarn test effect
报错分析
简单分析一下报错的原因。
obj.prop++
可以理解成obj.prop = obj.prop + 1
,存在get
和set
两个操作,触发get
操作会重新收集依赖,导致stop
中cleanupEffect
方法删除所有effect
失效。
实现
知道了根本原因是先触发get
操作重新执行了effect
中函数,也就是调用了track
方法,那需要完善的逻辑应该这个方法入手。我们可以定义一个全局变量shouldTrack
来判断是否需要进行track
操作。
let reactiveEffect;
let shouldTrack; // 定义
export function track(target, key) {
...
if(!shouldTrack) return // 直接return不进行依赖收集
if (!reactiveEffect) return;
dep.add(reactiveEffect);
reactiveEffect.deps.push(dep);
}
进行赋值的时候触发set
操作,执行trigger
函数,最终调用的是 Class 类ReactiveEffect
中run
方法。run
方法中原本是直接返回了入参函数的执行结果,这里就需要判断一下stop
的情况,可以依据active
来判断。
如果是调用了stop
方法之后,active
赋值为false
,这时候直接返回fn
;
如果没有调用stop
方法,先将shouldTrack
设为true
,表示可以进行track
调用,然后执行fn
,并将执行结果返回,但是在返回之前需要重置操作,将shouldTrack
设置成false
,因为如果在遇到stop
之后,run
函数中会直接return
,不会将shouldTrack
设为true
,那在track
时,就会走!shouldTrack
直接return
不再收集依赖。
run() {
if (!this.active) {
return this._fn();
}
shouldTrack = true;
reactiveEffect = this;
const result = this._fn();
shouldTrack = false;
return result;
}
重构
track
中shouldTrack
和reactiveEffect
的边界判断,可以提到track
函数体内顶部,单独封装一个函数合成这两个判断。
依赖收集这儿可以优化的点,当dep
中存在的reactiveEffect
就不再重复收集了。
export function track(target, key) {
if (!isTracking()) return;
...
if (dep.has(reactiveEffect)) return;
dep.add(reactiveEffect);
reactiveEffect.deps.push(dep);
}
function isTracking() {
return shouldTrack && reactiveEffect !== undefined;
}
调试
修改一下单测,更简单的单测来通过调试清晰看一下上述流程。
it("stop", () => {
let dummy;
const obj = reactive({ prop: 1 });
const runner = effect(() => {
dummy = obj.prop;
});
stop(runner);
obj.prop++;
expect(dummy).toBe(1);
});
这里通过一个视频讲解来更形象的了解,视频详情查看