vue3源码阅读与实现: 响应式系统-computed模块

136 阅读6分钟

本文是专栏的第四篇,上一篇地址为vue3源码阅读与实现: 响应式系统-ref模块

computed模块

模块总览

image-20240806142847263.png

computed接收一个getter函数,并返回一个计算属性ref,当getter函数中的响应式状态变化后,会引起getter重新计算,通过计算属性ref可以访问计算的结果.计算属性有以下特点:

  1. 缓存性: 如果getter中的响应式状态没有变化,多次访问计算属性ref的值会直接使用缓存而不是重新计算
  2. 不推荐直接修改计算属性: 如果一定要直接修改,必须同时传递gettersetter函数

有以下一些问题:

  1. 计算属性是一个响应式数据,所以一定会有收集依赖和触发依赖的操作.收集依赖可以在访问计算属性时进行,那触发依赖应该在什么时机进行呢?
  2. 计算属性的缓存是怎么实现的?
  3. 为什么访问计算属性需要通过.value?

带着这些疑问来一起debugger

debugger

使用如下测试用例:

...
    <div id="app"></div>
    <script>
      const { reactive, effect, computed } = Myvue;
      const obj = reactive({ name: "响应式" });
​
      debugger
      const computedData = computed(() => { // 关注点1: computed对getter函数进行了怎样的处理,又返回了什么值
        return `这是计算数据的返回的值: ${obj.name}`;
      });
​
      effect(() => {
        debugger
        document.querySelector("#app").innerHTML = computedData.value; // 关注点2: 访问计算属性时,计算属性如何收集依赖
      });
​
      setTimeout(() => {
        debugger
        obj.name = "修改之后值"; // 关注点3: 响应式状态改变,怎么引发计算属性触发依赖的
      }, 2000);
...

同样,debugger时有以下关注点:

  1. 关注点1: computedgetter函数进行了怎样的处理,又返回了什么值
  2. 关注点2: 访问计算属性时,计算属性如何收集依赖
  3. 关注点3: 响应式状态改变,怎么使计算属性触发依赖的

vscode中右键open with live server,开始调试

关注点1:计算属性的创建

  1. 进入第一个debugger,首先根据参数类型对gettersetter进行了初始化,然后返回了ComputedRefImpl实例,根据这个名字是不是就知道为什么官网叫计算属性ref了吧

image-20240716113307326.png

  1. 进入ComputedRefImpl,该类有两个重要属性

    1. effect: 存放了计算属性的回调函数,this.effect.run()可以重新执行该回调

    2. _dirty: 表示计算属性依赖的响应式数据是否变化,如果变化了在获取计算属性时需要重新执行this.effect.run重新计算

image-20240716120132081.png

  1. 在其构造函数中,首先创建了ReactiveEffect实例effect,这一步就把getter保存在了effect实例中,同时注意到还传递了第二个参数,值是一个箭头函数,箭头函数中包含一个triggerRefValue,看名字这个箭头函数应该和触发依赖有关,

image-20240716113718601.png

  1. 进入ReactiveEffect查看第二个参数,是一个调度器scheduler,(很重要,计算属性触发依赖的关键)

image-20240716114219241.png

  1. 触发依赖时这个调度器的执行优先级比run函数高,可以从triggerEffect函数看出来,有调度器就不会执行run函数了

image-20240716114416656.png

  1. 这里先有个印象,之后触发调度器的时候再详细讲解,到此,返回了这个计算属性ref,computed的初始化逻辑就结束了
总结

computed函数主要做了一件事情:

  1. 创建ComputedRefImpl实例,并返回,在实例中创建了一个ReactiveEffect,和以往响应式处理不同的是这次传递了一个调度器scheduler

关注点2:计算属性的依赖收集

进入第二个debugger

  1. 触发了计算属性的get value,首先进行依赖收集trackRefValue(在ref模块中讲解过),然后执行了this.effect.run,

image-20240716120920676.png

  1. 这里的run函数其实触发的就是,计算属性的参数

    () => { return `这是计算数据的返回的值: ${obj.name}`;}
    

    由于函数中访问了,objname,所以会触发obj的依赖收集,这个过程在reactive章节中已经详细描述,这里不再赘述,只说最终结果: 将this.effect添加到obj的依赖集合中

  2. 最后返回了run函数运行的结果

总结

这个过程中ComputedRefImpl实例主要在get value中做了2件事情

  1. 收集依赖,这里收集到的是计算属性的依赖
  2. 执行run函数,触发响应式状态的依赖收集

关注点3:计算属性的依赖触发

在这里,对响应式数据进行修改,查看响应式数据的依赖触发是如何引起计算属性的依赖触发的

  1. 进入debugger,修改name后,首先在proxyset中会先触发name属性相关依赖,也就是,在关注点2第2步收集的this.effect

  2. 关注点1我们知道this.effect是一个包含调度器的effect,因此触发时,会优先执行调度器逻辑,如图:

image-20240716123035864.png

  1. 而在调度器schduler中,正是计算属性触发依赖的逻辑,因此开始触发计算属性的依赖

image-20240716123212458.png

  1. 这样就实现了,响应式数据变化,触发计算属性重新计算的逻辑
总结

当响应式状态变化时,通过调度器,开启了计算属性的依赖触发,从而保证了自动执行下面的逻辑:响应式数据变化 => 计算属性变化 => 使用计算属性的地方变化

总结

在计算属性中,依赖关系是这样的: 数据变化 => 计算属性重新计算 => 使用计算属性的地方自动更新:

  1. 数据变化时触发依赖,使计算属性重新计算
  2. 计算属性重新计算,触发计算属性的依赖,从而让使用计算属性的地方自动更新

总的来说,计算属性的实现需要解决这么个问题:

因为计算属性没有set,因此无法像ref一样在set value中处理计算属性的依赖触发逻辑.那么就要找一个时机,来进行计算属性的依赖触发:

由上述第一步可知,数据变化相当于计算属性发生变化,那么在数据变化触发依赖时,进行计算属性的依赖触发即可.由reactive和ref模块源码可知,比如fn中访问了响应式数据那么fn就会被作为依赖收集起来,依赖触发时会重新执行fn,这样的依赖收集和触发逻辑显然不符合现在的期望,我们期望的是由fn触发了响应式数据的依赖收集,但依赖触发的时候不执行fn而是干点别的,比如去触发计算属性的依赖.因此为了实现这个逻辑,就可以使用调度器scheduler,在scheduler中进行计算属性的依赖触发,因为这个函数的优先级高,有他在依赖触发的时候就不会执行run而总是执行调度器

实现computed模块

computed函数

创建ComputedRefImpl,在创建ReactiveEffect实例时,需要传递第二个参数是scheduler

packages/reactivity/src/computed.ts,

import { isFunction } from "@vue/shared";
import { Dep } from "./deps";
import { ReactiveEffect } from "./effect";
import { trackRefValue, triggerRefValue } from "./ref";
​
export function computed(getterOrOptions) {
  let getter;
  const onlyGetter = isFunction(getterOrOptions);
  if (onlyGetter) {
    getter = getterOrOptions;
  }
  const cRef = new ComputedRefImpl(getter);
  return cRef;
}
​
export class ComputedRefImpl<T> {
  private _value!: T;
  public dep?: Dep;
  public readonly effect: ReactiveEffect<T>; // 重要属性,该实例保存了计算属性中的函数参数,也就是说每次需要获取计算属性的值,都要调用这个属性的run方法
  public readonly __v_isRef: boolean = true;
  public _dirty: boolean = true; // 重要属性,决定是否重新计算和触发计算属性的依赖
​
  constructor(getter) {
    // 传递调度器的原因: 在响应式数据触发依赖时,执行一些其他逻辑,而不是依赖的run函数.
    // 这里的scheduler意思是, 响应式数据触发依赖时也触发计算属性的依赖
    // 原因: 计算属性不一定有set操作所以不能在set中进行依赖触发,
    // 但计算属性的依赖一定需要计算属性的值变化时触发才能完成响应式, 计算属性的值变化依赖于某些响应式数据,那么当响应式数据变化时触发依赖即可
    // 当前的响应式收集依赖和触发依赖都是通过执行effect的run方法,现在的场景需要:
    //     收集依赖时通过run方法, 触发依赖时执行其他方法, 于是在effect中新加一个属性, 触发依赖时, 这个属性有值, 就执行这个属性
    // 因此将依赖触发的逻辑放在scheduler中,
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true;
        triggerRefValue(this);
      }
    });
    this.effect.computed = this;
  }
​
  get value() {
    // 依赖收集
    trackRefValue(this);
    if (this._dirty) {
      this._dirty = false;
      this._value = this.effect.run()!;
    }
​
    // 返回_value
    return this._value;
  }
}

修改ReactiveEffect

为之前定义的ReactiveEffect类新增shceduler属性

packages/reactivity/src/effets.ts

export type EffectScheduler = (...args: any[]) => any;
​
export class ReactiveEffect<T = any> {
    ...
  public scheduler?: EffectScheduler | null = null; // 调度器,如果有调度器,触发依赖时会优先执行调度器
  constructor(fn: () => T, scheduler?: EffectScheduler) {
      ...
    this.scheduler = scheduler; // 和计算属性相关
  }
}

总结

回到刚开始的几个问题:

  1. 计算属性是一个响应式数据,所以一定会有收集依赖和触发依赖的操作.收集依赖可以在访问计算属性时进行,那触发依赖应该在什么时机进行呢?

    计算属性的依赖触发通过scheduler在响应式状态发生变化时触发

  2. 计算属性的缓存是怎么实现的?

    每个计算属性ref都有一个_dirty属性,表示响应式数据是否发生了变化,如果变化,那么在执行访问计算属性时会重新计算,否则使用缓存的值

  3. 为什么访问计算属性需要通过.value?

    1. 计算属性实际上是一个ComputedRefImpl实例,和ref一样内部通过属性访问器get value()实现,所以需要.value才能访问