⭐ 推荐:社区规则条款 V1.0

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

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

通过拆解 Vue 3 用 Proxy 代理整个对象、以 WeakMap→Map→Set 三层结构存储 target、key 到 effect 的依赖关系,并用 track/trigger 完成依赖收集与触发,说明新增删除属性可被感知、依赖具体存在哪里以及解构为何会丢失响应式,最终可手写出约 80 行包含 reactive、effect、computed 的响应式内核。

学完这篇你能自己手写一个 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轻量论坛系统,原文地址:https://www.leleweb.cn/thread-589.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 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。