Vue Computed 的设计与实现
一、前言
在上一节中,我们分析了 Vue 的调度系统实现。本节我们将分析 computed 计算属性的实现原理。computed 是 Vue 中最常用的特性之一,它能够基于响应式状态派生出新的状态,并且具有缓存的特性。
二、示例引入
让我们从一个简单的计算属性示例开始:
<template>
<div>
<p>原始价格: {{ price }}</p>
<p>折扣价格: {{ discountPrice }}</p>
<button @click="increasePrice">加价</button>
</div>
</template>
<script>
import { ref, computed } from "vue";
export default {
setup() {
const price = ref(100);
const discount = ref(0.9);
// 计算属性:根据原价和折扣计算折扣价
const discountPrice = computed(() => {
console.log("computing discount price...");
return price.value * discount.value;
});
const increasePrice = () => {
price.value += 10;
};
return { price, discountPrice, increasePrice };
},
};
</script>
这个例子展示了计算属性的几个重要特性:
- 响应式计算:当 price 或 discount 变化时,discountPrice 会自动更新
- 缓存特性:如果依赖值没有变化,多次访问 discountPrice 只会计算一次
- 惰性求值:只有在访问 discountPrice 时才会进行计算
三、核心实现分析
3.1 computed 函数的入口
当我们调用 computed 函数时,它会创建一个 ComputedRefImpl 实例:
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false
) {
let getter: ComputedGetter<T>;
let setter: ComputedSetter<T> | undefined;
// 处理参数,支持传入 getter 函数或包含 get/set 的对象
if (isFunction(getterOrOptions)) {
getter = getterOrOptions;
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
// 创建 ComputedRefImpl 实例
const cRef = new ComputedRefImpl(getter, setter, isSSR);
if (__DEV__ && debugOptions && !isSSR) {
cRef.onTrack = debugOptions.onTrack;
cRef.onTrigger = debugOptions.onTrigger;
}
return cRef as any;
}
3.2 ComputedRefImpl 的实现
ComputedRefImpl 类本质上是一个特殊的 effect,它与 ReactiveEffect 一样实现了 Subscriber 接口,成为响应式系统中的一个订阅者:
export class ComputedRefImpl<T = any> implements Subscriber {
// 作为订阅者,与 ReactiveEffect 一样维护双向依赖关系
private deps?: Link = undefined; // 指向自己的依赖项
private depsTail?: Link = undefined; // 指向最后一个依赖项
// 作为响应式数据,需要收集依赖自己的订阅者
private readonly dep: Dep = new Dep(this);
// 计算结果缓存
private _value: any = undefined;
// 响应式标记
private readonly __v_isRef = true;
private readonly __v_isReadonly: boolean;
// effect 的运行状态标记
private flags: EffectFlags = EffectFlags.DIRTY;
private globalVersion: number = globalVersion - 1;
constructor(
// 计算函数,相当于 effect 的 fn
public fn: ComputedGetter<T>,
private readonly setter: ComputedSetter<T> | undefined,
isSSR: boolean
) {
this[ReactiveFlags.IS_READONLY] = !setter;
this.isSSR = isSSR;
}
}
与 ReactiveEffect 的共同点
-
订阅者身份
// ReactiveEffect deps?: Link = undefined depsTail?: Link = undefined // ComputedRefImpl deps?: Link = undefined depsTail?: Link = undefined- 都实现了 Subscriber 接口
- 都使用 deps 和 depsTail 参与双向十字链表
- 都会被加入到依赖的 dep.subs 列表中
-
执行机制
// ReactiveEffect run() { // 执行副作用函数 return this.fn() } // ComputedRefImpl get value() { // 执行计算函数 refreshComputed(this) return this._value }- 都有一个核心函数需要执行(fn/计算函数)
- 都会在执行过程中收集依赖
- 都支持停止和清理
-
依赖追踪
// 都会在执行过程中被收集为依赖 dep.track() // 都会在依赖变化时收到通知 notify(): true | void- 都参与响应式系统的依赖追踪
- 都实现了 notify 方法接收更新通知
- 都支持依赖的动态收集和清理
计算属性的特殊之处
虽然本质相同,但计算属性有其特殊性:
-
双重身份
// 既是订阅者 implements Subscriber // 也是响应式数据 private readonly dep: Dep = new Dep(this) private readonly __v_isRef = true- 不仅订阅其他响应式数据
- 自身也是一个响应式数据,可以被其他 effect 订阅
-
缓存机制
private flags: EffectFlags = EffectFlags.DIRTY private _value: any = undefined- 通过脏值标记控制重新计算
- 缓存计算结果避免重复计算
- 只在被访问且脏值时才重新执行
-
调度时机
notify() { this.flags |= EffectFlags.DIRTY batch(this, true) }- 依赖变化时不会立即计算
- 标记为脏值并等待下次访问
- 通过调度系统实现批量更新
这种设计让计算属性既保持了 effect 的响应式特性,又增加了缓存和延迟计算的优化,使其能够:
- 准确地追踪和响应依赖变化
- 避免不必要的重复计算
- 在合适的时机进行更新
- 支持复杂的计算属性链式依赖
3.3 计算属性的刷新过程
现在我们已经了解了计算属性的基本结构,接下来让我们看看它是如何处理值的更新的。
让我们以开头的折扣价格示例来分析计算属性是如何获取最新值的。当模板中的 {{ discountPrice }} 被渲染时,会触发计算属性的 get value 过程:
get value(): T {
// 1. 依赖收集:收集当前正在执行的 effect(模板渲染 effect)
const link = __DEV__
? this.dep.track({
target: this,
type: TrackOpTypes.GET,
key: "value",
})
: this.dep.track();
// 2. 计算新值
refreshComputed(this);
// 3. 同步版本号,用于后续的依赖追踪
if (link) {
link.version = this.dep.version;
}
// 4. 返回计算结果
return this._value;
}
这个过程涉及到几个关键步骤:
1. 依赖收集(作为 ref 数据)
const link = this.dep.track();
- 当模板渲染时,会创建一个渲染 effect
- 这个渲染 effect 会被收集为计算属性的依赖
- 这样当计算属性的值变化时,可以通知模板重新渲染
2. 计算值的更新
refreshComputed 函数是获取计算属性新值的核心逻辑:
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;
const dep = computed.dep;
// 3. 设置运行标记
computed.flags |= EffectFlags.RUNNING;
// 4. 依赖检查:如果依赖没有变化且不是 SSR,直接返回
// 在 SSR 中没有渲染 effect,计算属性没有订阅者,
// 因此不会追踪依赖,不能依赖脏值检查
if (
dep.version > 0 &&
!computed.isSSR &&
computed.deps &&
!isDirty(computed)
) {
computed.flags &= ~EffectFlags.RUNNING;
return;
}
// 5. 保存当前上下文
const prevSub = activeSub;
const prevShouldTrack = shouldTrack;
// 设置新的上下文
activeSub = computed;
shouldTrack = true;
try {
// 6. 准备依赖
prepareDeps(computed);
// 7. 执行计算函数
const value = computed.fn(computed._value);
// 8. 值变化检查:如果是首次计算或值发生变化
if (dep.version === 0 || hasChanged(value, computed._value)) {
// 更新值和版本号
computed._value = value;
dep.version++;
}
} catch (err) {
// 9. 错误处理:确保即使出错也更新版本号
dep.version++;
throw err;
} finally {
// 10. 恢复上下文
activeSub = prevSub;
shouldTrack = prevShouldTrack;
// 11. 清理旧依赖
cleanupDeps(computed);
// 12. 清除运行标记
computed.flags &= ~EffectFlags.RUNNING;
}
}
这个函数实现了完整的计算属性刷新逻辑:
- 优化检查
- 通过 TRACKING 和 DIRTY 标记快速判断是否需要重新计算
- 使用全局版本号避免不必要的计算
- 依赖追踪准备
- 设置运行标记,表示正在执行计算
- 保存并更新依赖追踪上下文
- 计算过程
- 准备新一轮的依赖收集
- 执行计算函数,同时收集新的依赖
- 比较新旧值,决定是否需要更新
- 依赖管理
- 在计算开始前准备依赖(prepareDeps)
- 计算完成后清理不再需要的依赖(cleanupDeps)
- 维护依赖的版本号,用于变化检测
- 错误处理
- 确保即使计算出错也能正确更新版本号
- 保证错误不会影响依赖追踪系统的状态
- 上下文管理
- 正确保存和恢复全局状态
- 确保计算属性的执行不影响其他响应式操作
这个实现确保了计算属性的几个重要特性:
- 准确的依赖追踪
- 高效的缓存机制
- 正确的错误处理
- 与响应式系统的完美集成
3. 依赖变化时的更新流程
当依赖发生变化时(例如点击按钮执行 price.value += 10),会触发一个完整的更新流程:
// ComputedRefImpl 的 notify 方法实现
notify(): true | void {
// 1. 标记为脏值
this.flags |= EffectFlags.DIRTY
// 2. 检查是否需要进入调度队列
if (
!(this.flags & EffectFlags.NOTIFIED) && // 还未被通知
activeSub !== this // 避免自身递归
) {
// 3. 将当前计算属性加入调度队列
batch(this, true)
return true
} else if (__DEV__) {
// 开发环境下的警告处理
}
}
完整的更新流程如下:
-
触发依赖更新
price.value += 10触发 price 的 set 操作- price 通知其所有依赖者(包括 discountPrice 计算属性)
-
计算属性接收通知
- discountPrice 的 notify 方法被调用
- 将计算属性标记为"脏值"(DIRTY)
- 通过调度系统(batch)将更新加入队列
-
等待访问
- 计算属性不会立即重新计算
- 而是等到下次访问
.value时才会计算 - 这体现了计算属性的"惰性计算"特性
-
重新计算过程 当模板中的
{{ discountPrice }}被访问时:get value(): T { // 1. 收集当前访问者作为依赖 const link = this.dep.track() // 2. 由于之前被标记为 DIRTY,这里会触发重新计算 refreshComputed(this) // 3. 更新依赖的版本号 if (link) { link.version = this.dep.version } // 4. 返回新计算的值 return this._value } -
更新传播
- 如果计算结果发生变化,会通知依赖这个计算属性的其他响应式效果
- 例如触发模板的重新渲染
这种设计实现了几个重要目标:
-
避免不必要的计算:只有真正需要值的时候才计算
-
批量更新优化:通过调度系统统一处理更新
-
防止递归死循环:通过标记机制避免自身递归
-
保持响应式链路:确保依赖变化能够正确传播
3.4 缓存机制
<template>
<div>{{ discountPrice }} - {{ discountPrice }}</div>
</template>
计算属性的缓存机制体现在:
- 同一次渲染中多次访问 discountPrice
- 第一次访问会计算新值
- 第二次访问直接返回缓存的 _value,因为 DIRTY 标记已被清除
- 惰性计算:只在真正需要值的时候才计算
- 缓存复用:多次访问只计算一次
- 响应式更新:依赖变化时自动标记为脏值,等待下次访问时更新
计算属性的设计实现了以下重要目标:
- 避免不必要的计算:只有真正需要值的时候才计算
- 批量更新优化:通过调度系统统一处理更新
- 防止递归死循环:通过标记机制避免自身递归
- 保持响应式链路:确保依赖变化能够正确传播
四、总结
通过以上分析,我们详细了解了 Vue 计算属性的实现原理:
- 通过 ComputedRefImpl 实现响应式依赖收集和更新
- 使用脏值检查和缓存机制提升性能
- 借助调度系统实现异步更新
在下一节中,我们将分析与计算属性密切相关的另一个重要特性 —— watch 的实现原理。watch 虽然与计算属性有些相似,但它们的使用场景和实现细节都有很大的不同。