从零开始的手写vue3响应式 —— effect(2)

112 阅读4分钟

前言

上一篇文章讲了 effect 和响应式数据的联系,那么在本篇中,会继续完善 effect

1、嵌套的effect

effect 是可嵌套的,为什么设计成可嵌套的模式呢,就拿vue的渲染函数来说,它就是在一个effect中执行的。

const Foo = {
  render() {
    return //...
  }
}

const Bar = {
  render() {
    return <Foo />
  }
}

//  单独的一个组件渲染
effect(() => {
  Foo.render();
})

//  如果是嵌套组件渲染
effect(() => {
  Bar.render();
  
  effect(() => {
    Foo.render();
  })
})

但是很明显,目前 effect 的设计是不满足嵌套 effect 的功能的。

it("should allow nested effects", () => {
    const obj = reactive({
      foo: true,
      bar: true,
    });

    let tmp1, tmp2;

    const effectFn2 = jest.fn(() => {
      console.log("fn2 trigger--");
      tmp2 = obj.bar;
    });

    const effectFn1 = jest.fn(() => {
      console.log("fn1 trigger--");
      effect(effectFn2);
      tmp1 = obj.foo;
    });

    effect(effectFn1);
    expect(effectFn1).toHaveBeenCalledTimes(1);
    expect(effectFn2).toHaveBeenCalledTimes(1);

    obj.foo = false;
    //  我们的期望是 foo的更新 会执行 effectFn1,但是实际的情况并非这样
    expect(effectFn1).toHaveBeenCalledTimes(2);
  });

image.png

但是实际上,foo 触发到了 effectFn2,这是怎么回事。

稍微分析大概就知道,执行完 effectFn2 之后, activeEffect 就指向了 effectFn2,当执行 tmp1 = obj.foo; 这句的时候,依赖收集已经就错了。

image.png

现在问题就很明确了:

同一个时刻 activeEffect 所储存的副作用函数只能有一个,当副作用函数发生嵌套的时候,内存副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。

那么如何解决?

我们需要一个栈来存储副作用函数,副作用执行时,将副作用压进栈中,执行完毕后出栈,并始终让 activeEffect 指向栈顶副作用。这样就能做到一个响应式数据只会读取其值的副作用函数,而不会出现相互影响的情况。


class ReactiveEffect {
  // ...
  run() {
    cleanupEffect(this);
    activeEffect = this;
    effectStack.push(this);
    this._fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
}

let activeEffect: ReactiveEffect | null = null;

//  effect 栈
const effectStack: ReactiveEffect[] = [];

image.png

2、避免无限递归循环

目前,我们所设计的 effect 其实有一个比较严重的问题:

it("should avoid implicit infinite recursive loops with itself", () => {
    const counter = reactive({ num: 0 });

    const counterSpy = jest.fn(() => counter.num++);
    effect(counterSpy);
    expect(counter.num).toBe(1);
    expect(counterSpy).toHaveBeenCalledTimes(1);
    counter.num = 4;
    expect(counter.num).toBe(5);
    expect(counterSpy).toHaveBeenCalledTimes(2);
  });

image.png

可以看到,我们执行 counter.num++ 这个命令的时候引起栈溢出,这是什么原因导致的呢,我们来分析下。

首先 counter.num++ 可以被拆分成 counter.num = counter.num + 1

这段程序既会读取 counter.num 的值,又会设置 counter.num 的值

还记得我们读取的时候会调用 track,设置的时候调用 trigger

export function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  dep.add(activeEffect);

  // dep就是一个与当前副作用函数存在联系的依赖集合
  activeEffect.deps.push(dep);
}

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);

  //  为什么需要新建一个Set
  //  因为用dep循环的话,run -> cleanEffect清除掉函数 -> fn又将函数加回来 -> 死循环
  const depToRun = new Set(dep);
  depToRun.forEach((e) => e.run());
  // dep && dep.forEach((e) => e.run());
}

注意到,我们访问 counter.num 使副作用被收集到 deps,然后又立刻设置 counter.num 使其取出 deps 中的副作用函数调用。但问题是我们的 副作用函数还没执行完,又被再次调用,这样无限的递归调用自己导致栈溢出。

那么如何解决?

很简单,如果trigger触发的副作用函数当前执行的副作用函数相同,则不触发执行。

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);

  //  为什么需要新建一个Set
  //  因为用dep循环的话,run -> cleanEffect清除掉函数 -> fn又将函数加回来 -> 死循环

  //  改:const depToRun = new Set(dep);
  const depToRun = new Set<ReactiveEffect>();
  //  新增:
  dep &&
    dep.forEach((e) => {
      if (e !== activeEffect) {
        depToRun.add(e);
      }
    });

  depToRun.forEach((e) => e.run());
  // dep && dep.forEach((e) => e.run());
}

image.png

3、调度执行

可调度指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用执行的时机、次数和方式

先来看下测试用例:

it("scheduler", () => {
    let dummy;
    let run: any;
    const scheduler = jest.fn(() => {
      run = runner;
    });
    const obj = reactive({ foo: 1 });
    
    // 1--
    const runner = effect(
      () => {
        dummy = obj.foo;
      },
      { scheduler }
    );
    
    // 2--
    expect(scheduler).not.toHaveBeenCalled();
    expect(dummy).toBe(1);
    
    // 3--
    // should be called on first trigger
    obj.foo++;
    expect(scheduler).toHaveBeenCalledTimes(1);
    // should not run yet
    expect(dummy).toBe(1);
    
    // 4--
    // manually run
    run();
    
    // should have run
    expect(dummy).toBe(2);
  });

可以观察到几件事:

  1. effect会返回一个课执行函数;并且能传入配置,配置项 scheduler 是一个函数
  2. scheduler 一开始不会被触发,一开始被触发的是副作用函数
  3. 依赖触发之后,不再执行副作用函数,转而执行scheduler
  4. effect返回的函数可以手动调用,且该函数与副作用函数等价

针对以上几点,我们来完善effect:


export interface ReactiveEffectOptions {
  lazy?: boolean;
  scheduler?: EffectScheduler;
}

export interface ReactiveEffectRunner<T = any> {
  (): T;
  effect: ReactiveEffect;
}

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {

  const _effect = new ReactiveEffect(fn);
  
  //  新增:将scheduler合并到实例_effect中去,后续trigger触发的时候调用scheduler
  if (options) {
    Object.assign(_effect, options);
  }

  _effect.run();
  
  //  新增:将run方法返回出去,满足手动调用副作用的功能
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner;
  runner.effect = _effect;

  return runner;
}


export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);

  const depToRun = new Set<ReactiveEffect>();

  dep &&
    dep.forEach((e) => {
      if (e !== activeEffect) {
        depToRun.add(e);
      }
    });

  // 改:depToRun.forEach((e) => e.run());
  depToRun.forEach((e) => {
    // 新增:有scheduler的时候调用scheduler
    if (e.scheduler) {
      e.scheduler();
    } else {
      e.run();
    }
  });
}

image.png

总结

  • 实现了可嵌套的 effect
  • 修复了自增时,effect 无限递归调用的bug
  • 实现了 effect 的调度器