Skip to content

L31 · 响应式原理:Proxy 实现 ​

🎯 本节目标:从零手写一个 mini 响应式系统,理解 Proxy 拦截机制
📦 本节产出:支持普通对象属性的 mini-reactivity 实验 + 与 Vue 源码的对照
🔗 前置钩子:L03 的 ref/reactive 使用经验
🔗 后续钩子:L32 将在此基础上实现 cleanup、嵌套 effect、computed

本阶段的稳定版源码固定到 Vue 3.5.43。下文 WeakMap → Map → Set 是教学模型,不是该版本源码摘录;它只覆盖普通可扩展对象的属性读取、写入和删除,不实现数组、集合、原型继承与属性描述符的完整规则。

1. 从最简单的案例出发 ​

javascript
// 我们想实现:数据变了,副作用函数自动重新执行
const state = reactive({ count: 0 })

effect(() => {
  console.log('count is:', state.count)  // 自动在 state.count 变化时重新执行
})

state.count++  // 控制台输出:count is: 1
state.count++  // 控制台输出:count is: 2

问题拆解:

  1. 如何知道副作用函数读取了哪些数据? → 拦截 getter
  2. 如何在数据变化时通知那些函数重新执行? → 拦截 setter
  3. 如何建立"数据-副作用"的对应关系? → 依赖收集的数据结构

2. Proxy 基础 ​

2.1 Proxy 是什么 ​

javascript
const obj = { name: 'Vue', version: 3 }

const proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log(`读取了 ${String(key)}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置了 ${String(key)} = ${value}`)
    const result = Reflect.set(target, key, value, receiver)
    return result
  },
})

proxy.name        // 控制台:读取了 name
proxy.version = 4 // 控制台:设置了 version = 4

2.2 为什么用 Reflect ​

javascript
// ❌ 直接操作 target 会丢失 receiver(this 指向问题)
get(target, key) {
  return target[key]  // 当 key 是 getter 时,this 指向 target 而不是 proxy
}

// ✅ Reflect 正确传递 receiver
get(target, key, receiver) {
  return Reflect.get(target, key, receiver)  // 确保 getter 中的 this 指向 proxy
}

完整案例:

javascript
const obj = {
  firstName: '张',
  lastName: '三',
  get fullName() {
    return this.firstName + this.lastName  // this 应该是 proxy,才能触发 firstName 和 lastName 的 track
  }
}

const proxy = reactive(obj)
// 读取 proxy.fullName 时:
// 1. 进入 fullName 的 get trap → track('fullName')
// 2. fullName getter 中 this.firstName → 如果 this 是 proxy → 触发 firstName 的 get trap → track('firstName')
// 3. 同理 this.lastName → track('lastName')
// 如果不用 Reflect,this 指向原始 obj,firstName/lastName 的读取不会被拦截

2.3 Proxy 能拦截的常见操作 ​

操作陷阱示例
读取属性getobj.key
设置属性setobj.key = value
删除属性deletePropertydelete obj.key
in 操作符has'key' in obj
for...inownKeys枚举属性
函数调用applyfn()
newconstructnew Fn()

这不是 Proxy 全部陷阱的清单。Vue 的普通可变对象 handler 使用前五种相关陷阱;Map/Set 等集合还需要专门的方法包装,不能仅凭属性 set trap 捕获 map.set()。本课 mini 版先实现 get/set/deleteProperty。


3. 依赖收集数据结构 ​

先用三层集合表达“哪个原始对象的哪个属性,被哪些 effect 读取过”:

targetMap (WeakMap) — 不因键本身而强引用原始对象;不是强制或即时 GC
  └─ target → depsMap (Map)
       ├─ 'count' → Set { effectA, effectB }
       └─ 'name'  → Set { effectC }

各层的职责:

  1. WeakMap<target, Map> — 以原始 target 为键,不额外阻止它被回收
  2. Map<key, Set> — 每个属性一个 set
  3. Set<effect> — 每个属性的所有订阅者,自动去重

4. 手写 mini reactive ​

先新建独立的 labs/mini-reactivity/,不修改商城使用的 Vue:

bash
mkdir -p labs/mini-reactivity
cd labs/mini-reactivity
npm init -y
npm install -D typescript@5 tsx@4 @types/node@22
npm install vue@3.5.43
typescript
// labs/mini-reactivity/mini-reactivity.ts
export type EffectRunner = () => void
const targetMap = new WeakMap<object, Map<PropertyKey, Set<EffectRunner>>>()
let activeEffect: EffectRunner | undefined
const reactiveMap = new WeakMap<object, object>()
const proxies = new WeakSet<object>()
function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
  if (value === null || typeof value !== 'object') return false
  const prototype = Object.getPrototypeOf(value)
  return prototype === Object.prototype || prototype === null
}
function track(target: object, key: PropertyKey) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, depsMap = new Map())
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, dep = new Set())
  dep.add(activeEffect)
}
function trigger(target: object, key: PropertyKey) {
  const dep = targetMap.get(target)?.get(key)
  if (!dep) return
  // 用快照遍历;后续 L32 在重跑时会删除再收集依赖。
  for (const runner of new Set(dep)) {
    if (runner !== activeEffect) runner()
  }
}
export function reactive<T extends object>(target: T): T {
  if (proxies.has(target)) return target
  const existing = reactiveMap.get(target)
  if (existing) return existing as T
  if (!isPlainObject(target)) throw new TypeError('mini 版只接受普通对象')
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      const value = Reflect.get(target, key, receiver)
      return isPlainObject(value) ? reactive(value) : value
    },
    set(target, key, value, receiver) {
      const previous = Reflect.get(target, key, receiver)
      const changed = Reflect.set(target, key, value, receiver)
      if (changed && !Object.is(previous, value)) trigger(target, key)
      return changed
    },
    deleteProperty(target, key) {
      const existed = Object.prototype.hasOwnProperty.call(target, key)
      const deleted = Reflect.deleteProperty(target, key)
      if (deleted && existed) trigger(target, key)
      return deleted
    },
  })
  reactiveMap.set(target, proxy)
  proxies.add(proxy)
  return proxy
}
export function effect(fn: () => void): EffectRunner {
  let running = false
  const runner = () => {
    if (running) return
    const previous = activeEffect
    activeEffect = runner
    running = true
    try { fn() }
    finally { activeEffect = previous; running = false }
  }
  runner()
  return runner
}

重跑时也要设置 activeEffect,不能只在首次注册时收集;finally 保证异常后恢复外层上下文。此处的 running 只阻止直接重入,不处理跨多个 effect 的写入循环。分支旧依赖仍会保留,L32 再补清理和停止能力。


5. 运行验证 ​

typescript
// labs/mini-reactivity/basic.ts
import assert from 'node:assert/strict'
import { reactive, effect } from './mini-reactivity'
const raw = { count: 0, name: 'Vue', nested: { value: 1 } }
const state = reactive(raw)
let countRuns = 0
let nameRuns = 0
effect(() => { countRuns++; console.log('count:', state.count) })
effect(() => { nameRuns++; console.log('name:', state.name) })
state.count++
state.name = 'Vite'
state.count = 1 // 相同值不触发
assert.equal(countRuns, 2)
assert.equal(nameRuns, 2)
assert.equal(reactive(raw), state)
assert.equal(reactive(state), state)
assert.equal(state.nested, state.nested)

在实验目录运行 npx tsx basic.ts;首次 effect 也会输出 count/name,然后分别输出变化后的值。用 npx tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext mini-reactivity.ts basic.ts 检查类型。


6. 嵌套对象的惰性代理 ​

typescript
const state = reactive({
  user: {
    profile: {
      name: '张三',
      age: 25,
    }
  }
})

// Vue 3 采用惰性代理(lazy proxy):
// 访问 state.user 时才给 user 创建 Proxy
// 访问 state.user.profile 时才给 profile 创建 Proxy
// 好处:未访问的嵌套对象不必提前创建代理;重复读取会复用缓存

Vue 2 对被观察的对象递归转换属性;Vue 3 的普通深层 reactive 在读取时再转换嵌套值。这样可以避免一些不必要的前置工作,实际收益仍取决于访问模式。shallowReactive、原始标记对象和不可代理类型还有各自规则。


7. 数组的响应式 ​

以下使用安装的 真实 Vue 3.5.43,不是上面的 mini。Vue 处理数组时,还要维护索引与 length 的依赖关联,并包装部分方法;简单的普通对象 get/set 不足以完整实现它。

typescript
// labs/mini-reactivity/array-demo.ts
import { reactive, effect } from 'vue'
const arr = reactive([1, 2, 3])
effect(() => console.log(arr.join(', ')))
arr.push(4)      // 1, 2, 3, 4
arr[0] = 10      // 10, 2, 3, 4
arr.splice(1, 1) // 10, 3, 4
arr.length = 1  // 10

运行 npx tsx array-demo.ts。例如新增索引可能已经隐式改变原数组 length,随后对 length 的 set 看到的旧值可能等于新值,因此“只比较当前属性的新旧值”会漏掉 length 订阅者。

操作Vue 2 的常见限制Vue 3
数组索引赋值需 Vue.set / splice 等方式可触发相关订阅
设置 length不直接触发数组更新可触发 length 和受影响索引订阅
push / splice通过包装数组方法处理Proxy 与数组专门逻辑共同处理
新增/删除对象属性Vue.set / Vue.delete 等方式对应陷阱与依赖类型共同处理

不要把“Proxy 能拦截”直接等同于“已经有完整的响应式语义”。Vue 3.5.43 数组处理


8. 避免重复代理 ​

第 4 节用了两种记录,处理两个不同问题:

typescript
// 原始对象 → 已创建的代理:reactive(raw) 多次调用返回同一个对象
const reactiveMap = new WeakMap<object, object>()
// 识别传入值已经是代理:reactive(proxy) 直接返回自身
const proxies = new WeakSet<object>()

这是对已有实现的摘录,不再声明第二份变量。只有 raw → proxy 的 WeakMap 不足以阻止 reactive(reactive(raw)),因为外层传入的是 proxy,不是 raw;mini 额外用 WeakSet 识别,真实 Vue 则使用内部标记、raw 访问与缓存等规则。


9. Vue 3 实际实现与我们的差异 ​

本课 mini 模型Vue 3.5.43 的对应机制
属性对应 Set<runner>Dep / Subscriber / Link,带版本与链表关系
保存/恢复 activeEffect保存/恢复 activeSub 等跟踪上下文
旧分支依赖未清除标记与清理本次未使用的 Link
尚无 refRefImpl 的 getter/setter;对象值可再转为 reactive
同步执行 runner底层 effect 可同步;组件和默认 watcher 使用 scheduler 批处理
普通对象 get/set/delete还覆盖 has/ownKeys、数组与集合等规则
没有只读/浅层模式readonly / shallowReactive 等有不同 handler 行为

底层响应式不是所有变更都异步执行。组件更新的排队由运行时调度器衔接,L35 再讨论。固定源码可对照 reactive.ts、dep.ts、effect.ts。


10. 本节总结 ​

检查清单 ​

  • [ ] 能解释 Proxy 的 get/set/deleteProperty 拦截器
  • [ ] 理解为什么用 Reflect(正确处理 receiver/this)
  • [ ] 理解 targetMap → depsMap → deps 三层数据结构
  • [ ] 能手写 mini reactive() + effect() + track() + trigger()
  • [ ] 知道为什么用 WeakMap(GC 友好)
  • [ ] 理解惰性代理的优势(对比 Vue 2 递归遍历)
  • [ ] 理解数组操作在 Proxy 中的表现
  • [ ] 能区分原始对象缓存与已是代理的识别

Git 提交 ​

bash
git add .
git commit -m "L31: 手写 mini 响应式系统 - Proxy + track + trigger"

🔬 深度专题 ​

📖 D03 · Proxy vs Object.defineProperty — Vue 3 为什么必须放弃 IE11?

🔗 → 下一节 ​

L32 将在此基础上升级——ReactiveEffect 类、cleanup 清理条件分支依赖、嵌套 effect 的栈处理、computed 和 ref 的底层实现。