Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vue3-computed源码解析 #22

Open
bill-lai opened this issue Mar 3, 2022 · 0 comments
Open

vue3-computed源码解析 #22

bill-lai opened this issue Mar 3, 2022 · 0 comments

Comments

@bill-lai
Copy link
Owner

bill-lai commented Mar 3, 2022

阅读准备

本文使用的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类的具体实现:

// 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的调度器逻辑相对比较复杂,我们从拆开讲解,下面我们看看删减源码:

// 异步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函数中的具体实现细节:

// 微任务
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)
})

  其实解决方案也很简单,只需要在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源码解析

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant