Vue3响应式对象-计算属性和异步计算属性
一、前言
在之前的博客中,已经针对ref对象和reactive对象做了详细的分析,这2种对象覆盖了常规类型以及对象类型的响应式设计。计算属性就是在这2种对象的基础上,实现的最后一种响应式对象。
计算属性分为2类,计算属性和异步计算属性。其中计算属性是最为常用的,异步计算属性在官方文档上没有说明和文档,其在计算属性的基础上更进一步进行了优化,有必要进行了解。
二、计算属性的意义
在ref对象和reactive对象可以基本实现所有类型数据的响应式后,计算属性存在的目的在于优化。
当一些复杂的数据结构需要多个响应式对象来共同实现时,我们可以选择使用方法或者计算属性。如果使用方法,这些方法在render渲染函数重新执行时,都会被重新执行,即便这些方法没有使用到变更的依赖。如果在这种情况下使用计算属性,则会因为其内部的缓存设计不会重新计算,直接获取缓存值。
三、计算属性
1.核心代码解析
创建计算属性
export function computed<T>( getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>, debugOptions?: DebuggerOptions, isSSR = false ) { let getter: ComputedGetter<T> let setter: ComputedSetter<T> // 如果传入的是一个方法,则为只读 const onlyGetter = isFunction(getterOrOptions) // 分别设置getter方法和setter方法 if (onlyGetter) { getter = getterOrOptions // 不存在setter也会给一个setter setter = __DEV__ ? () => { console.warn('Write operation failed: computed value is readonly') } : NOOP } else { getter = getterOrOptions.get setter = getterOrOptions.set } // 创建计算属性,默认非服务端渲染 const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR) // 开发环境相关的调试配置 if (__DEV__ && debugOptions && !isSSR) { cRef.effect.onTrack = debugOptions.onTrack cRef.effect.onTrigger = debugOptions.onTrigger } return cRef as any } 复制代码
从主流程上看代码相对简单,外部可以传入一个getter方法或者一个包含getter方法和setter方法的配置。然后根据是否存在setter进行创建计算属性,不存在setter则为只读计算属性。
计算属性设计
export class ComputedRefImpl<T> { // 依赖 public dep?: Dep = undefined // 内存缓存值 private _value!: T public readonly effect: ReactiveEffect<T> // ref对象标识,计算属性也是一种通过其他响应式对象来实现的ref对象 public readonly __v_isRef = true public readonly [ReactiveFlags.IS_READONLY]: boolean = false // 是否脏数据 public _dirty = true public _cacheable: boolean constructor( getter: ComputedGetter<T>, private readonly _setter: ComputedSetter<T>, isReadonly: boolean, isSSR: boolean ) { this.effect = new ReactiveEffect(getter, () => { // 当这个计算属性的依赖变更时,这个匿名方法被执行 if (!this._dirty) { // 将数据标识为脏数据,下一次读取时重新计算 this._dirty = true // 触发依赖更新,虽然计算属性在依赖数据变更时不主动计算, // 但需要触发依赖更新,因为这个计算数据是脏数据,本质上是变更了的 triggerRefValue(this) } }) // 标识当前effect对象是用作计算属性,且指向自己 this.effect.computed = this // 服务端渲染暂且不考虑,默认true this.effect.active = this._cacheable = !isSSR this[ReactiveFlags.IS_READONLY] = isReadonly } get value() { // 获取原始对象,计算属性可以被其他代理对象包装 const self = toRaw(this) // 收集依赖 trackRefValue(self) // 如果是脏数据或者不缓存,则重新计算 if (self._dirty || !self._cacheable) { self._dirty = false self._value = self.effect.run()! } return self._value } // 调用setter函数 set value(newValue: T) { this._setter(newValue) } } 复制代码
核心代码的注释都已标注,下面分模块简单分析。
构造函数
构造函数就是创建了一个ReactiveEffect对象,所有的响应式都是通过这个对象来实现的,不清楚的可以先看一下响应式设计原理简要了解,下一篇章详解。然后设计了一些配置相关的属性。
读
这是计算属性的重点,核心逻辑是判断当前缓存数据是否脏数据,是脏数据就重新计算。其中ReactiveEffect对象的run方法会调用getter方法进行计算。
写
直接调用setter方法,在创建计算属性时,如果未传入setter也会默认生成一个setter方法,详见创建代码。
依赖变更时
ReactiveEffect对象在创建时的入参中,第一个参数是getter方法,这个方法会进行依赖收集和数据计算。第二个方法则是依赖变更时的回调方法,这个方法将数据标识为脏数据,表示下次读取时需要重新计算,并且触发依赖更新。
2. 响应式流程图
需要注意的是,计算属性的依赖数据变更时,并没有立刻重新计算新值,而是置为脏数据,待下次读取时重新计算,正因为这个特性,异步计算属性才进行了更一层优化。
四、异步计算属性
1.计算属性的缺陷
虽然计算属性是常用属性,且并没有什么副作用,但它其实存在一个性能上的缺陷。在上面流程图中,响应式对象B每变更一次,就会执行一次相应的依赖更新流程,这往往是不合理的,因为在同步执行的情况下,只有最后一次变更会生效。示例如下:
let data = ref(1) let computedData = computed(() => { return data.value+1 }) let calls = 0 let rec = 0 effect(() => { rec = computedData.value calls++ }) expect(calls).toBe(1) data.value=2 data.value=3 data.value=4 expect(calls).toBe(4) expect(rec).tobe(5) 复制代码
可以看出,rec变量的值,取决于最终的那一次计算出的值。但整个计算却是进行4次,这表示有3次都是无效计算。当存在很多复杂逻辑时,这些无效计算无疑会极大的增加性能消耗。异步计算属性就是为了解决这种情况。
2.异步计算属性核心代码
// 异步计算属性的异步执行队列相关代码 const tick = /*#__PURE__*/ 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 } // 计算属性的实现 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 compareTarget: any let hasCompareTarget = false let scheduled = false // 是否已经确定异步执行 this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => { // 如果存在依赖 if (this.dep) { // 是否是由异步计算属性触发的更新 if (computedTrigger) { // 当前数据设置为对比数据 compareTarget = this._value hasCompareTarget = true } else if (!scheduled) { // 获取对比数据 const valueToCompare = hasCompareTarget ? compareTarget : this._value scheduled = true hasCompareTarget = false // 放入异步队列等待执行 scheduler(() => { // 获取最新数据与对比数据进行比较,变更了则触发更新 if (this.effect.active && this._get() !== valueToCompare) { triggerRefValue(this) } scheduled = false }) } for (const e of this.dep) { // 如果依赖中存在异步计算属性,则主动触发回调执行 if (e.computed instanceof DeferredComputedRefImpl) { e.scheduler!(true /* computedTrigger */) } } } // 置为脏数据 this._dirty = true }) this.effect.computed = this as any } private _get() { // 脏数据重新计算 if (this._dirty) { this._dirty = false return (this._value = this.effect.run()!) } return this._value } get value() { // 收集依赖 trackRefValue(this) // the computed ref may get wrapped by other proxies e.g. readonly() #3376 return toRaw(this)._get() } } 复制代码
核心注释已经标注。和计算属性相比,主要的差异点如下:
只读,不可写
依赖变更的回调方法逻辑更加复杂
我们主要分析下这个回调方法。这个方法的意图只有1个,保存当前的数据作为比对数据A,然后异步执行数据比较,如果比对数据A和最新数据不一致,则触发依赖更新。
其中存在的判断逻辑和主动触发异步计算属性的回调方法是为了处理异步计算属性的依赖关系。相关代码如下:
if (computedTrigger) { // 当前数据设置为对比数据 compareTarget = this._value hasCompareTarget = true } for (const e of this.dep) { // 如果依赖中存在异步计算属性,则主动触发回调执行 if (e.computed instanceof DeferredComputedRefImpl) { e.scheduler!(true /* computedTrigger */) } } 复制代码
下面使用如下示例代码结合图解分析:
let data = ref(1) let computedData = computed(() => { return data.value + 1 }) let deferComputed1 = deferredComputed(() => { return computedData.value + 1 }) let deferComputed2 = deferredComputed(() => { return deferComputed1.value + 1 }) let calls = 0 effect(() => { deferComputed2.value calls++ }) // T0时刻 expect(calls).toBe(1) data.value++ // T1时刻 expect(calls).toBe(1) await Promise.resolve() // T2时刻 expect(calls).toBe(2) 复制代码
示例代码中,一共有4个响应式对象。时刻图如下:
T0时刻
在T0时刻,由Dep依赖回调读取deferComputed2为起点,构建了相关的依赖关系。
T1时刻
在T1时刻,data数据变更,由此触发依赖更新。此时computedData置为脏数据,继续触发依赖更新。deferComputed1变更为脏数据,保存当前数据为比对数据,将数据比对操作放入异步队列,接着遍历其依赖列表,触发其中的异步计算属性的回调。deferComputed2置为脏数据,保存当前值作为比对值,由于是由异步计算属性触发,因此不再将比对操作放入异步队列,它的依赖列表中没有异步计算属性,因此不在继续触发依赖更新,因此calls等于1。
T2时刻
执行数据比对,deferComputed1获取最新数据,由于是脏数据,需要重新计算,读取computedData,此时computedData是脏数据,也要计算最新值。deferComputed1发现前后数据变更,由此触发依赖更新,deferComputed2开始比对数据。deferComputed2由于是脏数据,也要重新计算,数据依旧变更,继续触发依赖更新,Dep依赖执行回调,calls等于2。
上面着重分析了各个时刻的执行流程,其中还有个细节是很有意思的。异步队列是采用的微任务队列,当一个微任务在执行中又添加了另一个微任务,则这个后添加的微任务也会在本轮待执行的微任务中,而不是放到下一次执行微任务队列时执行。在T2时刻,deferComputed1触发的异步队列执行是执行的微任务,此时触发依赖更新会导致deferComputed2添加比对操作到异步队列中。这就符合一个微任务添加了另一个微任务,这2个微任务会同步执行,而不是异步执行。
五、总结
关于响应式对象的介绍到此结束,下面会继续分析响应式系统的核心ReactiveEffect对象。
作者:sunsetFeng
链接:https://juejin.cn/post/7168755759386198047