从 0 搭建一个 mini-vue 项目(四):computed 的响应性


theme: juejin
highlight: routeros

前言

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

  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,并且触发了 objgetter
  3. 通过 effect 方法创建 fn 函数
  4. fn 函数中,触发了 computedgetter
  5. 延迟触发了 objsetter

接下来我们将从源码中研究 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._dirtyfalse 时,会触发 triggerRefValue,我们知道 triggerRefValue依次触发依赖 (_dirty 在这里以为 的意思,可以理解为 《依赖的数据发生变化,计算属性就需要重新计算了》)
  2. 对于 ReactiveEffect 而言,我们之前是有了解过的,生成的实例,我们一般把它叫做 effect,他主要提供两个方法:
    1. run 方法:触发 fn,即传入的第一个参数
    2. stop 方法:语义上为停止的意思,我这里目前还没有实现

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

  • 定义变量 getter 为我们传入的回调函数
  • 生成了 ComputedRefImpl 实例,作为 computed 函数的返回值
  • ComputedRefImpl 内部,利用了 ReactiveEffect 函数,并且传入了 第二个参数
  1. computed 代码执行完成之后,我们在 effect 中触发了 computedgetter
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.namereactivesetter 行为,所以我们可以在 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. 此时的 _dirtyfalse,所以会执行 triggerRefValue 函数,我们进入 triggerRefValue

image.png

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

image.png

  1. fn 函数的触发,标记着 computedObj.value 触发,而我们知道 computedObj.value 本质上是 get value 函数的触发,所以代码接下来会触发 ComputedRefImplget 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.tspackages/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 其实是触发了 computedget value 方法。

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

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

image.png

这会导致,再次触发 schedulerscheduler 中还会再次触发 triggerRefValuetriggerRefValue 又触发 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. 在依赖触发时,需要谨记,先触发 computedeffect,再触发非 computedeffect
© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容