主题
实现 ref 与 computed
本节对标 Vue 3 源码
@vue/reactivity中的ref.ts和computed.ts
为什么需要 ref?
reactive 基于 Proxy,而 Proxy 只能代理对象。对于原始值(number / string / boolean),我们需要一种方式将它们"包装"为响应式。
这就是 ref 的由来 —— 用一个对象包裹原始值,通过 .value 访问:
ts
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1实现 ref
ts
class RefImpl<T> {
private _value: T // 存储经过转换后的值(如果是对象则为 reactive 代理)
private _rawValue: T // 存储原始值,用于新旧值比较时避免 proxy 对象的比较问题
public dep: Dep = new Set() // ref 自身的依赖集合,存储所有依赖此 ref 的 effect
public readonly __v_isRef = true // 标识符,用于 isRef() 判断
constructor(value: T) {
this._rawValue = value // 保存原始值
this._value = convert(value) // 如果 value 是对象则用 reactive 包装,否则直接使用原始值
}
// getter:访问 .value 时触发依赖收集
get value() {
trackRefValue(this) // 将当前 activeEffect 收集到此 ref 的 dep 中
return this._value
}
// setter:设置 .value 时进行新旧值比较,变化才触发更新
set value(newVal: T) {
// 使用 hasChanged 比较新旧原始值,避免不必要的触发
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal // 更新原始值
this._value = convert(newVal) // 更新转换后的值
triggerRefValue(this) // 通知所有依赖此 ref 的 effect 重新执行
}
}
}
// 转换函数:如果传入的是对象则用 reactive 包装为响应式,原始值直接返回
function convert<T>(value: T): T {
return isObject(value) ? reactive(value as any) : value
}
// 创建 ref 的工厂函数
export function ref<T>(value: T): Ref<T> {
return new RefImpl(value)
}关键设计
1. _value 和 _rawValue 为什么要分开?
_rawValue保存原始值,用于比较新旧值是否变化_value保存转换后的值(如果是对象,会被reactive包装)
比较时用原始值比较,避免两个 reactive proxy 的比较问题:
ts
if (hasChanged(newVal, this._rawValue)) {
// ...
}
function hasChanged(value: any, oldValue: any): boolean {
return !Object.is(value, oldValue)
}2. ref 传入对象时的行为
如果 ref 接收的是对象,内部会调用 reactive 转换:
ts
const obj = ref({ count: 0 })
// obj.value 是一个 reactive 对象
// obj.value.count 也是响应式的3. ref 的依赖收集
ref 不使用 targetMap 三层结构,而是直接在实例上挂载一个 dep:
ts
// ref 的依赖收集:直接将 activeEffect 添加到 ref 实例自身的 dep 中
// 与 reactive 的 track 不同,ref 不需要 targetMap 三层结构,因为只有一个 .value 属性
function trackRefValue(ref: RefImpl<any>) {
if (activeEffect) {
ref.dep.add(activeEffect) // 将当前 effect 添加到 ref 的依赖集合
activeEffect.deps.push(ref.dep) // 让 effect 反向记录,便于 cleanup 时移除
}
}
// ref 的触发更新:通知所有依赖此 ref 的 effect 重新执行
function triggerRefValue(ref: RefImpl<any>) {
// 复制一份依赖集合,避免遍历过程中集合变化导致无限循环
const effects = new Set<ReactiveEffect>()
ref.dep.forEach((effect) => {
// 排除当前正在执行的 effect,防止无限递归
if (effect !== activeEffect) {
effects.add(effect)
}
})
effects.forEach((effect) => {
if (effect.scheduler) {
effect.scheduler() // 有调度器时使用调度器(如 computed 的惰性更新)
} else {
effect.run() // 否则直接重新执行
}
})
}因为 ref 只有一个 .value 属性需要追踪,不需要 Map 来映射多个 key。
工具函数
ts
// 判断一个值是否为 ref 对象
export function isRef(value: any): value is Ref {
return !!(value && value.__v_isRef === true)
}
// 如果传入的是 ref 则自动解包返回 .value,否则直接返回原值
export function unref<T>(ref: T | Ref<T>): T {
return isRef(ref) ? ref.value : ref
}
// 创建一个代理对象,自动解包内部的 ref 属性
// 这是 Vue 3 模板中不需要写 .value 的核心实现
export function proxyRefs<T extends object>(objectWithRefs: T) {
return new Proxy(objectWithRefs, {
// get 拦截器:读取属性时自动解包 ref,使得模板中可以直接使用 count 而非 count.value
get(target, key, receiver) {
return unref(Reflect.get(target, key, receiver))
},
// set 拦截器:设置属性时,如果旧值是 ref 且新值不是 ref,则设置到 ref.value 上
set(target, key, value, receiver) {
const oldValue = (target as any)[key]
// 如果旧值是 ref 而新值不是 ref,则将新值赋给 ref.value,保持 ref 的引用不变
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
// 其他情况直接设置属性值
return Reflect.set(target, key, value, receiver)
},
})
}proxyRefs 的作用
在模板中使用 ref 时,不需要写 .value:
html
<template>
<div>{{ count }}</div> <!-- 不需要 count.value -->
</template>这是因为 Vue 3 在 setup 返回的对象上使用了 proxyRefs,自动解包 ref。
实现 computed
computed 是响应式系统中最精妙的设计之一。它有两个核心特性:
- 惰性计算 — 只在
.value被访问时才计算 - 缓存 — 依赖没有变化时,返回缓存值
ts
class ComputedRefImpl<T> {
private _value!: T // 缓存的计算结果
private _dirty = true // 脏标记:为 true 表示依赖已变化,需要重新计算
public readonly dep: Dep = new Set() // computed 自身的依赖集合,存储依赖此 computed 的 effect
public readonly effect: ReactiveEffect<T> // 内部的 effect 实例,用于追踪 getter 中的响应式依赖
public readonly __v_isRef = true // 标识符,让 computed 可以像 ref 一样被 isRef 识别
constructor(getter: () => T) {
// 创建一个 lazy 的 effect,传入 getter 作为副作用函数
// scheduler 是关键:依赖变化时不直接重新计算,而是标记 dirty 并通知下游
this.effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时,不立即重新计算
// 而是标记为 dirty,等下次访问时再计算(惰性计算的核心)
if (!this._dirty) {
this._dirty = true // 标记为需要重新计算
triggerRefValue(this) // 通知依赖此 computed 的 effect(如组件渲染 effect)
}
})
}
// getter:访问 .value 时触发
get value() {
trackRefValue(this) // 收集依赖此 computed 的 effect
// 只有 dirty 时才重新计算,否则返回缓存值(缓存机制)
if (this._dirty) {
this._dirty = false // 重置脏标记
this._value = this.effect.run()! // 执行 getter 获取最新值,同时重新收集 getter 内部的依赖
}
return this._value
}
}
// 创建 computed 的工厂函数
export function computed<T>(getter: () => T) {
return new ComputedRefImpl(getter)
}computed 的工作流程
初始状态: _dirty = true
1. 首次访问 .value
→ _dirty 为 true → 执行 getter → 缓存结果 → _dirty = false
2. 再次访问 .value
→ _dirty 为 false → 直接返回缓存
3. 依赖的响应式数据变化
→ 触发 scheduler → _dirty = true → 触发 computed 自身的依赖
→ 不执行 getter(惰性)
4. 下次访问 .value
→ _dirty 为 true → 重新执行 getter → 更新缓存为什么用 scheduler 而不是直接重新执行?
如果依赖变化就立即重新计算,但没人读取 .value,这次计算就浪费了。使用 scheduler 只是标记为 dirty,等真正需要时才计算,这就是惰性计算。
computed 也会触发依赖更新
computed 自身也是一个"响应式源",有自己的 dep:
ts
effect(() => {
// 这个 effect 依赖 sum(computed)
console.log(sum.value)
})当 sum 的依赖变化时,scheduler 中调用 triggerRefValue(this) 通知依赖 sum 的 effect 重新执行。
测试用例
ts
describe('ref', () => {
// 测试 ref 能正确存储和更新原始值
it('should hold a value', () => {
const a = ref(1)
expect(a.value).toBe(1) // 通过 .value 访问 ref 的值
a.value = 2 // 通过 .value 修改 ref 的值
expect(a.value).toBe(2)
})
// 测试 ref 的响应式能力:修改 ref.value 时,依赖它的 effect 应该重新执行
it('should be reactive', () => {
const a = ref(1)
let dummy: number
effect(() => {
dummy = a.value // effect 中访问 ref.value,触发依赖收集
})
expect(dummy!).toBe(1) // 首次执行后 dummy 为 1
a.value = 2 // 修改 ref.value 触发 triggerRefValue,effect 重新执行
expect(dummy!).toBe(2) // dummy 应该更新为 2
})
// 测试 ref 传入对象时,对象的嵌套属性也应该是响应式的
it('should make nested properties reactive', () => {
const a = ref({ count: 1 }) // ref 接收对象,内部会调用 reactive 转换
let dummy: number
effect(() => {
dummy = a.value.count // 访问嵌套属性,a.value 是 reactive 代理
})
expect(dummy!).toBe(1)
a.value.count = 2 // 修改嵌套属性触发 reactive 的 trigger
expect(dummy!).toBe(2) // effect 重新执行,dummy 更新
})
})
describe('computed', () => {
// 测试 computed 能正确返回计算值,且依赖变化后能返回最新值
it('should return updated value', () => {
const value = reactive({ foo: 1 })
const cValue = computed(() => value.foo) // computed 的 getter 依赖 value.foo
expect(cValue.value).toBe(1) // 首次访问,触发 getter 计算
value.foo = 2 // 修改依赖,computed 标记为 dirty
expect(cValue.value).toBe(2) // 再次访问,重新计算并返回最新值
})
// 测试 computed 的惰性计算和缓存特性
it('should compute lazily', () => {
const value = reactive({ foo: 1 })
const getter = vi.fn(() => value.foo) // 使用 mock 函数追踪 getter 调用次数
const cValue = computed(getter)
// 惰性计算:创建 computed 后 getter 还没有被调用
expect(getter).not.toHaveBeenCalled()
// 首次访问 .value,触发 getter 执行
expect(cValue.value).toBe(1)
expect(getter).toHaveBeenCalledTimes(1)
// 缓存机制:再次访问 .value,依赖没变化,直接返回缓存值,getter 不会重新执行
cValue.value
expect(getter).toHaveBeenCalledTimes(1)
// 依赖变化:scheduler 只标记 dirty,不会立即执行 getter
value.foo = 2
expect(getter).toHaveBeenCalledTimes(1)
// 直到再次访问 .value 时才重新计算
expect(cValue.value).toBe(2)
expect(getter).toHaveBeenCalledTimes(2) // 此时 getter 才被第二次调用
})
// 测试 computed 作为响应式源:当 computed 的值变化时,依赖它的 effect 应该重新执行
it('should trigger effect when computed value changes', () => {
const value = reactive({ foo: 1 })
const cValue = computed(() => value.foo)
let dummy: number
effect(() => {
dummy = cValue.value // effect 依赖 computed 的值
})
expect(dummy!).toBe(1)
value.foo = 2 // 修改 computed 的上游依赖 → computed 标记 dirty 并 trigger → effect 重新执行
expect(dummy!).toBe(2) // effect 重新执行时访问 cValue.value,computed 重新计算
})
})对比 React useMemo
| 维度 | Vue 3 computed | React useMemo |
|---|---|---|
| 依赖声明 | 自动收集 | 手动传入 deps 数组 |
| 缓存策略 | 只在依赖变化时重新计算 | 只在依赖变化时重新计算 |
| 返回值 | Ref 对象(.value) | 直接返回计算结果 |
| 可独立使用 | 是 | 必须在组件内 |
本节小结
- ref — 用对象包裹原始值,通过
.value实现响应式 - computed — 惰性计算 + 缓存 + 自身可作为响应式源
- proxyRefs — 自动解包 ref,模板中不需要写
.value - scheduler 模式 — 不直接执行,而是标记 dirty,延迟到访问时再计算
下一节实现 watch 和 watchEffect。