实现 computed
上一篇已经把 effect、track 和 trigger 串起来了,并且给 effect 增加了两个重要能力:
lazy:先不执行副作用函数,等需要时再手动执行scheduler:响应式数据变化后,把重新执行的时机交给调用方控制
这两个能力刚好可以用来实现 computed。
computed 的核心特点有三个:
- 第一次读取
.value时才执行 getter - 依赖没有变化时,多次读取会复用缓存值
- 依赖变化后,不会立刻重新计算,而是等下一次读取时再计算
从这三个特点出发,实现一个基础版 computed。
使用方式
computed 可以接收一个函数:
const state = reactive({
a: 1,
b: 2
})
const count = computed(() => state.a * 2)
console.log(count.value)
也可以接收一个对象,用 get 和 set 分别处理读取和写入:
const count = computed({
get() {
return state.a + state.b
},
set(newValue) {
state.a = newValue
}
})
console.log(count.value)
count.value = 100
所以实现时第一步要先处理参数归一化:不管传进来的是函数还是对象,最终都统一成 getter 和 setter。
参数归一化
先创建 computed.js:
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
}
function normalizeOptions(getterOrOptions) {
let getter
let setter
if (typeof getterOrOptions === 'function') {
getter = getterOrOptions
setter = () => {
console.warn('computed value is readonly')
}
}
else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return {
getter,
setter
}
}
如果传入的是函数,说明这个 computed 只负责读取,写入时给一个默认提示。
如果传入的是对象,就直接取对象上的 get 和 set。
接下来要返回一个带有 value 访问器的对象。模板里可以直接使用计算属性,但在逻辑层里还是通过 .value 读取和设置。
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
const obj = {
get value() {
// 返回 getter 的执行结果
},
set value(value) {
setter(value)
}
}
return obj
}
现在的问题是:get value 里应该怎么拿到结果?
计算属性的值来自 getter 的执行结果,而 getter 内部又可能读取响应式数据。也就是: computed 本质上还是需要 effect
完成依赖收集。
使用 lazy effect
如果直接执行 getter,它确实可以拿到值,但依赖变化时就没办法和响应式系统关联起来。
所以这里用上一篇实现的 effect 包装 getter:
import { effect } from './effect/effect.js'
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
return effectFn()
},
set value(value) {
setter(value)
}
}
return obj
}
一定需要 lazy: true。
因为 computed 不应该在创建时就执行 getter,而应该等第一次读取 .value 时再执行。effect 返回的 effectFn
就是包装后的执行函数,调用它时会执行 getter,并在执行过程中完成依赖收集。
但这样还有一个问题:每次读取 .value 都会重新执行 getter。
const count = computed(() => {
console.log('computed')
return state.a * 2
})
console.log(count.value)
console.log(count.value)
console.log(count.value)
这段代码会打印三次 computed。
计算属性应该有缓存。只要依赖没有变化,多次读取都应该返回上一次的结果。
增加缓存
增加两个变量:
value:保存上一次计算结果dirty:标记缓存是否已经失效
第一次读取时,dirty 是 true,执行 effectFn 得到最新值,然后把 dirty 改成 false。
后续再次读取时,如果 dirty 还是 false,直接返回缓存的 value。
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
},
set value(value) {
setter(value)
}
}
return obj
}
这样多次读取 .value 时,getter 只会执行一次。
不过新的问题也出现了:如果依赖的响应式数据变化了,缓存并不会自动失效。
const count = computed(() => {
console.log('computed')
return state.a * 2
})
console.log(count.value)
console.log(count.value)
state.a = 100
console.log(count.value)
console.log(count.value)
state.a 修改后,下一次读取应该重新计算。
所以响应式数据触发更新时,需要把 dirty 改回 true。
使用 scheduler 标记失效
依赖变化时,会通过 trigger 找到 getter 对应的 effectFn。
但是 computed 不希望依赖一变就立刻重新计算。它只需要把缓存标记为失效,等下一次读取 .value 时再重新计算。
使用 scheduler:
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
},
set value(value) {
setter(value)
}
}
return obj
}
现在
- 创建
computed时不会执行 getter - 第一次读取
.value时执行 getter,并缓存结果 - 后续读取直接返回缓存
- 依赖变化时只把
dirty改成true - 下一次读取
.value时再重新计算
computed 被 effect 使用
const count = computed(() => {
console.log('computed')
return state.a * 2
})
effect(() => {
console.log(count.value)
})
state.a = 100
外层 effect 读取了 count.value,所以它应该依赖这个计算属性。
当 state.a 修改后,count 的值会变化,外层 effect 也应该重新执行。
但是按照当前实现,count.value 只是一个普通对象上的 getter。读取它时,并没有调用 track 收集依赖。依赖变化时,scheduler
也只是把 dirty 改成了 true,并没有通知使用 count.value 的副作用函数。
所以要把 computed 自己也接入依赖收集:
- 读取
value时,手动调用track(obj, TrackOpTypes.GET, 'value') - 依赖变化时,手动调用
trigger(obj, TriggerOpTypes.SET, 'value')
import { TrackOpTypes, TriggerOpTypes } from './constant/enum.js'
import { effect } from './effect/effect.js'
import track from './effect/track.js'
import trigger from './effect/trigger.js'
export function computed(getterOrOptions) {
const { getter, setter } = normalizeOptions(getterOrOptions)
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true
trigger(obj, TriggerOpTypes.SET, 'value')
}
})
const obj = {
get value() {
track(obj, TrackOpTypes.GET, 'value')
if (dirty) {
value = effectFn()
dirty = false
}
return value
},
set value(value) {
setter(value)
}
}
return obj
}
这样外层 effect 执行时,会读取 count.value。读取过程中 track 会把外层 effect 收集到 count.value 的依赖集合里。
当 state.a 变化时,会先触发 computed 内部 getter 对应的 scheduler。scheduler 把 dirty 标记为 true,然后再通过
trigger 通知依赖 count.value 的外层 effect 重新执行。
外层 effect 再次读取 count.value 时,因为 dirty 已经是 true,所以会重新执行 getter 并得到最新值。
总结
实现了一个基础版 computed:
- 用参数归一化同时支持函数写法和对象写法
- 用
effect(getter, { lazy: true })实现懒执行 - 用
value和dirty实现缓存 - 用
scheduler在依赖变化时标记缓存失效 - 在读取和失效时手动调用
track、trigger,让computed可以被其他effect依赖