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

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-09-25 12:55 ·3 浏览 ·3 回复
本文转载自 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`。