实现mini-vue -- reactivity模块(五)实现computed计算属性

745 阅读6分钟

明确需求

  1. 计算属性具有缓存的特性,即当getter所依赖的响应式对象的值没有发生变化时,是不会重新执行getter去进行计算的
  2. 具有懒加载的特性,创建了计算属性后,只要不去访问计算属性的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,从而报错 image.png 既然知道了原因,那就好办了,只要在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);
}

我们再来捋一下单元测试的流程:

  1. 调用computed创建计算属性,会为getter实例化一个ReactiveEffect对象,这样一来当触发响应式对象的getter的时候,就会将getter作为依赖收集起来了
  2. 访问cValue.value,由于是首次访问,_dirtytrue,说明是脏属性,需要重新计算,因此会执行getter,但是不能直接执行,而是要通过对应的effect对象的run去执行,这样才能将activeEffect指向该effect对象,从而导致执行getter时,触发了valueget拦截,从而触发track,将activeEffect指向的effect对象作为依赖收集起来

3.4 依赖值更新懒加载 -- 直到使用计算属性时才重新计算

现在整个流程清楚了,我们的上一个报错也解决了,但是很快又迎来了新的挑战,现在又遇到一个报错 image.pnggetter依赖的响应式对象的值发生变化时,我们是不希望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,告知ComputedRefImplget value()需要进行计算了,然后当访问计算属性的value的时候,就会检查_dirty,发现,诶,我现在变“脏”了,需要重新计算了,于是调用了_effect.run(),从而实现重新计算,也就是懒加载了

好了,现在单元测试就可以顺利通过了 image.png