vue3 源码学习,实现一个 mini-vue(四):computed 计算属性

1,564 阅读10分钟

前言

本篇文章原文来自 我的个人博客 github地址

对于响应性系统而言,除了前两章接触的 ref 和 reactive 之外,还有另外两个也是我们经常使用到的,那就是:

  1. 计算属性:computed
  2. 侦听器:watch

本章我们先来实现一下 computed 这个 API

1. computed 计算属性

计算属性 computed 会 基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算

我们来看下面这段代码:

<div id="app"></div>
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    return '姓名:' + obj.name
  })

  effect(() => {
    document.querySelector('#app').innerHTML = computedObj.value
  })

  setTimeout(() => {
    obj.name = '李四'
  }, 2000)
</script>

上面的代码,程序主要执行了 5 个步骤:

  1. 使用 reactive 创建响应性数据
  2. 通过 computed 创建计算属性 computedObj,并且触发了 obj 的 getter
  3. 通过 effect 方法创建 fn 函数
  4. 在 fn 函数中,触发了 computed 的 getter
  5. 延迟触发了 obj 的 setter

接下来我们将从源码中研究 computed 的实现:

2. computed 源码阅读

  1. 因为研究过了 reactive 的实现,所以我们直接来到 packages/reactivity/src/computed.ts 中的第 84 行,在 computed 函数出打上断点:

image.png

  1. 可以看到 computed 方法其实很简单,主要就是创建并返回了一个 ComputedRefImpl 对象,我们将代码跳转进 ComputedRefImpl 类。

image.png

  1. 在 ComputedRefImpl 的构造函数中 创建了 ReactiveEffect 实例,并且传入了两个参数:
    1. getter:触发 computed 函数时,传入的第一个参数
    2. 匿名函数:当 this._dirty 为 false 时,会触发 triggerRefValue,我们知道 triggerRefValue 会 依次触发依赖 (_dirty 在这里以为 脏 的意思,可以理解为 《依赖的数据发生变化,计算属性就需要重新计算了》)
  2. 对于 ReactiveEffect 而言,我们之前是有了解过的,生成的实例,我们一般把它叫做 effect,他主要提供两个方法:
    1. run 方法:触发 fn,即传入的第一个参数
    2. stop 方法:语义上为停止的意思,我这里目前还没有实现

至此,我们已经执行完了 computed 函数,我们来总结一下做了什么:

  • 定义变量 getter 为我们传入的回调函数
  • 生成了 ComputedRefImpl 实例,作为 computed 函数的返回值
  • ComputedRefImpl 内部,利用了 ReactiveEffect 函数,并且传入了 第二个参数
  1. 当 computed 代码执行完成之后,我们在 effect 中触发了 computed 的 getter:
computedObj.value

根据我们之前在学习 ref 的时候可知,.value 属性的调用本质上是一个 get value 的函数调用,而 computedObj 作为 computed 的返回值,本质上是 ComputedRefImpl 的实例, 所以此时会触发 ComputedRefImpl 下的 get value 函数。

image.png

  1. 在 get value 中,做了两件事:

    1. 做了trackRefVale 依赖收集。
    2. 执行了之前存在 computed 中的函数 () => return '姓名' + obj.name,并返回了结果
  2. 这里可以提一下第 59 行中的判断条件,_dirty 初始化是 ture(_cacheable 初始化 false),所以会执行这个 if, 在 if 中将 _dirty 改为了 false,也就是说只要不改这个 _dirty,下次再去获取 computedObj.value 值时,不会重新执行 fn 。

  3. effect 函数执行完成,页面显示 姓名:张三,延迟两秒之后,会触发 obj.name 即 reactive 的 setter 行为,所以我们可以在 packages/reactivity/src/baseHandlers.ts 中为 set 增加一个断点:

image.png

  1. 可以发现因为之前 oldValue 是张三 ,现在 value 是李四,hasChange 方法为 true,进入到 trigger 方法

image.png

  1. 同样跳过之前相同逻辑,可知,最后会触发:triggerEffects(deps[0], eventInfo) 方法。进入 triggerEffects 方法:

image.png

  1. 这里要注意:因为我们在 ComputedRefImpl 的构造函数中,执行了 this.effect.computed = this,所以此时的 if (effect.computed) 判断将会为 true。此时我们注意看 effects,此时 effect 的值为 ReactiveEffect 的实例,同时 scheduler 存在值;
  2. 接下来进入 triggerEffect:

image.png

  1. 不知道大家还有没有印象,在 ComputedRefImpl 的构造函数创建 ReactiveEffect 实例时传进去的第二个参数,那个参数就是这里 scheduler。

image.png

  1. 我们进入 scheduler 回调:

image.png

  1. 此时的 _dirty 是 false,所以会执行 triggerRefValue 函数,我们进入 triggerRefValue:

image.png

  1. triggerRefValue 会再次触发 triggerEffects 依赖触发函数,把当前的 this.dep 作为参数传入。注意此时的 effect 是没有 computed 和 scheduler 属性的。

image.png

  1. fn 函数的触发,标记着 computedObj.value 触发,而我们知道 computedObj.value 本质上是 get value 函数的触发,所以代码接下来会触发 ComputedRefImpl 的 get value

image.png

  1. 获取到 computedObj.value 后 通过 ocument.querySelector('#app').innerHTML = computedObj.value 修改视图。

  2. 至此,整个过程结束。

梳理一下修改 obj.name 到修改视图的过程:

  1. 整个事件有 obj.name 开始

  2. 触发 proxy 实例的 setter

  3. 执行 trigger,第一次触发依赖

  4. 注意,此时 effect 包含 scheduler 调度器属性,所以会触发调度器

  5. 调度器指向 ComputedRefImpl 的构造函数中传入的匿名函数

  6. 在匿名函数中会:再次触发依赖

  7. 即:两次触发依赖

  8. 最后执行 :

() => {
  return '姓名:' + obj.name
}

得到值作为 computedObj 的值

总结:

到这里我们基本上了解了 computed 的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler ,并且整体的 computed 的流程也相当复杂。

对于 computed 而言,整体比较复杂,所以我们将分步进行实现

3. 构建 ComputedRefImpl ,读取计算属性的值

我们的首先的目标是:构建 ComputedRefImpl 类,创建出 computed 方法,并且能够读取值

  1. 创建 packages/reactivity/src/computed.ts :
import { isFunction } from '@vue/shared'
import { Dep } from './dep'
import { ReactiveEffect } from './effect'
import { trackRefValue } from './ref'

/**
 * 计算属性类
 */
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined
  private _value!: T

  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true

  constructor(getter) {
    this.effect = new ReactiveEffect(getter)
    this.effect.computed = this
  }

  get value() {
    // 触发依赖
    trackRefValue(this)
    // 执行 run 函数
    this._value = this.effect.run()!
    // 返回计算之后的真实值
    return this._value
  }
}

/**
 * 计算属性
 */
export function computed(getterOrOptions) {
  let getter

  // 判断传入的参数是否为一个函数
  const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    // 如果是函数,则赋值给 getter
    getter = getterOrOptions
  }

  const cRef = new ComputedRefImpl(getter)

  return cRef as any
}
  1. 在 packages/shared/src/index.ts 中,创建工具方法:
/**
 * 是否为一个 function
 */
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'
  1. 在 packages/reactivity/src/effect.ts 中,为 ReactiveEffect 增加 computed 属性:
  /**
   * 存在该属性,则表示当前的 effect 为计算属性的 effect
   */
  computed?: ComputedRefImpl<T>
  1. 在 packages/reactivity/src/index.ts 和 packages/vue/src/index.ts 导出

  2. 创建测试实例:packages/vue/examples/reactivity/computed.html:

  <body>
    <div id="app"></div>
  </body>
  <script>
    const { reactive, computed, effect } = Vue

    const obj = reactive({
      name: '张三'
    })

    const computedObj = computed(() => {
      return '姓名:' + obj.name
    })

    effect(() => {
      document.querySelector('#app').innerHTML = computedObj.value
    })

    setTimeout(() => {
      obj.name = '李四'
    }, 2000)
  </script>

此时,我们可以发现,计算属性,可以正常展示。

但是: 当 obj.name 发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。

4. 初见调度器,处理脏的状态

如果我们想要实现 响应性,那么必须具备两个条件:

  1. 收集依赖:该操作我们目前已经在 get value 中进行。
  2. 触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。

代码实现:

  1. 在 packages/reactivity/src/computed.ts 中,处理脏状态和 scheduler:
export class ComputedRefImpl<T> {
  ...

  /**
   * 脏:为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。即:数据脏了
   */
  public _dirty = true

  constructor(getter) {
    this.effect = new ReactiveEffect(getter, () => {
      // 判断当前脏的状态,如果为 false,表示需要《触发依赖》
      if (!this._dirty) {
        // 将脏置为 true,表示
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }

  get value() {
    // 触发依赖
    trackRefValue(this)
    // 判断当前脏的状态,如果为 true ,则表示需要重新执行 run,获取最新数据
    if (this._dirty) {
      this._dirty = false
      // 执行 run 函数
      this._value = this.effect.run()!
    }

    // 返回计算之后的真实值
    return this._value
  }
}

  1. 在 packages/reactivity/src/effect.ts 中,添加 scheduler 的处理:
export type EffectScheduler = (...args: any[]) => any
   
   
/**
 * 响应性触发依赖时的执行类
 */
export class ReactiveEffect<T = any> {
  /**
   * 存在该属性,则表示当前的 effect 为计算属性的 effect
   */
  computed?: ComputedRefImpl<T>

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null
  ) {}
  ...
}

  1. 最后不要忘记,触发调度器函数
/**
 * 触发指定的依赖
 */
export function triggerEffect(effect: ReactiveEffect) {
  // 存在调度器就执行调度函数
  if (effect.scheduler) {
    effect.scheduler()
  }
  // 否则直接执行 run 函数即可
  else {
    effect.run()
  }
}

此时,重新执行测试实例,则发现 computed 已经具备响应性。

5. computed 的 缓存问题 和 死循环问题

到目前为止,我们的 computed 其实已经具备了响应性,但是还存在一点问题。我们来看下下面的代码

5.1 存在的问题

我们来看下面的代码:

<body>
  <div id="app"></div>
</body>
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    console.log('计算属性执行计算')
    return '姓名:' + obj.name
  })

  effect(() => {
    document.querySelector('#app').innerHTML = computedObj.value
    document.querySelector('#app').innerHTML = computedObj.value
  })
  setTimeout(() => {
    computedObj.value = '李四'
  }, 2000)
</script>

结果报错了:

image.png 调用了两次 computedObj.value 按理说 computed 只会执行一次才对,但是却提示 超出最大调用堆栈大小。

5.2 为什么会出现死循环

我们继续从源码中找问题,我们接着从两秒之后的 obj.name = '李四' 开始调试。

  1. 修改 obj.name = '李四',此时会进行 obj 的依赖处理 trigger 函数中

image.png

  1. 代码继续向下进行,进入 triggerEffects(dep) 方法

  2. 在 triggerEffects(dep) 方法中,继续进入 triggerEffect(effect)

  3. 在 triggerEffect 中接收到的 effect,即为刚才查看的 计算属性的 effect

  4. 此时因为 effect 中存在 scheduler,所以会执行该计算属性的 scheduler 函数

image.png

  1. 在 scheduler 函数中,会触发 triggerRefValue(this)

image.png

  1. 而 triggerRefValue 则会再次触发 triggerEffects。 image.png

  2. 特别注意: 此时 effects 的值为 计算属性实例的 dep:

image.png

  1. 循环 effects,从而再次进入 triggerEffect 中。

  2. 再次进入 triggerEffect,此时 effect 为 非计算属性的 effect,即 fn 函数(修改 DOM 的函数)

  3. 因为他 不是 计算属性的 effect ,所以会直接执行 run 方法。

  4. 而我们知道 run 方法中,其实就是触发了 fn 函数,所以最终会执行:

document.querySelector('#app').innerHTML = computedObj.value
document.querySelector('#app').innerHTML = computedObj.value
  1. 但是在这个 fn 函数中,是有触发 computedObj.value 的,而 computedObj.value 其实是触发了 computed 的 get value 方法。

  2. 那么这次 run 的执行会触发 两次 computed 的 get value

  • 第一次进入:
    • 进入 computed 的 get value :
    • 首先收集依赖
    • 接下来检查 dirty 脏的状态,执行 this.effect.run()!
    • 获取最新值,返回
  • 第二次进入:
    • 进入 computed 的 get value :
    • 首先收集依赖
    • 接下来检查 dirty 脏的状态,因为在上一次中 dirty 已经为 false,所以本次 不会在触发 this.effect.run()!
    • 直接返回结束
  1. 按说代码应该到这里就结束了,但是不要忘记,在刚才我们进入到 triggerEffects 时,effets 是一个数组,内部还存在一个 computed 的 effect,所以代码会 继续 执行,再次来到 triggerEffect 中:
  • 此时 effect 为 computed 的 effect:

image.png

这会导致,再次触发 scheduler,scheduler 中还会再次触发 triggerRefValue,triggerRefValue 又触发 triggerEffects ,再次生成一个新的 effects 包含两个 effect,就像 第五、第六、第七步 一样 从而导致 死循环

5.3 解决方法

想要解决这个死循环的问题,其实比较简单,我们只需要 packages/reactivity/src/effect.ts 中的 triggerEffects 中修改如下代码:

export function triggerEffects(dep: Dep) {
  // 把 dep 构建为一个数组
  const effects = isArray(dep) ? dep : [...dep]
  // 依次触发
  // for (const effect of effects) {
  // 	triggerEffect(effect)
  // }

  // 不在依次触发,而是先触发所有的计算属性依赖,再触发所有的非计算属性依赖
  for (const effect of effects) {
    if (effect.computed) {
      triggerEffect(effect)
    }
  }
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect)
    }
  }
}

查看测试实例的打印,此时 computed 只计算了一次。

5.4 解决方法的原理

原理就是将具有 computed 属性的 effect 放在前面,先执行有 computed 属性的 effect,再执行没有 computed 属性的 effect

第一个执行的有 computed 属性的 effect: image.png

第二个执行的没有 computed 属性的 effect:

image.png

6. 总结

计算属性实现的重点:

  1. 计算属性的实例,本质上是一个 ComputedRefImpl 的实例
  2. ComputedRefImpl 中通过 dirty 变量来控制 run 的执行和 triggerRefValue 的触发
  3. 想要访问计算属性的值,必须通过 .value ,因为它内部和 ref 一样是通过 get value 来进行实现的
  4. 每次 .value 时都会触发 trackRefValue 即:收集依赖
  5. 在依赖触发时,需要谨记,先触发 computed 的 effect,再触发非 computed 的 effect