15_实现computed计算属性
(一)单元测试
// 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
。
// 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);
}

那接下来,就开始下一个单测。
// 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
不会执行。其实我们现在应该已经实现了啊,因为不读取,就不会调用访问器属性value
的getter
方法,自然也就不会调用_getter
。

那再次放开下面两行。
expect(cValue.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);
其实同上,应该也是通过的,那我们继续往下。
// should not compute again
cValue.value;
expect(getter).toHaveBeenCalledTimes(1);
当再次读取computed
的值时,getter
并不会被重新调用,那这里要验证的就是computed
的一大特点了,那就是会被缓存。

从单测结果可以看出,我们现在的代码,没有通过,getter被调用了两次。
那就来实现一下,首先需要有一个标识确定是否需要重新计算,那就定义一个_dirty
,还需要一个变量去存储一下首次计算得来的值,那就再定义一个_value
。
// 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;
}
}

通过,继续下一段。
// should not compute until needed
value.foo = 2;
expect(getter).toHaveBeenCalledTimes(1);
当value.foo
发生变化后,getter
依旧只会被调用一次。
那是什么意思呢?我们可以理解为,无论computed
依赖的值有没有发生变化,我们只有在用到computed
的时候,才会去重新判断是否需要重新计算和重新更新缓存值。
那先来跑一下单测看一下,看看是不是如我们所想。

这里报target
是undefined
,这是什么原因呢?
让我们回到effect.ts
,分析一下:
- 首先看出来是触发了
trigger
,那就是触发依赖了,因为此处肯定也触发set
了,然后看到value.foo
进行赋值了,所以触发依赖也很正常。 - 但是此处并没有
effect
去收集依赖,所以自然也就没有depsMap
,因为depsMap
的初始化是在track
里面。那这么看来,现在的问题就是:没法儿触发track
。
继续往下看下一段单测:
// now it should compute
expect(cValue.value).toBe(2);
expect(getter).toHaveBeenCalledTimes(2);
在下一段单测中,我们也能看到,当value
的值发生变化以后,getter
需要被再次调用一遍。
总结一下:
computed
是懒执行的,只有在用到的时候,才会调用getter
去计算;- 计算结果会进行缓存,当依赖值并未发生变化的时候,并不会重新计算。
所以,我们需要在适时的时候重新进行计算并更新缓存值。
那就意味着,当computed
依赖的原始值发生变化时,我们是需要被感知到的。
那既然如此,我们就进行依赖收集,收集一下getter
。
但是这里呢,又不太好使用effect
,那我们就引入class ReactiveEffect
,是我们的老伙计了,好久不见。
当然,在用之前,记得回到effect.ts
中导出一下。
那再改写一下computed
的原有逻辑。
// 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;
}
}
那再跑一下单测看下。

报了另外一个错,jest.fn()
也就是getter
调用了2次,我们期望是1次。
重新报错也是在预期内,因为当依赖值发生变化,会重新触发依赖,就会重新调用effect.run()
。
此时,一方面,我们并不需要实时触发依赖,也不需要去调用run
,只有在computed
的get
被触发的时候,也就是需要重新计算的时候run()
即可。
另一方面,我们也需要将_dirty
重新初始化为true
,以便于下次需要时可以重新计算。
基于上述需要,scheduler
此时站出来了。
因为当有scheduler
时,trigger
的时候,就会触发scheduler
,而scheduler
的逻辑,是可以让我们自定义的,那么问题就迎刃而解了。
this._effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true;
}
});
这样的话,就基本实现了。我们打开下面的所有单测,重新跑一下。

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