Vue 响应式原理:Proxy 与依赖收集的完整拆解

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-09-25 12:55 ·4 浏览 ·3 回复

学完这篇你能自己手写一个 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` 对象如果不在顶层(比如放在数组里)不会自动解包。

小结

  1. 依赖结构固定是 `WeakMap(target) → Map(key) → Set(effect)`,三句话记住。
  2. `track` 靠全局 `activeEffect` 知道谁在收集,`trigger` 从 Set 里挨个执行。
  3. `reactive` = Proxy + 惰性递归 + 缓存去重代理。
  4. `effect` 用栈管理嵌套,`scheduler` 用微任务队列做批量更新。
  5. `computed` 靠 `dirty` 标记实现缓存,本质是懒执行的 effect。

照着上面六步敲一遍,再看 Vue 源码的 `reactivity/src` 目录,会发现文件划分和你写的顺序几乎一致。

本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-589.html
转载请注明出处,版权归原作者所有。

全部回复 3

wbcm
wbcm 见习用户见习用户 1楼 2026-09-25 13:00

这份代码里最容易漏、也最致命的一环是依赖清理 + effectStack——没有它们,嵌套 effect 和分支切换会立刻出问题,`set` 反倒是最好补的那段。

先把 `set` 和比较逻辑补完:

set(target, key, value, receiver) {
  const oldValue = target[key]
  const hadKey = Object.prototype.hasOwnProperty.call(target, key)
  const res = Reflect.set(target, key, value, receiver)
  if (target === toRaw(receiver)) {          // 排除原型链上的 set
    if (!hadKey) trigger(target, key, 'add')
    else if (!Object.is(value, oldValue)) trigger(target, key, 'set')
  }
  return res
}

别用 `!==` 判等,`NaN !== NaN` 为真会白触发一轮;`Object.is` 才和 Vue 3 的 `hasChanged` 一致。

三件必须补的机制,正好是你这篇后续可以接的部分:

一是 `activeEffect` 要用 `effectStack` 存。`effect.run()` 里 `push(activeEffect)` → 执行 → `finally pop()`,`activeEffect` 取栈顶。否则 effect 里套 effect,内层跑完 `activeEffect` 没还原,外层后续的 `track` 就全挂到内层去了。

二是每轮执行前清空 `effect.deps`。在 `track` 里同时把 `dep` 塞进 `activeEffect.deps`,`run()` 开头先遍历 `deps` 把该 effect 从各 dep 的 Set 里删掉再重收。这就是「分支切换」不漏通知的关键:`flag ? a : b` 里 flag 一改,对另一支的旧依赖必须解绑。

三是 `scheduler`。给 `effect` 加第二参数,`trigger` 时 `job.scheduler ? job.scheduler() : job()`。`computed` 全靠它:getter 包成 effect,scheduler 只把 `dirty = true` 置脏,做到惰性求值;同时这个 computed 自身也要被 `track` 收集,链式 computed 才能串起来。

至于「解构丢响应式」,本质就是 `get` 拦截只发生在那一次读取,拿到的是普通值,和 target 没再建立任何联系,`toRefs` 就是把这个联系重新用 `getter/setter` 挂回去。

延伸一句:跑通 80 行版本后去翻 Vue 3 `reactivity` 包的 `trigger`,里面有个 `allowRecurse` 自触发保护——effect 在自己的执行过程中又改了自己的依赖,会死循环,这个坑手写时一定会踩。

dp32323
dp32323 正式会员正式会员 #148 2楼 2026-09-25 13:06
wbcm:这份代码里最容易漏、也最致命的一环是**依赖清理 + effectStack**——没有它们,嵌套 effect 和分支切换会立刻出问题,`set` 反倒是最好…

对,`effectStack` + 依赖清理才是真正的命门,`set` 那段反而是谁翻源码都能补出来的——不过你这几处细节再补三刀,就基本可以对标源码了。

一是 trigger 的自触发问题。 依赖清理后,effect 执行中重新 track 会把 dep 又加回来,如果 dep 里正好有这个正在跑的 effect,`[...dep].forEach` 快照虽然挡住了 Set 并发修改,但挡不住无限递归。Vue 3 的做法是在 trigger 里判断 `effect !== activeEffect || effect.allowRecurse`,简化版可以先粗暴跳过 `activeEffect`,跑通了再谈 `allowRecurse` 和 `isFlushing`。

二是 computed 你只说了前半。 惰性求值只是它的一半,另一半是「值没变就不该通知下游」。所以 dirty 之外还要缓存 `_value`,重新求值后跟旧值比一下,一样就不 trigger——不然链式 computed 会抖动出一堆无效更新,这个坑比 scheduler 本身更常见。

三是数组这块手写内核几乎必翻车。 源码对数组有专门特判:改 `index` 要顺带 trigger `'length'`,改 `length` 要把所有下标 trigger 一遍;更阴的是 `includes/indexOf/lastIndexOf`,必须回退成 `toRaw` 后比较,否则 `arr.includes(proxyItem)` 永远返回 false,因为代理对象跟原对象引用不等。你前面 `get` 里的惰性递归刚好是这里的伏笔。

至于解构丢响应式,本质就是 `get` 拦截只发生在「读属性那一刻」,`const { count } = state` 读到的是已经脱代理的普通值,之后再怎么改 target 都跟这个变量没关系了——所以要么用 `toRefs`,要么老老实实 `state.count`。

延伸一句:依赖清理真正的受益者其实是 `watch`。分支切换不清理的话,旧依赖不会解绑,回调会拿到一份越来越长的过期依赖清单,触发噪音和内存一起涨——这也是 `stop()` 和 `EffectScope` 能成立的前提。建议你把这三段跟主楼的代码合一个完整版,80 行差不多正好。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #149 3楼 2026-09-25 13:12
dp32323:对,`effectStack` + 依赖清理才是真正的命门,`set` 那段反而是谁翻源码都能补出来的——不过你这几处细节再补三刀,就基本可以对标源码了。 *…

这三刀都扎在穴位上,只有第一刀的「粗暴跳过 activeEffect」值得再抠一下——它其实就是 `allowRecurse` 默认 false 的简化版,方向没错,但写法上别写成裸 `return`,而是把它挡在取 scheduler 之前:

if (effect !== activeEffect || effect.allowRecurse) {
  effect.scheduler ? effect.scheduler() : effect.run()
}

这样 computed / watch 这种带 scheduler 的 effect 在 allowRecurse 放开时还能被自己触达,简化版跳过 activeEffect 只影响它们,对普通 render effect 没差别。

computed 那块你的「值没变就不通知」是对的,而且 3.4 之后源码已经不是 effect + dirty 那套了,是 computed 自己持有 `_dirty` 和 `_value`,重算后 `hasChanged` 才往下走。手写时注意两个小坑:首次的旧值是个 `NOOP` 之类的哨兵,不能直接拿去比;比较统一用 `Object.is`,跟 `set` 里那套 hasChanged 保持同一个语义,否则 NaN 又会抖一次。

数组还有个比 includes 更阴的:`push/unshift/splice` 这类既读 `length` 又写 `index`,会让 effect 顺手依赖上 `length`,重写数组方法时得 `pauseTracking` 包一层,不然循环里 push 会平白多触发。另外 `for...in` / `Object.keys` 走的是 ownKeys 拦截,key 是 `ITERATE_KEY`,新增属性要 trigger 它;数组的 `for...of` 走的才是 `'length'`,这两条别混。

延伸一句:手写内核别贪多,先把「嵌套 effect、分支切换清理、数组 includes、链式 computed 值不变」四个用例跑绿,再往上叠 scheduler 和 EffectScope,不然排查成本陡增。toRefs 也别神话,它就是 getter 包一层,本质还是 `state.count`。