今天我们要在保持既有链表架构不变的前提下,实现 computed 的惰性计算 + 缓存(dirty 旗标)与调度逻辑。
示例演示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
<style>
body {
padding: 150px;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
// import { ref, computed, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const c = computed(() => {
return count.value + 1
})
effect(() => {
console.log(c.value)
})
setTimeout(() => {
console.log(count.value)
}, 1000)
</script>
</body>
</html>
先看官方代码的效果:
可以看到控制台会先输出 1,再输出 2,其中的 computed 只在需要时重算(惰性)。
执行顺序如下:
初始化
- 初始化变量:
count、c - 初始化
effect,立即执行console.log(c.value) - 收集
computed依赖,触发计算函数() => count.value + 1 - 读取
count.value,函数返回0 + 1,结果为1,输出1
一秒之后
-
执行
count.value = 1 -
Vue 侦测到
count的值从0变为1 -
当
count.value被修改时,会通知所有订阅它的对象,此处包含c -
c接到通知后,重新计算自己的值,并接着通知所有订阅c的对象(即effect),最终触发effect重新执行 -
effect收到通知,自动重新执行其内部函数:() => console.log(c.value)effect再次读取c.value- 重新执行计算函数
() => count.value + 1 - 此时
count.value已为1 c计算出新值1 + 1 = 2,输出2
在这个过程中,computed 扮演的角色如下图所示:
设计核心
首先,computed 具有双重角色:
-
订阅者(Sub) :会收集其执行函数(getter)中访问到的所有响应式依赖。
-
依赖项(Dep) :当
effect访问computed.value时,computed会把这个effect收集起来,建立关联。 -
computed的入参既可能是函数,也可能是对象:- 若为函数:只有
getter(只读computed) - 若为对象:同时包含
getter与setter
- 若为函数:只有
什么是 Sub?什么是 Dep?可参考我们之前定义的接口:
/**
* 依赖项
*/
export interface Dependency {
// 订阅者链表头节点
subs: Link | undefined
// 订阅者链表尾节点
subsTail: Link | undefined
}
/**
* 订阅者
*/
export interface Sub {
// 订阅者链表头节点
deps: Link | undefined
// 订阅者链表尾节点
depsTail: Link | undefined
// 是否正在收集依赖
tracking: boolean
}
Sub 特征
- 有
deps头节点 - 有
depsTail尾节点 - 有“是否正在收集依赖”的标记
Dep 特征
- 有
subs头节点 - 有
subsTail尾节点 - 必定是响应式实体(
ref或reactive)
实现
先在 @vue/shared 新增一个类型判断函数:
export function isFunction(value) {
return typeof value === 'function'
}
由于 computed 的入参可能是函数或对象,我们新增 computed.ts 并导出 computed 函数,用来判定入参类型:
- 传入函数:表示只有 getter(只读)
- 传入对象:表示同时有 getter 与 setter
export function computed(getterOptions) {
let getter
let setter
if (isFunction(getterOptions)) {
getter = getterOptions
} else {
getter = getterOptions.get
setter = getterOptions.set
}
// ComputedRefImpl 是 computed 的实际响应式实现类,将 getter 与 setter 传入
return new ComputedRefImpl(getter, setter)
}
接着实现 ComputedRefImpl 类,并把 Dep 与 Sub 所需的属性加上:
class ComputedRefImpl implements Dependency, Sub {
// computed 是 ref,因此会有该标记;通过 isRef 也会返回 true
[ReactiveFlags.IS_REF] = true
// 保存 fn 的返回值
_value
// 作为 Dep:关联订阅者 Subs,触发更新时通知执行 fn
subs: Link
subsTail: Link
// 作为 Sub:记录收集到的 Dep
deps: Link
depsTail: Link
tracking = false
constructor(
public fn, // getter,源码中字段名为 fn,为与 effect 保持一致
private setter
) { }
get value() {
this.update()
return this._value
}
set value(newValue) {
// 若传入了 setter,表示入参是对象
if (this.setter) {
this.setter(newValue)
} else {
console.warn('computed is readonly')
}
}
update(){
this._value = this.fn()
}
}
运行这段代码,表面看能正确计算结果:
但目前 get value() 每次读取都会直接 update() ,尚未引入缓存/dirty。多次读值或多个 effect 时会反复计算。
我们刚才提到 computed 有双重角色;那如何让 computed 同时扮演 Dep 与 Sub 呢?回顾先前的链表/依赖逻辑:
当 Computed 作为 Dep
先在 get value() 里与当前的 activeSub 建立关联(link(this, activeSub)),并仅在 dirty 时调用 update,避免每次读值都重算。
class ComputedRefImpl implements Dependency, Sub {
...
...
get value() {
this.update()
if(activeSub){
link(this,activeSub)
}
console.log('computed',this)
return this._value
}
...
...
}
接着在控制台确认是否正常收集到 fn:
看起来已正确保存 fn,表明关联关系已建立。
我们已完成下图红色区域的链接:
当 Computed 作为 Sub
在 fn 执行期间需要收集访问到的响应式依赖。我们沿用此前的 setActiveSub / startTrack / endTrack 机制,无需改动 effect 架构;只需在 ComputedRefImpl.update() 内部包一层收集区段。
(回顾 effect 运行逻辑)
export function setActiveSub(sub) {
activeSub = sub
}
export class ReactiveEffect {
...
run() {
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
return this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
...
...
}
通过 setActiveSub 重新赋值 activeSub,在 computed.ts 引入并使用:
import { activeSub, setActiveSub } from './effect'
...
...
update(){
// 为了在 fn 执行期间收集访问到的响应式
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
this._value = this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
console.log(this)
}
}
...
...
在控制台中可以看到 dep 也被成功保存:
因此,下图红圈处也已完成:
报错
但你会发现一个错误:
原因是 Ref 在 setTimeout 触发更新时会执行 setter:
...
...
set value(newValue) {
if(hasChanged(newValue, this._value)){
this._value = isObject(newValue) ? reactive(newValue) : newValue
triggerRef(this)
}
}
...
然而执行到 propagate 函数时:
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
const sub = link.sub
// 只有不在执行中的才加入队列
if(!sub.tracking){
queuedEffect.push(sub)
}
link = link.nextSub
}
queuedEffect.forEach(effect =>effect.notify())
}
propagate 预期所有 sub 都有 run()(或可调度的接口),但我们的 ComputedRefImpl 并没有这个方法。
目前我们已分别完成两段链路:
- 让
computed成为count的订阅者(Sub) - 让
computed成为effect的依赖项(Dep)
接下来需要把这两段串起来,形成完整的更新流程。
解决问题
触发更新时的流程应为:
ref触发更新- 通过 Sub 找到
computed computed执行自身更新computed再通过自身的 sub 链表- 找到所有下游 Sub(例如 effect)并重新执行
因此我们需要:
- 处理
computed的更新 - 让
computed通过自己的 sub 链表通知其他 Sub 更新
回顾我们原本在 computed 内如何执行更新:
此前我们在 ComputedRefImpl 中定义了 update 方法,可以用它来更新 computed 的值。我们增加一个辅助:
export function processComputedUpdate(sub) {
// 通知 computed 更新
sub.update()
// 通知其 sub 链表中的其他 sub 更新
propagate(sub.subs)
}
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
const sub = link.sub
if(!sub.tracking){
// 如果 link.sub 有 update 方法,说明传入的是 computed
if('update' in sub){
processComputedUpdate(sub)
}else{
queuedEffect.push(sub)
}
}
link = link.nextSub
}
queuedEffect.forEach(effect =>effect.notify())
}
这样我们就能通过“是否存在 update 方法”来判断 Sub 是否是 computed:
- 若是
computed:除了触发其更新函数外,还需继续向下通知它的 sub 链表 - 若是普通
effect:加入执行队列并按原逻辑notify()
运行后表面上结果正确,但如果 index.html 这样写:
const count = ref(0)
const c = computed(() => {
console.count('computed')
return count.value + 1
})
effect(() => {
console.log(c.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
你会发现它触发了三次:
而用官方示例,实际只会执行两次:
问题根源在于 get value() 的实现:每次访问 .value 都直接触发 update(),没有实现缓存:
get value() {
this.update()
...
...
}
今天我们将加入缓存与 dirty 标记,并以 notify() 充当简易调度器:上游变更只标脏,下游读取时才重算。
下篇我们会补充“同一 tick 多次读值只计算一次”以及“多层 computed 链”的范例,来确认性能与语义。
computed 完整代码如下(当前版本,未加 dirty 优化前):
import { ReactiveFlags } from './ref'
import { Dependency, Sub, Link, link, startTrack, endTrack } from './system'
import { isFunction } from '@vue/shared'
import { activeSub, setActiveSub } from './effect'
class ComputedRefImpl implements Dependency, Sub {
// computed 是 ref,所以他会有这个标志,通过 isRef 也会返回 true
[ReactiveFlags.IS_REF] = true
// 保存 fn 返回值
_value
// 作为 Dep:关联 Subs,触发更新要通知执行 fn
subs: Link
subsTail: Link
// 作为 Sub:记录收集到的 Dep
deps: Link
depsTail: Link
tracking = false
constructor(
public fn, // getter,源码是 fn,保持与 effect 一致
private setter
) { }
get value() {
this.update()
if(activeSub){
link(this,activeSub)
}
return this._value
}
set value(newValue) {
if (this.setter) {
this.setter(newValue)
} else {
console.warn('computed is readonly')
}
}
update(){
/**
* 收集依赖
* 为了在 fn 执行期间,收集访问到的响应式
*/
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
this._value = this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
}
export function computed(getterOptions) {
let getter
let setter
if (isFunction(getterOptions)) {
getter = getterOptions
} else {
// 传入是对象,对象有 get 和 set
getter = getterOptions.get
setter = getterOptions.set
}
return new ComputedRefImpl(getter, setter)
}
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。