实现mini-vue -- reactivity模块(二)为effect扩展功能

766 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

本篇是在前面实现了effect的基础功能之上做出的额外扩展,扩展了以下几点:

  1. effect返回runner函数,也就是传入的副作用函数,可以用于单独执行副作用函数
  2. 扩展scheduler功能,首次调用effect.run()时会执行副作用函数,而后续随着响应式对象变更而触发依赖时,会执行scheduler
  3. 扩展stop功能,能够停止一个runner依赖,当不再需要函数被响应式触发时,就可以使用stop函数去将它从响应式对象的依赖中移除,变成普通函数
  4. 扩展onStop功能,当stop执行完毕后,就会执行onStop,可以理解为是一个钩子,在stop之后触发

以上扩展的这四个功能都是vue3源码中的,它们让整个reactivity包更加灵活,下面就让我们来开始逐一实现吧!

源码地址:github.com/Plasticine-…

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

现在跑一下测试用例,通过后该功能就算完成了 image.png


2. 扩展 scheduler 功能

新增需求:

  1. effect函数调用的时候可以接收第二个参数,传入一个options配置对象,里面可以配置一个scheduler
  2. 如果配置了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();
    }
  }
}

现在再去跑一下单元测试,通过则该功能已完成 image.png


3. 扩展 stop 功能

需求:

  1. 可以将副作用函数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,就到存放了runnerdeps集合中将runner删除,即一个runner对应多个deps集合,我们需要到每个deps集合中将runner删除,实际上删除的是effect,因为deps中存放的是effect对象而不是runner函数

但是有一个问题,stop函数能拿到的只有runner函数,那么它怎么获得targetkey从而去得到相应的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;
}

现在运行一下单元测试,却发现报错了 image.png 这是因为我们的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);
}

现在再跑单元测试,就会发现通过了 image.png 接下来需要重构一下,考虑到性能问题,外界如果多次调用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;
}

然后在effectstop方法中执行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));
}

现在测试用例就可以通过了 image.png 考虑到之后可能还会给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);

  // ...
}