从单测入手,完善Vue3源码中底层API effect功能

基于上一篇文章中实现的effect方法,根据 Vue3 源码中单测,完善该方法的三点功能,分别是:

  1. runner: effect可以返回自执行的入参runner函数
  2. scheduler: effect支持添加第二个参数选项中的scheduler功能
  3. 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,执行runnereffect内部函数也会执行,因此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

从单测入手,完善Vue3源码中底层API effect功能_第1张图片

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包含的需求点:

  1. 通过effect的第二个参数给定一个schedulerfn
  2. effect第一次执行的时候,执行第一个参数function
  3. 当响应式对象触发set操作时,不会执行function,而执行scheduler
  4. 当执行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();
    }
  }
}

验证

从单测入手,完善Vue3源码中底层API effect功能_第2张图片

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
}

验证

从单测入手,完善Vue3源码中底层API effect功能_第3张图片

优化

虽然单测通过了,但是代码是有优化空间的,我们来重构一下。

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;
    }
  }
}

验证

从单测入手,完善Vue3源码中底层API effect功能_第4张图片

优化

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

从单测入手,完善Vue3源码中底层API effect功能_第5张图片

在执行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单测

在原本的基础上,修改effectstop测试用例。

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

从单测入手,完善Vue3源码中底层API effect功能_第6张图片

报错分析

简单分析一下报错的原因。

obj.prop++可以理解成obj.prop = obj.prop + 1,存在getset两个操作,触发get操作会重新收集依赖,导致stopcleanupEffect方法删除所有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 类ReactiveEffectrun方法。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;
}

重构

trackshouldTrackreactiveEffect的边界判断,可以提到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);
});

这里通过一个视频讲解来更形象的了解,视频详情查看

你可能感兴趣的:(javascript,前端,vue.js)