06-computed的精妙设计
上一章我们深入了 ref 的实现原理,理解了原始值如何获得响应式能力。现在让我们思考一个问题:如果一个值需要根据其他响应式数据计算得出,每次访问都重新计算是否太浪费?
这就是 computed 要解决的问题——它不仅是响应式的,还具有惰性求值和缓存的特性。
从一个业务场景说起
假设你正在开发一个电商购物车,需要实时显示商品总价:
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ name: '商品A', price: 100, quantity: 2 },
{ name: '商品B', price: 50, quantity: 3 }
])
// 方案1:普通函数
function getTotalPrice() {
console.log('计算总价...')
return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
// 方案2:computed
const totalPrice = computed(() => {
console.log('计算总价...')
return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
</script>
<template>
<!-- 模板中多次使用 -->
<div>总价: {{ totalPrice }}</div>
<div>含税: {{ totalPrice * 1.1 }}</div>
<div>折扣后: {{ totalPrice * 0.9 }}</div>
</template>
n
h对比两种方案的输出结果,差异一目了然:模板中三次访问 totalPrice,如果用普通函数,会打印三次"计算总价...";而用 computed,只会打印一次。
这正是 computed 的核心价值所在——缓存。它只在依赖(items)发生变化时才重新计算,其余情况直接返回缓存值,从而避免不必要的重复计算。
那么,computed 是如何在 Vue 响应式系统中实现这一机制的呢?为了回答这个问题,我们需要先理解它的独特定位。
computed 的双重身份
computed 是响应式系统中最精妙的设计之一。它有一个独特的"双重身份":
- 作为订阅者(Subscriber):依赖其他响应式数据,当依赖变化时需要重新计算
- 作为依赖源(Dep):被其他 effect 订阅,当自身值变化时需要通知订阅者
flowchart TB
subgraph 上游依赖
A["items ref"]
end
subgraph computed双重身份
C["computed<br/>totalPrice"]
C1["Subscriber 角色<br/>订阅 items"]
C2["Dep 角色<br/>被模板订阅"]
end
subgraph 下游订阅者
D["渲染 Effect"]
end
A -->|"track"| C1
C1 --> C
C --> C2
C2 -->|"trigger"| D
这种双重身份并不是抽象的概念,而是通过 ComputedRefImpl 类具体实现的。它同时拥有 dep(作为依赖源管理下游订阅者)和 deps(作为订阅者记录上游依赖),从而在响应式系统中扮演着承上启下的关键角色。
ComputedRefImpl:核心实现
classDiagram
class ComputedRefImpl {
+_value: T
+dep: Dep
+deps: Link
+depsTail: Link
+flags: EffectFlags
+globalVersion: number
+fn: ComputedGetter
+notify(): boolean
+get value(): T
+set value(v)
}
class Subscriber {
<<interface>>
+deps: Link
+depsTail: Link
+flags: EffectFlags
+notify(): void
}
class Dep {
+subs: Link
+version: number
+track(): Link
+notify(): void
}
ComputedRefImpl ..|> Subscriber : 实现订阅者接口
ComputedRefImpl --> Dep : 拥有自己的Dep
// 文件: packages/reactivity/src/computed.ts(伪代码)
export class ComputedRefImpl<T = any> implements Subscriber {
_value: any = undefined
// 作为依赖源:拥有自己的 Dep,管理下游订阅者
readonly dep: Dep = new Dep(this)
// 作为订阅者:deps 链表记录上游依赖
deps?: Link = undefined
depsTail?: Link = undefined
flags: EffectFlags = EffectFlags.DIRTY // 初始状态为"脏"
// 全局版本号,用于快速路径优化
globalVersion: number = globalVersion - 1
constructor(
public fn: ComputedGetter<T>,
private readonly setter: ComputedSetter<T> | undefined,
) {
// 没有 setter 则标记为只读
this[ReactiveFlags.IS_READONLY] = !setter
}
// 当上游依赖变化时被调用
notify(): true | void {
this.flags |= EffectFlags.DIRTY // 标记为脏,下次访问时重新计算
if (!(this.flags & EffectFlags.NOTIFIED) && activeSub !== this) {
batch(this, true) // 加入批处理队列
return true // 返回 true 表示需要继续通知 computed 的下游订阅者
}
}
get value(): T {
// 1. 收集当前访问者作为订阅者
const link = this.dep.track()
// 2. 按需重新计算(核心逻辑)
refreshComputed(this)
// 3. 同步版本号,用于后续脏检查
if (link) {
link.version = this.dep.version
}
return this._value
}
set value(newValue) {
if (this.setter) {
this.setter(newValue)
}
}
}
关键设计点:
dep:作为依赖源,管理下游订阅者(如渲染 effect)deps/depsTail:作为订阅者,记录上游依赖(如 ref、reactive)flags:状态标记,DIRTY表示需要重新计算globalVersion:全局版本号,用于快速判断是否需要重新计算
有了这个核心结构作为基础,computed 就能够实现惰性求值的特性了。接下来让我们深入探索这一机制是如何运作的。
惰性求值与缓存机制
我们已经知道 computed 具有双重身份,但这还不足以解释它的缓存特性。computed 的核心特性是惰性求值——只有在被访问时才计算,且只有依赖变化时才重新计算。
DIRTY 标记:脏检查机制
这种惰性求值的实现依赖于一个巧妙的状态管理机制——DIRTY 标记。它通过状态转换来控制计算时机:
stateDiagram-v2
[*] --> DIRTY: 初始状态
DIRTY --> CLEAN: 访问 value,执行计算
CLEAN --> DIRTY: 依赖变化,notify() 被调用
CLEAN --> CLEAN: 访问 value,直接返回缓存
note right of DIRTY: 需要重新计算
note right of CLEAN: 可以使用缓存
当依赖变化时,computed 不会立即重新计算,而是先标记为 DIRTY。只有下次访问 .value 时才真正计算——这就是"惰性"的含义。
refreshComputed:按需计算的核心
明白了状态管理机制后,我们来看看具体的计算逻辑。refreshComputed 函数负责在需要时重新计算 computed 的值,它设计了三层快速路径,尽可能避免不必要的计算:
flowchart TB
A["访问 computed.value"] --> B["refreshComputed()"]
B --> C{"快速路径1<br/>TRACKING && !DIRTY?"}
C -->|是| D["跳过计算 ✓"]
C -->|否| E{"快速路径2<br/>globalVersion 未变化?"}
E -->|是| D
E -->|否| F{"快速路径3<br/>EVALUATED && !isDirty?"}
F -->|是| D
F -->|否| G["执行 getter 函数"]
G --> H{"值变化了?"}
H -->|是| I["更新 _value<br/>dep.version++"]
H -->|否| J["保持原值"]
I --> K["返回新值"]
J --> K
// 文件: packages/reactivity/src/effect.ts(伪代码)
export function refreshComputed(computed: ComputedRefImpl): undefined {
// 快速路径1:正在追踪且不脏,直接跳过
if (computed.flags & EffectFlags.TRACKING &&
!(computed.flags & EffectFlags.DIRTY)) {
return
}
computed.flags &= ~EffectFlags.DIRTY // 清除脏标记
// 快速路径2:全局版本未变化,说明没有任何响应式数据变化
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
// 快速路径3:已求值过且依赖未真正变化
if (computed.flags & EffectFlags.EVALUATED && !isDirty(computed)) {
return
}
// 需要重新计算
computed.flags |= EffectFlags.RUNNING
const prevSub = activeSub
const prevShouldTrack = shouldTrack
activeSub = computed // 设置当前活跃订阅者,用于依赖收集
shouldTrack = true
try {
prepareDeps(computed) // 准备依赖追踪
const value = computed.fn(computed._value) // 执行 getter
// 只有值变化时才更新版本号,触发下游更新
if (computed.dep.version === 0 || hasChanged(value, computed._value)) {
computed.flags |= EffectFlags.EVALUATED
computed._value = value
computed.dep.version++ // 版本号递增,通知下游订阅者
}
} finally {
activeSub = prevSub
shouldTrack = prevShouldTrack
cleanupDeps(computed) // 清理未使用的依赖
computed.flags &= ~EffectFlags.RUNNING
}
}
三层快速路径详解
| 快速路径 | 条件 | 含义 | 性能收益 |
|---|---|---|---|
| 路径1 | TRACKING && !DIRTY | 正在被追踪且不脏 | 避免任何检查 |
| 路径2 | globalVersion 未变化 | 没有任何响应式数据变化 | 避免遍历依赖 |
| 路径3 | EVALUATED && !isDirty | 已求值过且依赖版本号匹配 | 避免执行 getter |
这三层快速路径层层递进,从最简单的状态检查到复杂的版本号比对,尽可能在早期就判断出无需计算,从而最大程度地提升性能。
isDirty:深度脏检查
当所有快速路径都不满足时,就需要通过 isDirty 进行深度检查,确认依赖是否真的发生了变化:
// 文件: packages/reactivity/src/effect.ts(伪代码)
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
// 检查版本号是否匹配
if (link.dep.version !== link.version) {
return true // 版本号不匹配,说明依赖变化了
}
// 如果依赖的是另一个 computed,递归检查
if (link.dep.computed) {
refreshComputed(link.dep.computed) // 先刷新上游 computed
if (link.dep.version !== link.version) {
return true
}
}
}
return false
}
如果依赖的是另一个 computed,会先刷新那个 computed,形成递归检查链。这种设计确保了 computed 嵌套链的正确性——无论嵌套多少层,都能准确地判断是否需要重新计算。
computed 的依赖传播
了解了 computed 如何判断是否需要重新计算后,还有一个关键问题需要解决:当 computed 的依赖变化时,它如何高效地通知自己的订阅者呢?答案在于 notify() 的巧妙设计。
sequenceDiagram
participant R as count (ref)
participant C1 as double (computed)
participant C2 as quad (computed)
participant E as 渲染 Effect
R->>R: count.value = 2
R->>C1: dep.notify()
Note over C1: 标记为 DIRTY
C1-->>R: return true
Note over R: 检测到是 computed
R->>C1: double.dep.notify()
C1->>C2: notify()
Note over C2: 标记为 DIRTY
C2-->>C1: return true
C1->>C2: quad.dep.notify()
C2->>E: notify()
Note over E: 加入调度队列
Note over E: 调度执行
E->>C2: 访问 quad.value
C2->>C1: 访问 double.value
Note over C1: refreshComputed
Note over C2: refreshComputed
// 文件: packages/reactivity/src/dep.ts(伪代码)
// Dep.notify() 中的关键逻辑
notify() {
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
// 如果 notify() 返回 true,说明是 computed
// 需要继续通知 computed 的下游订阅者
(link.sub as ComputedRefImpl).dep.notify()
}
}
}
这种设计巧妙地避免了在 computed 的 notify 中直接调用 dep.notify(),既减少了调用栈深度,又让依赖传播的逻辑更加清晰和集中。通过返回值来传递信息,而不是直接调用,这是一种优雅的职责分离设计。
Vue 2 vs Vue 3:computed 的演进
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 实现方式 | Watcher + lazy 标记 | ComputedRefImpl + DIRTY 标记 |
| 依赖追踪 | Dep + Watcher 双向链表 | Link 链表 + 版本号 |
| 脏检查 | 简单的 dirty 布尔值 | 三层快速路径 + isDirty |
| 缓存策略 | 依赖变化即标脏 | 版本号比对,更精细 |
| 性能优化 | 无 globalVersion | globalVersion 快速路径 |
Vue 3 的主要改进:
- 更精细的脏检查:Vue 2 只有一个
dirty布尔值,Vue 3 通过版本号可以精确判断依赖是否真的变化,避免了不必要的重新计算 - globalVersion 优化:如果没有任何响应式数据变化,可以直接跳过检查,进一步提升性能
- Link 链表:相比 Vue 2 的数组实现,Link 链表支持 O(1) 的增删操作,在大规模应用中性能优势明显
这些改进使得 Vue 3 的 computed 在复杂场景下的性能表现更加出色,尤其是在有大量 computed 嵌套的情况下。
总结
通过对 computed 的深入剖析,我们可以将其核心设计概括为四个关键点:
- 双重身份:既是订阅者(依赖上游),又是依赖源(被下游订阅),在响应式链中承上启下
- 惰性求值:只有被访问时才计算,依赖变化时只标记为脏,不立即执行
- 缓存机制:只有依赖真正变化时才重新计算,其余时间复用缓存值
- 三层快速路径:从状态检查到版本号比对,尽可能避免不必要的计算和检查
computed 是 Vue 响应式系统中最精妙的设计之一,它完美平衡了响应性和性能,让开发者可以放心地使用派生状态,而不必担心性能问题。
本章涉及源码:
packages/reactivity/src/computed.ts- ComputedRefImpl、computed()packages/reactivity/src/effect.ts- refreshComputed()、isDirty()
至此,我们已经深入理解了 computed 如何优雅地解决派生数据的问题。但在实际开发中,除了派生数据,我们还经常需要在数据变化时执行一些「副作用」——比如发起网络请求、操作 DOM、记录日志等。下一章,我们将深入 watch 的实现原理,探索 Vue 响应式系统的另一个重要组成部分。
🤔 深度思考
1. computed 的三层快速路径中,globalVersion 是什么?为什么它能作为快速判断的依据?如果没有这个优化,会有什么问题?
2. 当一个 computed 依赖另一个 computed 时,isDirty 会递归调用 refreshComputed。这种设计有什么好处?会不会导致循环依赖问题?Vue 是如何处理的?
3. computed 的 notify() 返回 true 来触发依赖链传播。为什么不直接在 notify() 内部调用 this.dep.notify()?这种设计有什么优势?
4. 在实际项目中,如何判断一个计算逻辑应该用 computed 还是用普通函数?有哪些场景下 computed 反而不如普通函数?