likes
comments
collection
share

17_实现相对完善的effect

作者站长头像
站长
· 阅读数 18

17_实现相对完善的effect

一、我们要优化哪些点?

在上篇实现相对完善的reactive后,那我们继续来实现相对完善的effect

首先我们还是列举一些对effect的简单考虑:

  • 分支切换,也就是不同条件执行不同代码,例如:三元表达式。
  • 嵌套effect的情况,应该如何处理?
  • prop++的情况,既读又取导致无限递归,栈溢出的情况。

二、effect相关考虑完善

(一)分支切换问题

1. 单测用例
it('should discover new branches while running automatically', () => {
  let dummy;
  const obj = reactive({ prop: 'value', run: false });

  const conditionalSpy = jest.fn(() => {
    dummy = obj.run ? obj.prop : 'other';
  });
  effect(conditionalSpy);

  expect(dummy).toBe('other');
  expect(conditionalSpy).toHaveBeenCalledTimes(1);

  obj.prop = 'Hi';
  expect(dummy).toBe('other');
  expect(conditionalSpy).toHaveBeenCalledTimes(1);

  obj.run = true;
  expect(dummy).toBe('Hi');
  expect(conditionalSpy).toHaveBeenCalledTimes(2);

  obj.prop = 'World';
  expect(dummy).toBe('World');
  expect(conditionalSpy).toHaveBeenCalledTimes(3);
});
it('should not be triggered by mutating a property, which is used in an inactive branch', () => {
  let dummy;
  const obj = reactive({ prop: 'value', run: true });

  const conditionalSpy = jest.fn(() => {
    dummy = obj.run ? obj.prop : 'other';
  });
  effect(conditionalSpy);

  expect(dummy).toBe('value');
  expect(conditionalSpy).toHaveBeenCalledTimes(1);

  obj.run = false;
  expect(dummy).toBe('other');
  expect(conditionalSpy).toHaveBeenCalledTimes(2);

  obj.prop = 'value2';
  expect(dummy).toBe('other');
  expect(conditionalSpy).toHaveBeenCalledTimes(2);
});
2. 完善逻辑
2.1. 前置概念

首先,我们需要明确的一点是:什么是分支切换

在上述单测中,可以看到,conditionalSpy中存在一个三元表达式,根据obj.run的值不同,会执行不同的代码分支。并且当obj.run 的值发生变化时,分支也会随之变更,这就是所谓的分支切换。

根据某个响应式对象值的变化,可能会增加或减少“活跃”响应式对象。 增加倒是还好,get操作会触发track进行收集起来; 减少的话,我们似乎目前并没有进行处理。那就意味着,会存在冗余依赖,那再次trigger的时候,也就会触发不必要地更新。

2.2. 通过第一个单测

下面我们分步来具体讲解一下。

在第一段单测中,运行的过程中会发现报错了,报错信息如下:

17_实现相对完善的effect

然后在点击进入到报错位置,打上断点,开始调试。

17_实现相对完善的effect

发现dep的值是undefined,而下面还接着去遍历了undefined,所以报错了。 然后再看depsMap中,只有run属性的依赖。这个很容易理解,因为effect首次运行时,只读取了run的值,自然就只有run被收集起来。

那完善思路就很明确了,只要dep存在,才继续往下运行。

// src/reactivity/effect.ts

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  // + 考虑到depsMap可能也会没有,这里我们也加上
  if (!depsMap) return;

  let dep = depsMap.get(key);
  // + 如果没有dep,则直接终止运行
  if (!dep) return;

  triggerEffects(dep);
}

再跑一遍第一段单测。

17_实现相对完善的effect

可以看到测试已通过。

2.3. 通过第二个单测

在第二段单测中,初始的依赖对应关系如下:

17_实现相对完善的effect

obj.run的值变成false时,分支随之切换,再次对应的依赖关系应该如下图:

17_实现相对完善的effect

而当我们另外在调试过程中,却发现:

17_实现相对完善的effect

obj.run变成false时,targetMap中依旧对应的是runprop的依赖。

17_实现相对完善的effect

继续往下走,当obj.prop变化时,也触发了trigger,并且取到了依赖,触发更新。

但这并不是我们所期望的,一方面,我们希望的是当分支不活跃时,理应冗余依赖应从targetMap中删除; 另一方面,就算不活跃分支中的响应式对象发生变化,也不需要去进行这种不必要地更新,因为无论更不更新都不会影响程序运行的结果且浪费性能。

那实现思路就很清晰了,我们只需要在每次收集依赖前将依赖全部清空,然后再重新收集即可。

// src/reactivity/effect.ts

export class ReactiveEffect {
  // ... 省略部分代码

  run() {
    // 已经被stop,那就直接返回结果
    if (!this.active) {
      return this._fn();
    }
    // 未stop,继续往下走
    // 此时应该被收集依赖,可以给activeEffect赋值,去运行原始依赖
    shouldTrack = true;
    // + 清空依赖
    cleanupEffect(this);
    activeEffect = this;
    const result = this._fn();
    // 由于运行原始依赖的时候,会触发代理对象的get操作,会重复进行依赖收集,所以调用完以后就关上开关,不允许再次收集依赖
    shouldTrack = false;

    return result;
  }

  // ... 省略部分代码
}

看上去好像结束了,就这么一行代码。 但如果你尝试运行单测,会发现目前的实现会导致无限循环执行。

17_实现相对完善的effect

原因在哪呢?

问题出在triggerEffectsfor of循环中: 这里会遍历执行run,而run运行中会cleanupEffect(this)清空所有依赖; 然后重新运行this._fn()原始依赖时,会继续进行依赖的收集,会重新添加到dep中。

就相当于下面这段代码:

const set = new Set([1]);

set.forEach(item => {
  set.delete(1);
  set.add(1);
  console.log('遍历中');
})

在浏览器中运行,会发现无限执行下去,内存暴增,最后卡死。

语言规范中对此有明确的说明:在调用forEach遍历Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。

因此,上面的代码会无限执行。 解决办法很简单,那就是构造另外一个Set集合并遍历它,或者拓展成数组进行遍历。 遍历的是新Set,而增删操作的是旧的Set,并不会造成什么影响。

const set = new Set([1]);
const newSet = new Set(set);
// const newArr = [...set];

newSet.forEach(item => {
  set.delete(1);
  set.add(1);
  console.log('遍历中');
})

那我们此处也采用同样的思路去解决。

export function triggerEffects(dep) {
  // + 重新构建一个新的 Set
  const effects = new Set<any>(dep);

  for (const effect of effects) {
    if (effect.scheduler) {
      // ps: effect._fn 为了让scheduler能拿到原始依赖
      effect.scheduler(effect._fn);
    } else {
      effect.run();
    }
  }
}
17_实现相对完善的effect

(二)嵌套effect问题

1. 单测用例
it('should allow nested effects', () => {
  const nums = reactive({ num1: 0, num2: 1, num3: 2 });
  const dummy: any = {};

  const childSpy = jest.fn(() => (dummy.num1 = nums.num1));
  const childEffect = effect(childSpy);
  const parentSpy = jest.fn(() => {
    dummy.num2 = nums.num2;
    childEffect();
    dummy.num3 = nums.num3;
  });
  effect(parentSpy);

  expect(dummy).toEqual({ num1: 0, num2: 1, num3: 2 });
  expect(parentSpy).toHaveBeenCalledTimes(1);
  expect(childSpy).toHaveBeenCalledTimes(2);

  // * 应该只触发childEffect
  nums.num1 = 4;
  expect(dummy).toEqual({ num1: 4, num2: 1, num3: 2 });
  expect(parentSpy).toHaveBeenCalledTimes(1);
  expect(childSpy).toHaveBeenCalledTimes(3);

  // * 触发parentEffect,触发一次childEffect
  nums.num2 = 10;
  expect(dummy).toEqual({ num1: 4, num2: 10, num3: 2 });
  expect(parentSpy).toHaveBeenCalledTimes(2);
  expect(childSpy).toHaveBeenCalledTimes(4);

  // * 触发parentEffect,触发一次childEffect
  nums.num3 = 7;
  expect(dummy).toEqual({ num1: 4, num2: 10, num3: 7 });
  expect(parentSpy).toHaveBeenCalledTimes(3);
  expect(childSpy).toHaveBeenCalledTimes(5);
});

首先明确一点:effect是可以嵌套的。

简单举个栗子就是:组件嵌套、计算属性。 那有朋友就要问了,组件嵌套和effect嵌套有什么关系吗? 其实关系就在于,组件中的template会被转成render函数,而组件要实现响应式,就得将render函数作为ReactiveEffect 的参数进行依赖收集。而当组件嵌套或者使用计算属性时,此时就会产生effect的嵌套,而这我们是需要支持的。

上面的单测就展示了effect(parentSpy)中嵌套了childEffect的情况,然后分别触发num1num2num3变化,然后观察dummy 的变化及父子effect的执行情况。

2. 完善逻辑

首先先走一遍单测,看一下我们现有的代码哪里会不满足用例的需求。

17_实现相对完善的effect
2.1. 出现第一个问题

通过报错信息可以看到,我们期望num3为7,但是实际上num3还是2。 很显然,num3并没有被更新,也就是nums.num3 = 7,并没有触发到parentSpy的执行。 那我们反推回去,可以猜测依赖收集时,depsMap中并没有收集到num3的依赖。

为了验证这个猜想,我们在nums.num3 = 7这一行,打上断点,我们来调试一下。

17_实现相对完善的effect

通过调试,可以看出,depsMap中果然只有num1num2的依赖。

那为什么会造成这个情况呢? num1num2的依赖都能收集到,那意思就是num3get操作被触发时,没有track到相关的依赖。 并且可以看到depsMap中连num3这个键都没有,那肯定就是isTracking()false时,直接return掉了。

那我们再次给parentSpy中的dummy.num3 = nums.num3;这一行打上断点,调试一下看看。

17_实现相对完善的effect

通过断点进到num3触发get后的track操作,可以看到shouldTrackfalse,那这样的话,我们上面的猜想也就成立了,果然是这个原因。

分析一下过程,首先shouldTrack为一个全局变量。 当effect(parentSpy)开始运行时,会运行run方法,shouldTrack被置为true; 再当嵌套的childEffect()运行时,也会运行里层_effectrun方法,shouldTrack先被置为true ,进行原始依赖的运行,后被置为false,不允许再次收集依赖。 当childEffect运行结束后,到我们断点的这一行,shouldTrack依旧为false,所以nums3track就直接跳出了。 但我们需要的是:shouldTrack应该为true,因为此时父级effect还并未执行结束。

2.2. 解决第一个问题

那我们就可以用一个lastShouldTrack来存储上一次的shouldTrack,再当执行完时,恢复上一次的状态。

// src/reactivity/effect.ts

export class ReactiveEffect {
  // ... 省略部分代码

  run() {
    // 已经被stop,那就直接返回结果
    if (!this.active) {
      return this._fn();
    }

    let lastShouldTrack = shouldTrack;
    // 此时应该被收集依赖,可以给activeEffect赋值,去运行原始依赖
    activeEffect = this;
    shouldTrack = true;
    cleanupEffect(this);
    const result = this._fn();
    // 由于运行原始依赖的时候,会触发代理对象的get操作,会重复进行依赖收集
    // 调用完以后就恢复上次的状态
    shouldTrack = lastShouldTrack;

    return result;
  }

  // ... 省略部分代码
}

此时,再次调试一下,可以发现,shouldTrack已经是true了。

17_实现相对完善的effect

而且我们也已经可以给nums3收集到依赖了。

17_实现相对完善的effect
2.3. 出现第二个问题

这时,我们再次跑单测时,会发现dummy.nums3还是不对,还是没有更新。

17_实现相对完善的effect

梳理一下现在的情况:

  1. 收集到了nums3的依赖
  2. nums3的依赖触发正常触发
  3. nums3的值并未被更新

综上,那只能怀疑一点,那就是nums3的依赖收集的并不对。 为什么会出现这个情况呢?

我们使用activeEffect这个全局变量来存储通过effect注册的依赖,而这么做的话,我们一次只能存储一个依赖。 当从外层effect进入里层effect时,内层函数的执行会覆盖activeEffect的值,activeEffect的指向从parentSpy转向childSpy。 并且,这个指向的变化是不可逆的,没办法从里向外层转。 所以,导致nums3的依赖虽然收集到了,但是收集的activeEffectchildSpy,而不是parentSpy

为了验证是不是这个问题,我们需要调整一下测试用例nums.num3 = 7;后面代码的执行顺序。

// src/reactivity/tests/effect.spec.ts

nums.num3 = 7;
expect(childSpy).toHaveBeenCalledTimes(5);
expect(parentSpy).toHaveBeenCalledTimes(3);
expect(dummy).toEqual({ num1: 4, num2: 10, num3: 7 });
17_实现相对完善的effect

果然如此,通过截图可以看出: childSpy的期望是通过的,那么childSpy的执行次数为5,而parentSpy的执行次数可以看到实际执行次数为2。 那就证明nums3变化后触发了childSpy的执行,那收集到的依赖不对,就不言而喻了。

2.4. 解决第二个问题

归根结底的原因,其实也可以理解成:由于effect的嵌套,导致activeEffect的指向未能恢复到上一次的状态。 那我们要做的就是,将activeEffect先行保存,结束完以后再恢复。 做法其实和解决第一个问题类似。

// src/reactivity/effect.ts

export class ReactiveEffect {
  // ... 省略部分代码

  run() {
    // 已经被stop,那就直接返回结果
    if (!this.active) {
      return this._fn();
    }

    let parent = activeEffect;
    let lastShouldTrack = shouldTrack;

    try {
      parent = activeEffect;
      // 此时应该被收集依赖,可以给activeEffect赋值,去运行原始依赖
      activeEffect = this;
      shouldTrack = true;

      cleanupEffect(this);
      return this._fn();
    } finally {
      // 由于运行原始依赖的时候,会触发代理对象的get操作,会重复进行依赖收集
      // 调用完以后就恢复上次的状态
      activeEffect = parent;
      shouldTrack = lastShouldTrack;
    }
  }

  // ... 省略部分代码
}
17_实现相对完善的effect
2.5. 其他思路

仔细再回想一下解决上述两个问题的过程,其实都是,内层函数运行结束时,对应的变量没有恢复到外层的状态。 这种类似嵌套和递归的过程,都是一种进到最里层,然后再一层一层向外走。 有点类似于洋葱圈模型,类似这种,都可以类比成入栈出栈的操作。 那这样的话,我们就可以模拟一个结构,来进行这样的操作。

这里附上代码。

// src/reactivity/effect.ts

const effectStack: any = [];

export class ReactiveEffect {
  // ... 省略部分代码

  run() {
    // 已经被stop,那就直接返回结果
    if (!this.active) {
      return this._fn();
    }

    if (!effectStack.includes(this)) {
      cleanupEffect(this);
      let lastShouldTrack = shouldTrack;
      try {
        // 此时应该被收集依赖,可以给activeEffect赋值,去运行原始依赖
        shouldTrack = true;
        // 入栈
        effectStack.push(this);
        activeEffect = this;
        return this._fn();
      } finally {
        // 出栈
        effectStack.pop();
        // 由于运行原始依赖的时候,会触发代理对象的get操作,会重复进行依赖收集,所以调用完以后就关上开关,不允许再次收集依赖
        // 恢复 shouldTrack 开启之前的状态
        shouldTrack = lastShouldTrack;
        activeEffect = effectStack[effectStack.length - 1];
      }
    }
  }

  // ... 省略部分代码
}

(三)无限递归循环

1. 单测用例
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);
});
2. 完善逻辑

还是先来跑一下这个单测,看看有什么问题。

17_实现相对完善的effect

通过单测可以看出effect中的依赖是一个自增操作counter.num++,单测运行过程中引起了栈溢出

那为什么会出现栈溢出呢? 那就要对比自增跟我们之前的依赖有什么不同? 可以注意到,我们之前的依赖都是单一操作,要么,要么。 而自增可以分成两步来看,先读取自身的值,然后再加一并写入。

再来分析一下执行过程:

首先读取counter.num的值,这会触发track操作,将当前副作用函数收集到depsMap中,接着将其加1后再赋值给counter.num ,此时会触发trigger操作,即把depsMap中的副作用函数取出并执行。但问题是该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。这样会导致无限递归地调用自己,于是就产生了栈溢出。

解决办法并不难。 通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。 此时无论是track时收集的副作用函数,还是trigger时要触发执行的副作用函数,都是activeEffect。基于此,我们可以在trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行,如以下代码所示:

// src/reactivity/effect.ts

export function triggerEffects(dep) {
  const effects = new Set<any>();

  // + 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
  dep && dep.forEach(effect => {
    if (effect !== activeEffect) {
      effects.add(effect);
    }
  });

  for (const effect of effects) {
    if (effect.scheduler) {
      // ps: effect._fn 为了让scheduler能拿到原始依赖
      effect.scheduler(effect._fn);
    } else {
      effect.run();
    }
  }
}
3. 单测结果
17_实现相对完善的effect

ps

这是一个 早起俱乐部

⭐️ 适合人群:所有想有所改变的人,可以先从早起半小时开始!抽出30分钟,从初心开始!! ⭐️ 没有任何其它意味,只是本人想寻找一起早起、志同道合的小伙伴。