Skip to content

实现 ref 与 computed

本节对标 Vue 3 源码 @vue/reactivity 中的 ref.tscomputed.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 是响应式系统中最精妙的设计之一。它有两个核心特性:

  1. 惰性计算 — 只在 .value 被访问时才计算
  2. 缓存 — 依赖没有变化时,返回缓存值
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 computedReact useMemo
依赖声明自动收集手动传入 deps 数组
缓存策略只在依赖变化时重新计算只在依赖变化时重新计算
返回值Ref 对象(.value)直接返回计算结果
可独立使用必须在组件内

本节小结

  1. ref — 用对象包裹原始值,通过 .value 实现响应式
  2. computed — 惰性计算 + 缓存 + 自身可作为响应式源
  3. proxyRefs — 自动解包 ref,模板中不需要写 .value
  4. scheduler 模式 — 不直接执行,而是标记 dirty,延迟到访问时再计算

下一节实现 watchwatchEffect

用心学习,用代码说话 💻