学完这篇你能自己手写一个 80 行的 Vue 3 响应式内核,看懂 `reactive`/`effect`/`computed` 之间到底谁在通知谁。
很多人背得下「Vue 3 用 Proxy 替代了 Object.defineProperty」,却说不清依赖到底存在哪个变量里、为什么解构会丢响应式。下面从零拆一遍,代码可以直接跑在浏览器控制台。
第一步:先搞清 Proxy 解决了什么
Vue 2 用 `Object.defineProperty` 递归劫持每个属性,所以有三个死穴:新增/删除属性不触发、数组下标修改不触发、初始化时全量递归开销大。
Proxy 代理的是整个对象,`get`/`set`/`deleteProperty`/`has`/`ownKeys` 都能拦截,新增删除天然可感知,而且是访问到哪一层才代理哪一层(惰性)。
第二步:搭依赖存储的三层结构
响应式的核心数据结构是一张「谁在哪个对象的哪个属性上依赖了我」的表,Vue 3 用的是 `WeakMap → Map → Set`:
const targetMap = new WeakMap() // target -> Map
// Map: key -> Set<effect>
let activeEffect = null
function track(target, key) {
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, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) [...dep].forEach(fn => fn())
}
最外层用 `WeakMap` 是为了 target 被回收时依赖表自动消失,不造成内存泄漏——这也是面试常问的一点。
第三步:reactive 只做两件事——代理 + 收集
const proxyCache = new WeakMap()
function reactive(raw) {
if (proxyCache.has(raw)) return proxyCache.get(raw) // 防止重复代理
const proxy = new Proxy(raw, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key)
return typeof res === 'object' && res !== null ? reactive(res) : res
},
set(target, key, value, receiver) {
const old = target[key]
const ok = Reflect.set(target, key, value, receiver)
if (old !== value) trigger(target, key)
return ok
},
deleteProperty(target, key) {
const ok = Reflect.deleteProperty(target, key)
trigger(target, key)
return ok
}
})
proxyCache.set(raw, proxy)
return proxy
}
源码位置:`packages/reactivity/src/reactive.ts` 与 `baseHandlers.ts`,`mutableHandlers` 就是这段逻辑的完整版。
注意:返回前必须判断 `old !== value`,否则 `obj.a = obj.a` 这种自赋值也会触发更新,无限循环就从这里来。
注意:深层对象是在 `get` 里惰性递归代理的,所以 `reactive({a:{b:1}})` 的 `a` 一开始并不是 proxy,直到你访问它。
第四步:effect 记录「谁在跑」
const effectStack = []
let activeEffect = null
function effect(fn) {
const runner = () => {
if (effectStack.includes(runner)) return
effectStack.push(runner)
activeEffect = runner
try { return fn() } finally {
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] || null
}
}
runner()
return runner
}
`activeEffect` 就是「当前正在执行的副作用」,`track` 把它塞进 dep 里,`trigger` 再把它拿出来执行。
注意:用栈而不是单个变量,是为了支持 effect 嵌套(父 effect 里跑子 effect)。子执行完必须还原成父,否则依赖会被记错人。
第五步:加调度器,避免一次改三行就渲染三次
const queue = new Set()
let flushing = false
function queueJob(job) {
queue.add(job)
if (!flushing) {
flushing = true
Promise.resolve().then(() => {
queue.forEach(j => j())
queue.clear()
flushing = false
})
}
}
`trigger` 里不再直接 `fn()`,而是 `queueJob(fn)`。用 `Set` 去重、用微任务批量执行,这就是 `nextTick` 的由来:同一轮同步代码里改多个值,只渲染一次。
源码位置:`packages/runtime-core/src/scheduler.ts`。
第六步:ref 和 computed 凭什么不一样
`ref` 只是把值包成 `{ value }` 再走 `reactive`,所以模板里要写 `.value`。
`computed` 是带懒执行 + 脏标记的 effect:
function computed(getter) {
let value, dirty = true
const runner = effect(getter, {
lazy: true,
scheduler: () => { dirty = true; trigger(obj, 'value') }
})
const obj = {
get value() {
if (dirty) { value = runner(); dirty = false } // 缓存生效点
track(obj, 'value')
return value
}
}
return obj
}
依赖没变时 `dirty` 为 false,直接返回缓存值,这就是 computed 比方法调用省性能的原因。
常见坑
- `const { a } = reactive(obj)` 解构会丢响应式,因为取出来的是普通值,不是访问 proxy 的 `get`。要用 `toRefs`。
- `reactive` 只接受对象,传基本类型无效,得用 `ref`。
- `shallowRef`/`shallowReactive` 只代理第一层,大数组场景用它省开销。
- 模板里访问 `ref` 对象如果不在顶层(比如放在数组里)不会自动解包。
小结
- 依赖结构固定是 `WeakMap(target) → Map(key) → Set(effect)`,三句话记住。
- `track` 靠全局 `activeEffect` 知道谁在收集,`trigger` 从 Set 里挨个执行。
- `reactive` = Proxy + 惰性递归 + 缓存去重代理。
- `effect` 用栈管理嵌套,`scheduler` 用微任务队列做批量更新。
- `computed` 靠 `dirty` 标记实现缓存,本质是懒执行的 effect。
照着上面六步敲一遍,再看 Vue 源码的 `reactivity/src` 目录,会发现文件划分和你写的顺序几乎一致。