
本文使用的
vue版本为3.2.26。在阅读computed源码之前,我们需要知道它的特性,可以通过阅读单例测试源码或者是阅读官网的API了解特性,推荐阅读单例,了解特性在后面阅读时才能更好理解。
在vue3中可以使用用户自定义的getter方法创建一个计算对象,计算对象通过.value来获取计算值。计算对象分为两种分别是computed和deferredComputed函数创建的。通过文档和单例可以知道computed和deferredComputed有以下特性:
computed的getter函数是懒加载的,在获取value时才会调用getter函数。computed会缓存上一次的value,当重复获取时,直接返回缓存值,只有依赖数据发生变化才会重新执行。computed返回的对象中有effect属性,可以调用stop(computed.effect)方法可以停止computed的监听。computed可以传入setter函数,当对computed.value更改时会调用这个函数effect监听函数中使用deferredComputed对象时,deferredComputed对象的value发生变化时,不会立即触发effect监听函数,而是在下一次微任务 (Promise.then) 触发.deferredComputed对象的value时,会直接拿到最新值,不会等待下一次微任务. 在vue3中computed也是属于一种ref类型,当使用isRef函数执行时会返回true。通过上一章vue3-ref源码解析我们知道,在ref类型能响应式的关键就是存储自身的dep,在获取时调用trackRefValue函数,在更改时调用triggerRefValue函数。
而只读版本的computed是不会直接通过value属性来更改的,它是通过传入的getter函数里面的依赖发生更改时重新执行的getter函数来实现的。更改依赖就重新执行,这个是不是很熟悉,没错他就是effect的特性,不了解的同学可以通过vue3-effect源码解析看看。也就是说当依赖数据发生更改而引起effect重新执行监听函数时,我们就需要实现懒加载以及triggerRefValue函数的调用。
下面我们一起来看看vue中是如何实现的,
首先我们看看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函数或者是包含get和set属性的对象,当只有getter时会给一个默认的setter。然后根据getter、setter和是否有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重新执行teffect在effectStack栈内,重新执行将什么都不干, teffect依赖函数执行完毕,teffect出栈reuser.name = '123',reuser.name的修改会触发welcome调度器的重新执行,因为是脏数据所以什么都不干执行完毕 大家看到, 当effect收集函数内先依赖computed,并修改computed依赖的数据时,在effect外修改computed可能会导致effect无法正常响应。 这个不知道是bug还是处于什么考虑。
deferredComputed是在effect中使用时有异步的特性,当effect收集到deferredComputed依赖,deferredComputed的value发生变化并不会马上触发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,通知依赖了当前computed的effect重新执行。
valueToCompare是在执行微任务前缓存下来的,当不存在dcEffect依赖deferredComputed时是没问题的,因为会缓存_value,即使用户主动获取了computed.value改变了this._value,在下一次微任务比对的也是缓存的_value。
当存在dcEffect依赖deferredComputed时就会出问题,比如存在dc1和dc2,dc2的值依赖dc1,而dc1只有一下次微任务才会执行triggerRefValue通知dc2调度器。所以dc2在下一次微任务时才会获取valueToCompare来比对决定是否执行triggerRefValue。假如用户通过dc2.value来强行刷新值的话,_value就会存储最新的值,在下一次微任务时拿到的valueToCompare会与dc2.value一样,就会导致dc2无法执行triggerRefValue,依赖了dc2的effect无法正常执行,比如下方代码:
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源码解析
下一章:vue3-effectScope源码解析
©2021 - bill-lai 的小站 -站点源码