L31 · 响应式原理:Proxy 实现
🎯 本节目标:从零手写一个 mini 响应式系统,理解 Proxy 拦截机制
📦 本节产出:支持普通对象属性的 mini-reactivity 实验 + 与 Vue 源码的对照
🔗 前置钩子:L03 的 ref/reactive 使用经验
🔗 后续钩子:L32 将在此基础上实现 cleanup、嵌套 effect、computed本阶段的稳定版源码固定到 Vue 3.5.43。下文 WeakMap → Map → Set 是教学模型,不是该版本源码摘录;它只覆盖普通可扩展对象的属性读取、写入和删除,不实现数组、集合、原型继承与属性描述符的完整规则。
1. 从最简单的案例出发
// 我们想实现:数据变了,副作用函数自动重新执行
const state = reactive({ count: 0 })
effect(() => {
console.log('count is:', state.count) // 自动在 state.count 变化时重新执行
})
state.count++ // 控制台输出:count is: 1
state.count++ // 控制台输出:count is: 2问题拆解:
- 如何知道副作用函数读取了哪些数据? → 拦截 getter
- 如何在数据变化时通知那些函数重新执行? → 拦截 setter
- 如何建立"数据-副作用"的对应关系? → 依赖收集的数据结构
2. Proxy 基础
2.1 Proxy 是什么
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 = 42.2 为什么用 Reflect
// ❌ 直接操作 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
}完整案例:
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 能拦截的常见操作
| 操作 | 陷阱 | 示例 |
|---|---|---|
| 读取属性 | get | obj.key |
| 设置属性 | set | obj.key = value |
| 删除属性 | deleteProperty | delete obj.key |
in 操作符 | has | 'key' in obj |
for...in | ownKeys | 枚举属性 |
| 函数调用 | apply | fn() |
new | construct | new 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 }各层的职责:
WeakMap<target, Map>— 以原始 target 为键,不额外阻止它被回收Map<key, Set>— 每个属性一个 setSet<effect>— 每个属性的所有订阅者,自动去重
4. 手写 mini reactive
先新建独立的 labs/mini-reactivity/,不修改商城使用的 Vue:
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// 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. 运行验证
// 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. 嵌套对象的惰性代理
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 不足以完整实现它。
// 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 节用了两种记录,处理两个不同问题:
// 原始对象 → 已创建的代理: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 |
| 尚无 ref | RefImpl 的 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 提交
git add .
git commit -m "L31: 手写 mini 响应式系统 - Proxy + track + trigger"🔬 深度专题
📖 D03 · Proxy vs Object.defineProperty — Vue 3 为什么必须放弃 IE11?
🔗 → 下一节
L32 将在此基础上升级——ReactiveEffect 类、cleanup 清理条件分支依赖、嵌套 effect 的栈处理、computed 和 ref 的底层实现。