Vue 不是监听数据变化,它在你读取时就记了一笔账

Vue 的响应式常被误解成框架在后台盯着数据,一变就刷新页面。真实顺序相反:组件渲染时每读一个响应式属性,Vue 就把当前副作用登记进一张三级依赖表;写入时只通知表里记过的副作用,再由调度器合并到微任务统一刷新。Vue 2 用 defineProperty 逐属性埋点,Vue 3 换成 Proxy 做懒代理,能力与成本都变了。本文从 track/trigger 讲清收集时机、computed 缓存与 nextTick 批处理,并拆解解构丢响应式、直接改原对象等常见失分点。读完你能判断一次更新为何发生,也能定位它为何没有发生。

是什么

Vue 的响应式系统,常被说成“数据变了,页面自动更新”,但这句话把因果说反了。它真正的做法不是等数据变了再去找谁在用,而是在组件渲染、computed 求值、watch 回调执行时,把“谁读了哪个属性”登记下来;等到写入发生,再按登记簿精确通知。换句话说,先有读取,后有追踪;没有读过,就不会被通知。

一句话直觉:它像图书馆的借阅登记——你每次翻哪本书,管理员记下你的名字;书被修订时,只通知借过它的人,而不是全馆广播。

典型场景包括模板里的 {{ user.name }}、computed 的派生值、watchEffect 里的请求参数,以及 Pinia 的 getter。它们看起来是不同 API,底层跑的都是同一套 track/trigger(收集/触发)机制。面试问“Vue 响应式原理”,真正想听的是三件事:依赖在什么时机收集、收集的粒度有多细、为什么有些修改收不到通知。

核心原理

Vue 2:初始化时逐属性埋点

Vue 2 用 Object.defineProperty 把 data 里的每个属性改写成 getter/setter,而且是在初始化阶段递归遍历整棵对象树。每个属性闭包里挂一个 Dep,本质是一个订阅者集合。渲染 watcher 求值时,把自己设为全局的 Dep.target;getter 执行 dep.depend() 把当前 watcher 收进集合;setter 执行 dep.notify() 逐个通知。

这像装修时给每个抽屉装一个报警器:抽屉被拉开就响。代价是装修阶段必须把所有抽屉都装一遍,对象越深,初始化越慢;更要命的是,只有“事先装过”的抽屉才有报警器。于是新增属性、删除属性、用下标改数组都绕过了 setter——这就是 Vue.set、Vue.delete 和那七个被改写的数组方法存在的原因。Map、Set 则完全不在覆盖范围内。

Vue 3:整个对象套一层 Proxy,访问到才代理

Vue 3 的 reactive() 返回一个 Proxy,拦截 get、set、has、deleteProperty、ownKeys 等操作。它不再在初始化时递归,而是懒代理:get 里发现取到的值还是普通对象,才用 reactive() 包一层再返回。没被访问的深层分支,连代理都不会创建。

依赖关系存成三级结构:WeakMap<目标对象, Map<属性名, Set<副作用>>>。最外层用 WeakMap,目标对象没人引用时可以连同依赖一起被回收,不用手动清理。全局变量 activeEffect 指向当前正在执行的副作用;真实实现里还有一个 effectStack,用来处理 effect 嵌套。

  • 读属性:get 陷阱调用 track(target, key),把 activeEffect 加进对应的 Set。
  • 写属性:set 陷阱先用 Object.is 比较新旧值,没变就直接返回;变了则 Reflect.set 写入并调用 trigger(target, key)。
  • 触发时:按依赖集合派发通知;组件渲染 effect 不直接执行,而是交给调度器,避免同步重跑和重复排队。

代理不是每次访问都新建:reactive() 内部用 reactiveMap 缓存“原对象到代理”的映射,所以 state.nested === state.nested 成立;但代理和原对象永远不相等。数组的 length 与遍历键(ITERATE_KEY)是特殊依赖:push 同时改索引和长度,新增或删除属性还要额外通知遍历依赖,否则 v-for 会漏掉变化。

一个能跑的最小实现如下,省略了依赖清理、调度器和 computed,但骨架与 Vue 一致:

let activeEffect = null
const targetMap = new WeakMap()
function track(t, k) {
  if (!activeEffect) return
  let deps = targetMap.get(t) || targetMap.set(t, new Map()).get(t)
  let dep = deps.get(k) || deps.set(k, new Set()).get(k)
  dep.add(activeEffect)
}
function trigger(t, k) {
  const dep = targetMap.get(t)?.get(k)
  if (dep) [...dep].forEach(fn => fn())
}
const reactive = obj => new Proxy(obj, {
  get: (t, k, r) => (track(t, k), Reflect.get(t, k, r)),
  set: (t, k, v, r) => (Reflect.set(t, k, v, r), trigger(t, k), true)
})
const effect = fn => { activeEffect = fn; fn(); activeEffect = null }
const state = reactive({ n: 0 })
effect(() => console.log('n =', state.n))   // n = 0
state.n++                                  // n = 1

这段代码里,effect 首次执行时读到 state.n,track 就把这个函数登记到 n 的集合;state.n++ 触发 set,trigger 再把它跑一遍。示例在派发前复制了一份集合,避免边遍历边改动;框架内部更多依靠调度器与版本标记处理这种重入。此外,每次重跑前还要清掉旧依赖,否则条件分支切换后会残留对旧属性的订阅;Vue 2 的 watcher 也有 cleanupDeps,这不是 Vue 3 独有的设计。

为什么用 Reflect 而不是直接 target[key]?因为 Reflect 能带上 receiver,让继承来的访问器、原型链上的 getter 拿到正确的 this,并且 set 会返回布尔值,失败时不会静默。

调度:写入是同步的,DOM 更新是异步的

trigger 并不会立刻重渲染组件。组件渲染 effect 被放进调度队列,queueJob 按 effect id 去重,随后在 Promise.then 的微任务里统一 flush。所以连续三次赋值只产生一次组件更新,nextTick 返回的正是这次 flush 的 promise。“数据变了 DOM 马上变”是错觉,你读到的往往还是旧 DOM。

computed 是懒执行的 effect,带一个 dirty 标记:读取 .value 时,只有脏了才重新计算;依赖变化时先把标记置脏,再通知自己的订阅者。Vue 3.4 起,如果重算结果和上次相同,下游不会被继续触发,这也是它减少无效渲染的关键。ref 则是手写的存取器:get value 里 track,set value 里 trigger;当值是对象时会转成 reactive,所以 ref({}) 仍是深响应式。它在 reactive 对象的顶层会自动解包,但放进数组或 Map 就不会。

常见考点与误区

  • “依赖收集发生在定义 data 的时候”:错。定义阶段只是准备好拦截器,真正登记发生在 effect 执行、实际读到属性的那一刻。模板没渲染到的字段、v-if 另一分支里的字段,都不会进依赖表。
  • “改完数据 DOM 立即更新”:错。写入同步改的是状态,组件更新被合并进微任务;要在更新后测量 DOM,必须 await nextTick()。这也解释了为什么在一次事件里连改三个值只触发一次渲染。
  • “reactive 对象解构出来仍然响应式”:错。const { count } = state 只是把当前数值复制给一个普通变量,后续模板读它不经过 Proxy,自然不 track。要保留连接得用 toRefs,或者始终通过 state.count 访问。
  • “Vue 3 换成 Proxy 就一定比 Vue 2 快”:不严谨。Proxy 每次读写都多一层陷阱调用;它的收益主要是懒代理、能拦截新增/删除、支持 Map/Set,以及不用递归初始化。浅层小对象上两者差距很小,而且 Proxy 无法被 polyfill,这是 Vue 3 不支持 IE11 的根因之一。
  • “Vue 2 的数组完全不响应”:错。push、pop、shift、unshift、splice、sort、reverse 这七个方法被改写,会手动 notify;真正拦不住的是 arr[0] = x 和 arr.length = 0。
  • “computed 每次访问都会重算”:错。它有缓存,只在依赖变脏时重算;反过来说,在 computed 里发请求、改状态属于副作用,应该放进 watch 或事件处理里。
  • “watchEffect 和 watch 差不多”:差别不小。watchEffect 立即执行一次,自动收集执行过程中同步访问到的依赖,await 之后的读取不会被收集;watch 要显式给源,默认懒执行,还能拿到新旧值。
  • “套了 Proxy 就什么都能监听到”:toRaw() 拿回原对象后直接改,或者把原对象另存一份在别处修改,都绕过了陷阱,不会触发更新。

实践建议

  • 大对象、图表实例、地图 SDK 这类“不需要深层观测”的数据,用 shallowRef 配 triggerRef,或者 markRaw 标记。整对象替换触发更新,比给每个内部字段套代理便宜得多。
  • 从 reactive 对象往外传值时,别直接解构。组合式函数返回 toRefs(state),或者返回一个 getter;跨组件传对象则保持引用,不要展开成新对象。
  • watch 尽量给明确的 getter 源,少用 deep: true 扫大对象;需要在 DOM 更新后读取布局,用 flush: 'post' 或 await nextTick(),不要靠 setTimeout 猜时间。
  • 手动创建的 effect 要收进 effectScope,组件卸载时 stop()。在 setup 之外裸跑 effect 又没有停止句柄,很容易在路由切换后留下泄漏的订阅。
  • 排查“为什么没更新”时,先用 onTrack/onTrigger 调试钩子确认属性到底有没有被读到:没 track 就不会 trigger。Vue 2 项目里新增字段用 $set,删字段用 $delete,数组改下标用 splice。
  • 一次事件里需要连续改多个字段时,放心批量写,让调度器合并;但别在两次写之间读 DOM 期望新值,那是拿同步思维用异步渲染。

一句话总结:Vue 的响应式不是盯着数据,而是在读取时登记、写入时按图索骥,再用微任务把更新合并成一次。

相关阅读

← 返回文章志