明确需求
- 计算属性具有缓存的特性,即当
getter所依赖的响应式对象的值没有发生变化时,是不会重新执行getter去进行计算的 - 具有懒加载的特性,创建了计算属性后,只要不去访问计算属性的
value,是不会触发getter的
1. 单元测试描述
接受一个 getter 函数,返回一个只读的响应式 ref 对象,即 getter 函数的返回值
首先编写单元测试用来描述该功能
describe('computed', () => {
it('happy path', () => {
const value = reactive({ foo: 1 });
const cValue = computed(() => value.foo);
expect(cValue.value).toBe(1);
});
});
先实现happy path还是比较容易的
// 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);
}
2. 实现首次访问懒加载功能
当我们创建了一个计算属性的时候,如果我们不去使用它,那么它的getter函数是不会被执行的,只有当我们用到它的时候才会执行
首先写一下描述这个功能的单元测试吧
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(undefined);
expect(getter).toHaveBeenCalledTimes(1);
});
其实目前我们的实现已经是懒加载了,只要不去访问cValue.value,那么它内部维护的ComputedRefImpl实例的get value()就不会触发,从而getter不会被执行,实现了懒加载的效果
但是问题是缓存并没有生效,如果缓存生效,那么当cValue所依赖的value.foo没有发生改变的时候,它是不会被再次计算的,也就意味着getter的执行次数仍然是1次,但是现在并不是这样,所以接下来我们需要实现缓存的功能
3. 实现缓存的功能
3.1 缓存功能的单元测试
为了体现缓存的功能,在前面的单元测试中加上如下测试
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(undefined);
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(1);
expect(getter).toHaveBeenCalledTimes(2);
// should not compute again
cValue.value;
expect(getter).toHaveBeenCalledTimes(2);
});
3.2 实现思路
我们可以用一个变量实现缓存,只要该变量为true,就意味着我们需要进行计算,而当它为false时,说明依赖的响应式对象都没有发生改变,不需要重新计算,直接返回缓存的值即可
这样的变量在vue3的源码中叫做_dirty,意思就是需要重新计算的时候,这个计算属性是一个“脏”属性,由于首次使用的时候是要计算的,所以该变量初始值为true
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;
}
}
export function computed(getter) {
return new ComputedRefImpl(getter);
}
3.3 修复depMaps不存在的bug
解决了重复调用getter的问题之后,我们要处理一下依赖的响应式对象值修改的时候的问题,也就是单元测试中的value.foo = 2
由于getter依赖于value.foo,所以value.foo改变的时候应当会去执行getter函数,但是别忘了我们修改value.foo的时候是会触发trigger的,而trigger会去获取depsMap,由于getter并没有用effect包裹,所以触发get的时候是不会将getter收集起来的
这也就导致了trigger没有办法找到相应的depsMap,从而报错
既然知道了原因,那就好办了,只要在
ComputedRefImpl内部给getter包裹到effect里面就可以了
+ import { ReactiveEffect } from './effect';
class ComputedRefImpl {
private _getter: any;
// 控制是否需要重新计算
private _dirty: boolean = true;
private _value: any;
+ private _effect: ReactiveEffect;
constructor(getter) {
this._getter = getter;
+ this._effect = new ReactiveEffect(getter);
}
get value() {
if (this._dirty) {
this._dirty = false;
- this._value = this._getter();
+ this._value = this._effect.run();
}
return this._value;
}
}
export function computed(getter) {
return new ComputedRefImpl(getter);
}
我们再来捋一下单元测试的流程:
- 调用
computed创建计算属性,会为getter实例化一个ReactiveEffect对象,这样一来当触发响应式对象的getter的时候,就会将getter作为依赖收集起来了 - 访问
cValue.value,由于是首次访问,_dirty为true,说明是脏属性,需要重新计算,因此会执行getter,但是不能直接执行,而是要通过对应的effect对象的run去执行,这样才能将activeEffect指向该effect对象,从而导致执行getter时,触发了value的get拦截,从而触发track,将activeEffect指向的effect对象作为依赖收集起来
3.4 依赖值更新懒加载 -- 直到使用计算属性时才重新计算
现在整个流程清楚了,我们的上一个报错也解决了,但是很快又迎来了新的挑战,现在又遇到一个报错
当
getter依赖的响应式对象的值发生变化时,我们是不希望getter被立即执行的,而是等到我们下次去访问到getter.value的时候才去执行,也就是懒加载
前面实现了首次访问的时候是懒加载的,现在我们依然希望即使不是首次访问计算属性的值,而是依赖的响应式对象变化后再去访问计算属性也要是懒加载的,也就是说,当响应式对象的值修改,触发trigger的时候,它会执行_effect.run(),从而执行getter,只要我们能够让它做到只有第一次执行_effect.run()的时候需要执行getter,而后续再执行的时候,不去执行getter就可以实现这一点了
这个描述听着有没有觉得很熟悉?没错,就是前面实现reactive的时候扩展的scheduler功能!
我们可以传入一个scheduler,首次触发_effect.run()的时候让它执行getter初始化计算属性,而后续则需要它去执行scheduler,这样就不会出现响应式对象一变化就立刻执行getter的情况了
并且计算属性每次需要计算的时候,它的_dirty应该为true,表示计算属性又变“脏”了,以表明计算属性需要重新计算,这个操作就可以放到scheduler中去处理
class ComputedRefImpl {
private _getter: any;
// 控制是否需要重新计算
private _dirty: boolean = true;
private _value: any;
private _effect: ReactiveEffect;
constructor(getter) {
this._getter = getter;
this._effect = new ReactiveEffect(getter);
+ this._effect.scheduler = () => {
+ if (!this._dirty) {
+ this._dirty = true;
+ }
+ };
}
get value() {
if (this._dirty) {
this._dirty = false;
this._value = this._effect.run();
}
return this._value;
}
}
这样一来,当trigger触发的时候,就不会触发到getter了,而是触发scheduler,将_dirty置为true,告知ComputedRefImpl的get value()需要进行计算了,然后当访问计算属性的value的时候,就会检查_dirty,发现,诶,我现在变“脏”了,需要重新计算了,于是调用了_effect.run(),从而实现重新计算,也就是懒加载了
好了,现在单元测试就可以顺利通过了