结合前面实现的功能,在本章,我们可以实现vue.js中一个非常重要且特色的能力—计算属性。
computed
计算属性的实现,如下所示:
//reactivity/src/computed.ts
import { effect } from './effect'
export type ComputedGetter<T> = (...args: any[]) => T
export interface ComputedRef<T = any> {
readonly value: T
}
export class ComputedRefImpl<T> {
private readonly effect: ReactiveEffect
constructor(
getter: ComputedGetter<T>,
) {
// 把getter作为副作用函数,创建一个ReactiveEffect
this.effect = new ReactiveEffect(getter)
}
get value() {
// 当读取value的时候才执行effect
return this.effect.run()
}
}
export function computed<T>(
getter: ComputedGetter<T>,
): ComputedRef<T> {
const cRef = new ComputedRefImpl(getter)
return cRef as any
}
参照vue3源码,我们定义了一个computed函数,它接受getter函数作为参数,在函数内部实例化一个ComputedRefImpl对象,ComputedRefImpl在构造函数中创建了一个ReactiveEffect实例,computed函数返回ComputedRefImpl对象,而该对象value属性是一个访问属性,只有当读取value的值时,才会执行副作用函数并将其结果作为返回值。
将computed函数导出:
// reactivity/src/index.ts
export { computed } from './computed'
// vue/src/index.ts
export { reactive, effect, scheduler, computed } from '@my-vue3/reactivity'
我们可以使用computed函数来创建一个计算属性:
const { reactive, computed } = Vue
const obj = reactive({
foo: 1,
})
const res = computed(() => obj.foo + 1)
console.log(res.value)
值缓存
上面实现的计算属性只能做到懒计算,但还做不到对值进行缓存,假使我们多次访问res.value的值,会导致副作用函数多次计算,即使obj.foo的值本身并没有变。
为了解决这个问题,就需要我们在实现ComputedRefImpl时,添加对值进行缓存的功能,如以下代码所示:
//reactivity/src/computed.ts
export class ComputedRefImpl<T> {
private readonly effect: ReactiveEffect
// value缓存上一次计算的值
private _value!: T
// dirty标志,用来标识是否需要重新计算值,为true则意味着"脏",需要计算
public _dirty = true
constructor(
getter: ComputedGetter<T>,
) {
this.effect = new ReactiveEffect(getter)
}
get value() {
if (this._dirty) {
this._dirty = false
this._value = this.effect()!
}
return this._value
}
}
这里新增了两个变量value和dirty,其中value用来缓存上一次计算的值,而dirty是个标识,代表是否需要重新计算。当我们通过res.value访问时,只有当dirty为true时才会调用effect重新计算,否则直接使用上一次缓存在value的值。这样无论我们访问多少次res.value,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的value值。
重新计算
接着这里也带来了新的问题,如果我们修改了obj.foo的值,在访问res.value会发现访问到的值没有发生变化:
const obj = reactive({
foo: 1,
})
const res = computed(() => obj.foo + 1)
console.log(res.value)
obj.foo++
// 值仍然是2
console.log(res.value)
这是由于第一次访问res.value值后,dirty标识会设置为false,代表不需要计算。即使我们修改了obj.foo的值,但只要dirty标识为false,就不会重新计算,所以导致我们得到了错误的值。而解决方法就是当obj.foo的值变化时,dirty的值重置为true就可以了,这里就需要我们用到之前实现的scheduler,代码如下:
//reactivity/src/computed.ts
export class ComputedRefImpl<T> {
private readonly effect: ReactiveEffect
private _value!: T
public _dirty = true
constructor(
getter: ComputedGetter<T>,
) {
this.effect = new ReactiveEffect(getter, () => {
//在调度器中将dirty重置为true
if (!this._dirty)
this._dirty = true
})
}
get value() {
if (this._dirty) {
this._dirty = false
this._value = this.effect()!
}
return this._value
}
}
我们为effect添加了scheduler调度器函数,它会在getter函数中所依赖的响应式数据变化时执行,这样我们在scheduler函数内将dirty重置为true,下一次访问res.value时,就会重新调用effect计算值,这样就能够得到预期的结果了。
computed的effect嵌套
此时我们的computed还有一个问题,体现在当我们在一个effect函数中读取计算属性时:
const obj = reactive({
foo: 1,
})
const res = computed(() => {
return obj.foo + 1
})
effect(() => {
// 在该副作用函数中读取res.value
console.log(res.value)
})
// 修改obj.foo的值,并不会触发effect注册的副作用函数
obj.foo++
如以上代码所示,res是一个计算属性,并且在另一个effect的副作用函数中读取了res.value的值。如果修改了obj.foo的值,我们期望副作用函数重新执行。但是如果尝试运行上面这段代码,会发现修改obj.foo的值并不会触发副作用函数的执行。
尝试分析这个问题的原因我们发现这是一个effect嵌套。一个计算属性内部拥有自己的effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的getter函数来说,它里面访问的响应式数据只会把computed内部的effect收集为依赖。而当把计算属性用于另外一个effect时,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集。
而解决办法就是读取计算属性的值时,我们需要手动调用track函数进行追踪,当计算属性依赖的响应式数据发生变化时,我们可以手动调用trigger函数触发响应。如一下代码所示:
//reactivity/src/computed.ts
export class ComputedRefImpl<T> {
private readonly effect: ReactiveEffect
private _value!: T
public _dirty = true
constructor(
getter: ComputedGetter<T>,
) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
// 当计算属性依赖的响应式数据发生变化时, 手动调用trigger函数触发响应
trigger(this, 'value')
}
})
}
get value() {
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
}
// 当读取value时,手动调用track函数进行追踪
track(this, 'value')
return this._value
}
}