vue3-computed源码解析

1,035 阅读11分钟

阅读准备

本文使用的vue版本为3.2.26。在阅读 computed 源码之前,我们需要知道它的特性,可以通过阅读单例测试源码或者是阅读官网的 API了解特性,推荐阅读单例,了解特性在后面阅读时才能更好理解。

  在vue3中可以使用用户自定义的getter方法创建一个计算对象,计算对象通过.value来获取计算值。计算对象分为两种分别是computeddeferredComputed函数创建的。通过文档和单例可以知道computeddeferredComputed有以下特性:

  • computedgetter函数是懒加载的,在获取value时才会调用getter函数。
  • computed会缓存上一次的value,当重复获取时,直接返回缓存值,只有依赖数据发生变化才会重新执行。
  • computed返回的对象中有effect属性,可以调用stop(computed.effect)方法可以停止computed的监听。
  • computed可以传入setter函数,当对computed.value更改时会调用这个函数
  • effect监听函数中使用deferredComputed对象时,deferredComputed对象的value发生变化时,不会立即触发effect监听函数,而是在下一次微任务 (Promise.then) 触发.
  • 当直接获取deferredComputed对象的value时,会直接拿到最新值,不会等待下一次微任务.

  在vue3computed也是属于一种ref类型,当使用isRef函数执行时会返回true。通过上一章vue3-ref源码解析我们知道,在ref类型能响应式的关键就是存储自身的dep,在获取时调用trackRefValue函数,在更改时调用triggerRefValue函数。

  而只读版本的computed是不会直接通过value属性来更改的,它是通过传入的getter函数里面的依赖发生更改时重新执行的getter函数来实现的。更改依赖就重新执行,这个是不是很熟悉,没错他就是effect的特性,不了解的同学可以通过vue3-effect源码解析看看。也就是说当依赖数据发生更改而引起effect重新执行监听函数时,我们就需要实现懒加载以及triggerRefValue函数的调用。

  下面我们一起来看看vue中是如何实现的,

computed

  首先我们看看computed函数的实现:

export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'

// 创建计算属性ref
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>

  // 是否只有getter
  const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    getter = getterOrOptions
    // 如果只有getter,在开发环境下吧setter换成警告
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  // 传入 getter、setter、是否有setter 创建computed对象
  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter)

  // 如果是开发环境在effect对象注入传入的收集和触发钩子
  if (__DEV__ && debugOptions) {
    cRef.effect.onTrack = debugOptions.onTrack
    cRef.effect.onTrigger = debugOptions.onTrigger
  }

  return cRef as any
}

  computed的第一个参数是一个getter函数或者是包含getset属性的对象,当只有getter时会给一个默认的setter。然后根据gettersetter和是否有setter创建ComputedRefImpl对象,并将用户传入的测试参数收集钩子和触发钩子附加到computed对象的effect属性上。

ComputedRefImpl

  入口函数还是比较简单的,简单的一些判断和附加,我们接下来看看ComputedRefImpl类的具体实现:

// Computed对象
class ComputedRefImpl<T> {
  // 引用了当前computed的effect的Set
  public dep?: Dep = undefined

  // 放置缓存值
  private _value!: T
  // 当前值是否是脏数据,(当前值需要更新)
  private _dirty = true
  // 放置effect对象
  public readonly effect: ReactiveEffect<T>

  // ref标识
  public readonly __v_isRef = true
  // isReadonly标识
  public readonly [ReactiveFlags.IS_READONLY]: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean
  ) {
    // 创建effect对象,将当前getter当做监听函数,并附加调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 如果当前不是脏数据
      if (!this._dirty) {
        // 当前为脏数据
        this._dirty = true
        // 触发更改
        triggerRefValue(this)
      }
    })

    // 根据传入是否有setter函数来决定是否只读
    this[ReactiveFlags.IS_READONLY] = isReadonly
  }

  get value() {
    // readonly(computed),获取时this就是readonly,无法修改属性, 所以要先获取原始对象
    const self = toRaw(this)
    // 收集依赖
    trackRefValue(self)
    // 如果当前是脏数据(没更新)
    if (self._dirty) {
      // 更改为不是脏数据
      self._dirty = false
      // 执行收集函数,更新缓存
      self._value = self.effect.run()!
    }
    // 如果不是脏数据则直接获取缓存值
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

  ComputedRefImpl对象是通过_value_dirty属性来实现懒加载的。_value放置缓存的value_dirty标识当前是否是脏数据 (需要更新)。当用户获取computed.value时,如果不是脏数据则直接返回缓存的value,如果是脏数据则调用getter来获取最新的value缓存起来,并更改为不是脏数据。

  构建ComputedRefImpl对象时会创建一个以getter为监听函数的effect对象,并注入调度器,下面我们简称为ceffect。当ceffect的依赖数据更改触发需要重新收集时,并不会马上执行收集函数,而是执行computed的调度器 vue-effect源码解析讲过)

  当computed的调度器被执行时,说明getter里面的依赖数据发生更改,此时computed.value也可能更改,而computed又是懒加载的,我们不能直接执行依赖函数查看是否真正修改了,这样会失去懒加载特性,所以我们就认为它被修改了。

  也就是说调度器被执行了就是更改了computed,这时候需要更改为脏数据并且执行triggerRefValue函数。

  get value就比较简单了,判断是否是脏数据,如果是则获更改脏数据状态,执行收集并获取返回值做为最新的value,并返回。

注意

  我们看到调度器还有一条判断,如果当前已经是脏数据了,则不会重新更改和调用triggerRefValue,大部分情况如果是脏数据说明已经triggerRefValue过了,当前还未获取过computed.value所以不需要再次triggerRefValue是合理的。但是有些情况会执行不正确,大家看看这段代码:

const reuser = reactive({ name: 'bill' })
const welcome = computed(() => 'hello ' + reuser.name)

// teffect
effect(() => {
  console.log(welcome.value)
  reuser.name = 'lzb'
})

reuser.name = '123'

// hello bill

  实际上只会打印一次hello bill,让我们来理一理发生了什么

  • 入栈teffect对象,执行teffect依赖函数
  • 执行时遇到welcome.value,执行ceffect.run(),入栈ceffect,并收集到reuser.name依赖,ceffect出栈
  • 执行到reuser.name = 'lzb'reuser.name的修改会触发welcome调度器的重新执行,将修改为脏数据并触发关联的teffect重新执行
  • teffecteffectStack栈内,重新执行将什么都不干, teffect依赖函数执行完毕,teffect出栈
  • 执行reuser.name = '123'reuser.name的修改会触发welcome调度器的重新执行,因为是脏数据所以什么都不干执行完毕

  大家看到, effect收集函数内先依赖computed,并修改computed依赖的数据时,在effect外修改computed可能会导致effect无法正常响应。 这个不知道是bug还是处于什么考虑。

deferredComputed

  deferredComputed是在effect中使用时有异步的特性,当effect收集到deferredComputed依赖,deferredComputedvalue发生变化并不会马上触发effect收集函数,而是等到下一次微任务执行。当直接获取deferredComputed.value时是同步执行的,会马上获取到最新值。

  deferredComputed也有懒加载的特性,也就是说也是根据自定义effect调度器实现的。当数据改变执行triggerRefValue来触发其他依赖了自身的effect收集函数的重新执行。也就是说当deferredComputed数据发生改变在下一次微任务执行triggerRefValue即可实现在effect中异步的特性。

  由于deferredComputed的调度器逻辑相对比较复杂,我们从拆开讲解

DeferredComputedRefImpl简易版

  下面我们看看删减源码:

// 异步computed类
class DeferredComputedRefImpl<T> {
  public dep?: Dep = undefined

  private _value!: T
  private _dirty = true
  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true
  public readonly [ReactiveFlags.IS_READONLY] = true

  constructor(getter: ComputedGetter<T>) {
    let scheduled = false

    this.effect = new ReactiveEffect(getter, () => {
      // 被effect 引用有dep才需要延迟
      if (this.dep) {
        if (!scheduled) {
          // 获取比对值,决定是否执行 triggerRefValue
          const valueToCompare = this._value
          // 标识正在等待
          scheduled = true

          // 下一次微任务执行
          scheduler(() => {
            // 如果当前computed没有停用
            // 并且主动获取值,查看比对值是否一直
            if (this.effect.active && this._get() !== valueToCompare) {
              triggerRefValue(this)
            }
            // 没有再等待冲刷
            scheduled = false
          })
        }
      }
      this._dirty = true
    })
  }

  private _get() {
    if (this._dirty) {
      this._dirty = false
      // 执行effect获取返回值
      return (this._value = this.effect.run()!)
    }
    return this._value
  }

  // 获取值,主动获取一定能刷新值
  get value() {
    trackRefValue(this)
    return toRaw(this)._get()
  }
}

export function deferredComputed<T>(getter: () => T): ComputedRef<T> {
  return new DeferredComputedRefImpl(getter) as any
}

  deferredComputed大部分属性都跟computed差不多,不同的是调度器的定义,在获取value时将trackRefValue与值处理分离开来。

  首先我们看到调度器的处理,如果当前deferredComputed没有收集到依赖,也就是没有在effect中使用,那么就直接修改为脏数据即可,因为异步特性是针对effect的监听函数的。除此之外deferredComputed还会防抖,一次微任务中多次调度只会执行一次,使用scheduled变量来完成这一特性,在进入前记录当前deferredComputed正在执行任务,执行完毕后会恢复状态,当多次进入时会判断如果正在执行任务则直接忽略。最后在下一次微任务后如果值被修改则triggerRefValue,触发当前deferredComputed值被修改使得被引用的effect被重新执行。

scheduler

  接下来我们看看scheduler函数中的具体实现细节:

// 微任务
const tick = Promise.resolve()
// 任务队列
const queue: any[] = []
// 正在执行任务
let queued = false

// 任务调度器
const scheduler = (fn: any) => {
  queue.push(fn)
  // 如果正在执行任务仅添加到任务中即可
  if (!queued) {
    // 如果没有执行任务标识正在执行,然后再下一次微任务中执行
    queued = true
    tick.then(flush)
  }
}
// 冲刷,执行任务
const flush = () => {
  // 获取所有任务,然后执行
  for (let i = 0; i < queue.length; i++) {
    queue[i]()
  }
  // 清空
  queue.length = 0
  queued = false
}

  scheduler中是使用promise.then来实现异步的,当任务进入时会存入到队列中。如果当前是队列首次执行,则在下一次微任务调用flush方法。flush中按顺序执行任务队列的所有方法,然后恢复状态。

  在任务执行期间,加入的任务始终会在下一次微任务一起执行,即使实在任务中加入任务,比如下方这段代码

scheduler(() => {
  console.log('1')
  scheduler(() => {
    console.log('2')
  })
})

  让我们回到deferredComputed,综合scheduler,也就是说,所有的更改会在一次微任务中按顺序执行。

  现在deferredComputed主要代码我们已经看完了,但是还有一些情况需要处理。我们看到上面DeferredComputedRefImpl源码实现中,只有当valueToCompare发生变化时才会调用triggerRefValue,通知依赖了当前computedeffect重新执行。

需解决问题

  valueToCompare是在执行微任务前缓存下来的,当不存在dcEffect依赖deferredComputed时是没问题的,因为会缓存_value,即使用户主动获取了computed.value改变了this._value,在下一次微任务比对的也是缓存的_value

  当存在dcEffect依赖deferredComputed时就会出问题,比如存在dc1dc2dc2的值依赖dc1,而dc1只有一下次微任务才会执行triggerRefValue通知dc2调度器。所以dc2在下一次微任务时才会获取valueToCompare来比对决定是否执行triggerRefValue。假如用户通过dc2.value来强行刷新值的话,_value就会存储最新的值,在下一次微任务时拿到的valueToCompare会与dc2.value一样,就会导致dc2无法执行triggerRefValue,依赖了dc2effect无法正常执行,比如下方代码:

const src = ref(0)
const c1 = deferredComputed(() => src.value % 2)
const c2 = deferredComputed(() => c1.value + 1)

let count = 0
effect(() => {
  count++
  return c2.value
})
// 1
console.log(count)

src.value = 1
// 刷新c2,c2的_value是最新值
c2.value

Promise.resolve().then(() => {
  // c2 拿到的valueToCompare与this._get()值一致,无法发送triggerRefValue
  // 1 
  console.log(count)
})

DeferredComputedRefImpl完整版

  其实解决方案也很简单,只需要在deferredComputed发生更改时,获取所有关联的dceffect,并让他们缓存当前_value(valueToCompare),确保能正确的比对,我们看看vue是如何处理的:

class DeferredComputedRefImpl<T> {
  ...
  constructor(getter: ComputedGetter<T>) {
    // 比较目标
+   let compareTarget: any
    // 是否需要比较目标比较
+   let hasCompareTarget = false
    let scheduled = false

    this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => {
      if (this.dep) {
        // 如果是deferredComputed引起的调度
+       if (computedTrigger) {
+         // 获取当前比对值,防止出现拿最新的不触发trigger的情感
+         // 如果不缓存值,当上一个缓冲区刷新之后,获取当前的都是最新值,则不会触发trigger
+         compareTarget = this._value
+         hasCompareTarget = true
        } else if (!scheduled) {
          // 获取比对之
+         const valueToCompare = hasCompareTarget ? compareTarget : this._value
          // 标识正在等待
          scheduled = true
          // 恢复hasCompareTarget
+         hasCompareTarget = false

          scheduler(() => {
            if (this.effect.active && this._get() !== valueToCompare) {
              triggerRefValue(this)
            }
            scheduled = false
          })
        }

        // 获取当前关联的deferredComputed依次触发调度器,并传入是computed触发标识
+       for (const e of this.dep) {
+         if (e.computed) {
+           e.scheduler!(true /* computedTrigger */)
+         }
+       }
+     }
      this._dirty = true
    })

    // 标识当前effect是deferredComputed
+   this.effect.computed = true
  }
  ...
}

  dcEffect会在附加computed属性设置为true,来标识当前effect属于deferredComputed。当deferredComputed值被修改引起调度器重新执行时,会获取所有关联的dcEffect,然后逐一执行他们的调度器,并注明执行是来源于deferredComputed

  如果调度器是deferredComputed执行的,那么就需要缓存当前_value,确保能正确对比。vue中使用hasCompareTarget变量来标识是需要使用缓存值来比对还是直接使用_value来比对。当前deferredComputed又可能被其他deferredComputed依赖,也需要对被关联deferredComputed通知缓存value。这样就能处理这种情况了。

  到这里computed的具体实现了就已经看完了,完结撒花。

上一章:vue3-ref源码解析

下一章:vue3-effectScope源码解析