Skip to content
On this page

15_实现computed计算属性

(一)单元测试

ts
// src/reactivity/__tests__/computed.spec.ts

describe('computed', function () {
  it('happy path', function () {
    // 特点: ref .value 缓存
    const user = reactive({
      age: 1
    });

    const age = computed(() => {
      return user.age;
    });

    expect(age.value).tobe(1);
  });
});

大家都知道computed,它的特点就是通过.value来访问,类似于ref,还有缓存。

computed函数的执行会返回一个对象,这个接口对象的value属性是一个访问器属性,只有当读取value的值时,才会执行并将其结果作为返回值返回。

(二)代码实现

既然类似于ref,那我们依旧采用同样地处理。 建立computed.ts,导出computed

ts
// src/reactivity/computed.ts

class ComputedRefImpl {
  private _getter: any;

  constructor(getter) {
    this._getter = getter;
  }

  get value() {
    return this._getter();
  }
}


export function computed(getter) {
  return new ComputedRefImpl(getter);
}
15_01_computed happy path单测结果

那接下来,就开始下一个单测。

ts
// src/reactivity/__tests__/computed.spec.ts

it('should compute lazily', () => {
  const value = reactive({
    foo: 1
  });
  const getter = jest.fn(() => value.foo);
  const cValue = computed(getter);

  // lazy
  expect(getter).not.toHaveBeenCalled();

  // expect(cValue.value).toBe(1);
  // expect(getter).toHaveBeenCalledTimes(1);

  // should not compute again
  // cValue.value;
  // expect(getter).toHaveBeenCalledTimes(1);

  // should not compute until needed
  // value.foo = 2;
  // expect(getter).toHaveBeenCalledTimes(1);

  // now it should compute
  // expect(cValue.value).toBe(2);
  // expect(getter).toHaveBeenCalledTimes(2);

  // should not compute again
  // cValue.value;
  // expect(getter).toHaveBeenCalledTimes(2);
});

根据单测呢,能看出,首先,computed是懒执行的,当我们不去读取cValue.value的时候,getter 不会执行。其实我们现在应该已经实现了啊,因为不读取,就不会调用访问器属性valuegetter方法,自然也就不会调用_getter

15_02_computed lazily第一次单测结果

那再次放开下面两行。

ts
expect(cValue.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);

其实同上,应该也是通过的,那我们继续往下。

ts
// should not compute again
cValue.value;
expect(getter).toHaveBeenCalledTimes(1);

当再次读取computed的值时,getter并不会被重新调用,那这里要验证的就是computed的一大特点了,那就是会被缓存。

15_03_computed缓存单测结果

从单测结果可以看出,我们现在的代码,没有通过,getter被调用了两次。

那就来实现一下,首先需要有一个标识确定是否需要重新计算,那就定义一个_dirty ,还需要一个变量去存储一下首次计算得来的值,那就再定义一个_value

ts
// src/reactivity/computed.ts

class ComputedRefImpl {
  private _getter: any;
  // + 增加是否需要重新缓存标识和缓存变量
  private _dirty: Boolean = true;
  private _value: any;

  constructor(getter) {
    this._getter = getter;
  }

  get value() {
    if (this._dirty) {
      this._dirty = false;
      this._value = this._getter();
    }
    return this._value;
  }
}
15_04_computed缓存第二次单测结果

通过,继续下一段。

ts
// should not compute until needed
value.foo = 2;
expect(getter).toHaveBeenCalledTimes(1);

value.foo发生变化后,getter依旧只会被调用一次。

那是什么意思呢?我们可以理解为,无论computed依赖的值有没有发生变化,我们只有在用到computed的时候,才会去重新判断是否需要重新计算和重新更新缓存值。

那先来跑一下单测看一下,看看是不是如我们所想。

15_05_computed依赖值更新无depsMap

这里报targetundefined,这是什么原因呢?

让我们回到effect.ts,分析一下:

  1. 首先看出来是触发了trigger,那就是触发依赖了,因为此处肯定也触发set了,然后看到value.foo进行赋值了,所以触发依赖也很正常。
  2. 但是此处并没有effect去收集依赖,所以自然也就没有depsMap,因为depsMap的初始化是在track 里面。那这么看来,现在的问题就是:没法儿触发track

继续往下看下一段单测:

ts
// now it should compute
expect(cValue.value).toBe(2);
expect(getter).toHaveBeenCalledTimes(2);

在下一段单测中,我们也能看到,当value的值发生变化以后,getter需要被再次调用一遍。

总结一下:

  1. computed是懒执行的,只有在用到的时候,才会调用getter去计算;
  2. 计算结果会进行缓存,当依赖值并未发生变化的时候,并不会重新计算。

所以,我们需要在适时的时候重新进行计算并更新缓存值。

那就意味着,当computed依赖的原始值发生变化时,我们是需要被感知到的。

那既然如此,我们就进行依赖收集,收集一下getter

但是这里呢,又不太好使用effect,那我们就引入class ReactiveEffect,是我们的老伙计了,好久不见。

当然,在用之前,记得回到effect.ts中导出一下。

那再改写一下computed的原有逻辑。

ts
// src/reactivity/computed.ts

import { ReactiveEffect } from './effect';

class ComputedRefImpl {
  private _dirty: Boolean = true;
  private _value: any;
  private _effect: ReactiveEffect;

  constructor(getter) {
    // + 构造_effect
    this._effect = new ReactiveEffect(getter);
  }

  get value() {
    if (this._dirty) {
      this._dirty = false;
      // + 注意此处需要用run去调用
      this._value = this._effect.run();
    }
    return this._value;
  }
}

那再跑一下单测看下。

15_06_computed依赖值更新getter调用两次

报了另外一个错,jest.fn()也就是getter调用了2次,我们期望是1次。

重新报错也是在预期内,因为当依赖值发生变化,会重新触发依赖,就会重新调用effect.run()

此时,一方面,我们并不需要实时触发依赖,也不需要去调用run,只有在computedget被触发的时候,也就是需要重新计算的时候run()即可。

另一方面,我们也需要将_dirty重新初始化为true,以便于下次需要时可以重新计算。

基于上述需要,scheduler此时站出来了。

因为当有scheduler时,trigger的时候,就会触发scheduler,而scheduler的逻辑,是可以让我们自定义的,那么问题就迎刃而解了。

ts
this._effect = new ReactiveEffect(getter, () => {
  if (!this._dirty) {
    this._dirty = true;
  }
});

这样的话,就基本实现了。我们打开下面的所有单测,重新跑一下。

15_07_computed完成

全部通过!剃刀党最喜欢看的就是这绿色PASS和一堆✅。