阅读准备
本文使用的
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
首先我们看看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
入口函数还是比较简单的,简单的一些判断和附加,我们接下来看看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
deferredComputed
是在effect
中使用时有异步的特性,当effect
收集到deferredComputed
依赖,deferredComputed
的value
发生变化并不会马上触发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
,通知依赖了当前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)
})
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源码解析