obsidian-computer
前端识记mini-vue-reading

02-reactivity-reactive-ref-computed

第 2 章 · reactivity(下):reactive、ref、computed

最后更新 2026年8月29日

第 2 章 · reactivity(下):reactive、ref、computed

第 1 章解决了"记录谁读了、通知谁重跑"。本章解决剩下的问题:怎么拦截读写(reactive/ref),以及在 effect 之上的第一个高级封装(computed)。 读完第 1 章后,本章的每个文件都会显得"薄"——这是正常的,说明你抓对了重心。


2.1 reactive.ts + baseHandlers.ts:Proxy 只是耳目

失败用例

it('reactive happy path', () => {
  const original = { foo: 1 }
  const observed = reactive(original)
  expect(observed).not.toBe(original)   // 返回的是代理,不是原对象
  expect(observed.foo).toBe(1)          // 读写行为透明
})

it('nested reactive', () => {
  const original = { nested: { foo: 1 }, array: [{ bar: 2 }] }
  const observed = reactive(original)
  expect(isReactive(observed.nested)).toBe(true)   // ★ 嵌套对象也是响应式
  expect(isReactive(observed.array[0])).toBe(true)
})

it('readonly cannot set', () => {
  const user = readonly({ age: 10 })
  user.age = 11                          // 写失败 + 警告
  expect(user.age).toBe(10)
})

实现:reactive.ts 是薄壳

// reactive.ts
import { mutableHandlers, readonlyHandlers, shallowReadonlyHandlers } from './baseHandlers'

export function reactive(raw)        { return createActiveObject(raw, mutableHandlers) }
export function readonly(raw)        { return createActiveObject(raw, readonlyHandlers) }
export function shallowReadonly(raw) { return createActiveObject(raw, shallowReadonlyHandlers) }

function createActiveObject(raw, handlers) {
  return new Proxy(raw, handlers)
}

整个文件的信息量是:reactive/readonly/shallowReadonly 的差别只在 handlers。真正的内容全在 baseHandlers.ts:

// baseHandlers.ts
import { track, trigger } from './effect'
import { reactive, readonly, ReactiveFlags } from './reactive'
import { isObject } from '../shared'

function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key) {
    // ① isReactive/isReadonly 的实现藏在这里
    if (key === ReactiveFlags.IS_REACTIVE) return !isReadonly
    if (key === ReactiveFlags.IS_READONLY) return isReadonly

    const res = Reflect.get(target, key)

    if (shallow) return res              // ② shallow:不递归,到此为止

    if (isObject(res)) {                 // ③ 惰性递归:读到对象才包
      return isReadonly ? readonly(res) : reactive(res)
    }

    if (!isReadonly) track(target, key)  // ④ readonly 不会被写 → 不必收集依赖
    return res
  }
}

function createSetter() {
  return function set(target, key, value) {
    const res = Reflect.set(target, key, value)
    trigger(target, key)                 // 先改值,再通知(顺序不能反)
    return res
  }
}

// ⑤ 顶层执行一次,缓存住,而不是每次 createActiveObject 时新建
const get = createGetter()
const set = createSetter()
const readonlyGet = createGetter(true)
const shallowReadonlyGet = createGetter(true, true)

export const mutableHandlers = { get, set }

export const readonlyHandlers = {
  get: readonlyGet,
  set(target, key) {
    console.warn(`key "${String(key)}" set 失败,因为 target 是 readonly`, target)
    return true      // 返回 true 骗过 Proxy,避免抛 TypeError
  },
}

export const shallowReadonlyHandlers = {
  ...readonlyHandlers,          // 复用 readonly 的 set(同样禁止写)
  get: shallowReadonlyGet,
}

逐段解释

① 用"读一个不存在的特殊 key"实现类型判断。 isReactive(obj) 的实现是:

export function isReactive(value) {
  return !!value[ReactiveFlags.IS_REACTIVE]   // 读这个 key
}

普通对象读 __v_isReactive 得到 undefined,!! 后为 false;代理对象的 get 拦截器截住这个 key 返回 true/false。对象本身没有这个属性,答案完全由拦截器"现场编造"。 这招同时解释了为什么参数是 isReadonly 取反——readonly 代理不是 reactive。第一次读会觉得绕,认清后你会在 Vue 源码里看到一整族这种 flag(__v_raw、__v_skip...),全是同一机制。

③ 惰性递归是 Vue 3 对 Vue 2 的关键胜利。 对比:

  • Vue 2:new Vue({data}) 时立刻深度遍历整个 data,对每个属性 defineProperty。数据有 1 万个叶子节点就要做 1 万次拦截定义,哪怕你只用其中 3 个;
  • Vue 3:只代理最外层。读到 observed.nested 这一刻,发现值是对象,才对它调 reactive()。没读过的分支永远保持原始对象,零开销。

代价是每次读嵌套对象都新建一个 Proxy?真源码里有一层 reactiveMap 缓存(同一个 raw 永远返回同一个代理),mini-vue 略去了——这是又一个"mini 版故意不做,读真源码时验证"的点。

④ 为什么 readonly 不 track:track 的唯一用途是"将来 trigger 时找到你",而 readonly 对象永远不会被合法地写、永远不会 trigger,收集了也是白白占内存。一行 if 背后是"这条数据通路的下游是否存在"的推理——读源码时看到"少做了一步",先想下游,往往不是漏了而是省了。

⑤ 工厂函数 + 顶层缓存。 四种 getter 的逻辑 90% 相同,差两个布尔值。三种写法对比:复制四份(改 bug 要改四处);每次 new Proxy 时现场 createGetter(每个响应式对象一份闭包,浪费);顶层 create 一次、模块级缓存、所有代理共享——mini-vue 选的第三种。createGetter(isReadonly, shallow) 这种用高阶函数把"变体差异"参数化的手法,会在 compiler 章的 createTransformXxx 再次出现,是源码级 DRY 的标准形态。

Reflect.get 而不是 target[key]:教学层面可以理解为等价写法;真实原因与 receiver、原型链上的 getter 里 this 指向有关(Reflect.get(target, key, receiver) 能让原型 getter 里的 this 指向代理,保证嵌套依赖也被收集)。mini-vue 没传 receiver,读 vuejs/core 时注意对比这个参数。


2.2 ref.ts:为什么已有 reactive 还要发明 ref

问题

Proxy 的能力边界是对象。let count = 0 是基本值,没有属性访问,无从拦截。而"一个响应式的数字/字符串/布尔"是刚需(计数器、开关、输入框内容)。

解法与失败用例

解法朴素:包一层对象。约定属性名叫 value:

it('ref happy path', () => {
  const a = ref(1)
  let dummy, calls = 0
  effect(() => { calls++; dummy = a.value })
  expect(dummy).toBe(1)
  a.value = 2
  expect(dummy).toBe(2)
  a.value = 2                    // ★ 设相同值
  expect(calls).toBe(2)          //    不应触发更新
})

it('ref with object', () => {
  const a = ref({ count: 1 })
  a.value.count = 2              // ★ value 是对象时,内部也要响应式
  expect(a.value.count).toBe(2)
})

实现

// ref.ts
import { trackEffects, triggerEffects, isTracking } from './effect'
import { reactive } from './reactive'
import { hasChanged, isObject } from '../shared'

class RefImpl {
  private _value
  private _rawValue        // 存未代理的原始值,用于比较
  public dep = new Set()   // ★ 只有一个 value,不需要 targetMap,自带一个 dep
  public __v_isRef = true  // isRef 的判断依据

  constructor(value) {
    this._rawValue = value
    this._value = convert(value)     // 对象 → reactive,基本值 → 原样
  }

  get value() {
    if (isTracking()) trackEffects(this.dep)   // 复用第 1 章拆出来的后半段
    return this._value
  }

  set value(newValue) {
    if (!hasChanged(newValue, this._rawValue)) return   // ★ 值没变,静默
    this._rawValue = newValue
    this._value = convert(newValue)
    triggerEffects(this.dep)         // 先赋值再触发,顺序同 Proxy set
  }
}

function convert(value) {
  return isObject(value) ? reactive(value) : value
}

export function ref(value) { return new RefImpl(value) }
export function isRef(r)   { return !!r?.__v_isRef }
export function unRef(r)   { return isRef(r) ? r.value : r }

逐段解释

ref 用 class 的 get/set 而不是 Proxy——因为 key 是写死的 value,用 accessor 属性就够了,比 Proxy 轻。同一个"拦截读写"的需求,key 固定时用 getter/setter,key 任意时才需要 Proxy。拦截手段的选择由拦截面的大小决定,这也是 Vue 2(defineProperty,key 固定于初始化时已知的集合)与 Vue 3(Proxy,任意 key 包括新增)的分界。

dep = new Set() 直接长在实例上:第 1 章 targetMap 三层查找的意义是"从 (target, key) 定位到 dep",ref 的 (target, key) 恒为 (自己, 'value'),一步到位。这正是当初把 trackEffects/triggerEffects 拆出来的原因——ref 跳过定位,直接用后半段。回看第 1 章的拆分,你现在拥有了"作者视角":拆分不是审美,是被第二个调用方逼出来的。

_rawValue 为什么要单独存:hasChanged 比较必须用原始值。若 value 是对象,_value 已被 convert 成 reactive 代理,拿代理和新来的原始对象比永远不相等,"设相同对象"就会误触发。一个字段专门为"正确比较"而存在——源码里冗余存储常常是为某个比较/回退服务的,找到那个使用点才算读懂。

proxyRefs:模板里不写 .value 的真相

setup 返回 { count: ref(0) },模板里却直接写 {{ count }}。魔法在这 15 行:

export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key) {
      return unRef(Reflect.get(target, key))       // 读:自动拆包
    },
    set(target, key, value) {
      if (isRef(target[key]) && !isRef(value)) {
        target[key].value = value                  // 写:旧值是 ref、新值不是 → 写进 .value
        return true
      }
      return Reflect.set(target, key, value)       // 否则整个替换
    },
  })
}

第 4 章你会看到组件初始化时对 setup 的返回值套了这一层。"框架的便利性魔法 = 某处多包的一层代理",在 Vue 里这个等式几乎处处成立。


2.3 computed.ts:scheduler 的第一个客户

失败用例(一字一句都要读)

it('should compute lazily', () => {
  const value = reactive({ foo: 1 })
  const getter = vi.fn(() => value.foo)
  const cValue = computed(getter)

  expect(getter).not.toHaveBeenCalled()      // ① 不读就不算(懒)
  expect(cValue.value).toBe(1)               // ② 读了才算
  cValue.value
  expect(getter).toHaveBeenCalledTimes(1)    // ③ 再读不重算(缓存)

  value.foo = 2
  expect(getter).toHaveBeenCalledTimes(1)    // ④ ★ 依赖变了,仍然不算!
  expect(cValue.value).toBe(2)               // ⑤ 直到下次读,才重算
  expect(getter).toHaveBeenCalledTimes(2)
  cValue.value
  expect(getter).toHaveBeenCalledTimes(2)    // ⑥ 又进入缓存态
})

第④行是全测试的灵魂:依赖变化时 computed 什么都不计算。它只是默默记下"我脏了"。这个行为普通 effect 做不到(普通 effect 变化即重跑),唯一的实现通道就是 scheduler。

实现(完整体只有 25 行)

// computed.ts
import { ReactiveEffect } from './effect'

class ComputedRefImpl {
  private _value
  private _dirty = true                    // ★ 唯一的状态:缓存是否失效
  private _effect: ReactiveEffect

  constructor(getter) {
    this._effect = new ReactiveEffect(getter, () => {
      // scheduler:依赖变化时执行的不是 getter,而是这两行
      if (!this._dirty) this._dirty = true
    })
  }

  get value() {
    if (this._dirty) {
      this._dirty = false
      this._value = this._effect.run()     // run 一次:算出新值 + 重新收集依赖
    }
    return this._value
  }
}

export function computed(getter) {
  return new ComputedRefImpl(getter)
}

逐段解释

为什么 getter 必须包成 ReactiveEffect,而不是普通函数? 试着用普通函数写:get value() { return getter() }——懒和缓存都能做,但 value.foo = 2 之后没有任何机制把 _dirty 置回 true,缓存永远不失效。getter 里读了 value.foo,就必须有人被收集进 foo 的 dep,这个"人"只能是一个 ReactiveEffect。所以结构是:

value.foo 的 dep ──收集──> computed 内部的 effect
                              └─ scheduler: _dirty = true   (被通知时只改标记)
cValue.value 被读 ── _dirty? ──> effect.run() 重算并缓存

状态机视角:computed 是一个两态机——clean(缓存有效,读直接返回)和 dirty(缓存失效,读触发重算)。依赖变化把它打到 dirty,读把它打回 clean。整个实现就是这个状态机的直译。把"懒计算 + 缓存失效"压缩成一个布尔 + 一个 scheduler,这 25 行是控制反转的教学标本。

mini-vue 故意没做的一层:cValue 本身也可能被 effect 依赖(组件渲染读了 computed)。依赖链变成 foo → computed → 渲染 effect,foo 变化时应该把"脏"沿链传播、并触发渲染 effect。真源码在 scheduler 里加了 triggerRefValue(this)(把依赖自己的 effect 也通知一遍)。带着这个问题去读 vuejs/core 的 computed.ts,还会遇到 Vue 3.4 重写的"版本计数"优化——那是理解现代响应式系统(脏检查 vs 推拉结合)的门票。


2.4 本章的三个可迁移结论

  1. 拦截手段跟着拦截面走:key 固定 → getter/setter(ref);key 任意 → Proxy(reactive)。
  2. 薄壳文件是好信号:reactive.ts 三行一个函数,说明复杂度被推到了 handlers——读源码先找"复杂度真正堆积的文件",壳可以秒过。
  3. 每个"魔法"对应一层代理:.value 自动拆包 = proxyRefs;this.xxx 直通 setup 返回值 = 第 4 章的 PublicInstanceProxy;模板里 {{ count }} 不用前缀 = codegen 生成的 _ctx + 上面两层。祛魅路径是统一的。

自测

  1. isReactive 读的那个 key 在对象上存在吗?答案从哪来的?
  2. 惰性递归相比 Vue 2 的全量递归省在哪?代价是什么,真源码怎么补?
  3. ref 为什么不用 Proxy?_rawValue 为谁而存在?
  4. 不看书画出 computed 的两态状态机,标出谁把它打脏、谁把它打回干净。
  5. (手撕)实现 computed 并通过 2.3 的测试;然后思考:如果渲染 effect 依赖了 computed,你的实现缺了哪一环?

反向链接(1)

评论

关系图谱

00-总览与阅读路线01-reactivity-effect与依赖收集02-reactivity-reactive-ref-computed03-runtime-挂载流程04-runtime-组件系统05-runtime-更新与diff06-compiler-三段式07-方法论-如何啃大型开源项目README

On this page