Featured image of post Vue 3 响应式系统:Proxy、依赖收集与 effect 调度工程实践
前端

Vue 3 响应式系统:Proxy、依赖收集与 effect 调度工程实践

Vue 3 响应式系统:Proxy、依赖收集与 effect 调度工程实践

改了数据视图却不更新、解构后“丢响应”、深度监听里 newValue === oldValue、异步里创建的 watch 内存泄漏——这些坑多半不是“Vue 坏了”,而是 依赖收集(track)与派发更新(trigger) 的契约被绕开了。本文基于 Vue 官方 Reactivity in DepthReactivity FundamentalsWatchersReactivity API 文档,并结合 MDN 的 Proxy 说明,把 Vue 3 响应式从原理讲到可复现的工程实践。

问题背景:为什么“改了变量 UI 不跟着变”

普通 JavaScript 赋值不会自动重算依赖值。官方用表格公式类比:

let A0 = 1
let A1 = 2
let A2 = A0 + A1
A0 = 2
// A2 仍然是 3

要让 A2 随依赖变化,需要三件事:

  1. 读依赖时登记订阅(track)
  2. 写依赖时通知订阅者(trigger)
  3. 把“重算逻辑”包装成 effect,在依赖变化时重跑

Vue 做不到拦截局部变量读写(语言层面没有钩子),但能拦截 对象属性 的读/写。Vue 2 主要靠 Object.defineProperty 的 getter/setter;Vue 3 对 响应式对象使用 Proxy,对 ref 使用 getter/setter。MDN 指出 Proxy 可自定义对象的基础操作(属性查找、赋值、枚举、函数调用等),这正是 Vue 运行时收集依赖的入口。

核心原理:track / trigger / effect

1. Proxy 拦截对象属性

官方伪代码把机制压缩得很清楚:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

含义:

  • 读路径调用 track:当前正在执行的 effect 订阅该属性。
  • 写路径调用 trigger:通知订阅该属性的 effect 重跑。
  • ref 把任意类型装进带 .value 的容器,用 getter/setter 完成同一套 track/trigger。

2. 依赖存储结构

effect 订阅存在全局结构(概念上)为:

WeakMap<target, Map<key, Set<effect>>>
  • target:原始对象(或 ref 对象)
  • key:属性名(ref 固定为 'value'
  • Set<effect>:依赖该属性的副作用集合

track 时若存在 activeEffect,把它加入对应 Set;trigger 时遍历该 Set 并执行。WeakMap 以对象为键,目标对象不再被引用时依赖图可被 GC,这是设计上的关键点。

3. activeEffect 与 Reactive Effect

let activeEffect

function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect() // 首次运行即可完成依赖收集
}

首次执行 effect 时,内部读到的响应式属性都会把“当前 effect”登记为订阅者;之后依赖变更再 trigger。Vue 对外暴露的 watchEffect() 就是创建这种 Reactive Effect 的 API:立即运行、自动收集依赖、依赖变化后重跑。组件渲染本身也是一个(更复杂的)reactive effect——状态与 DOM 同步的底层模型。

4. computed 是带缓存的 effect

import { ref, computed } from 'vue'

const A0 = ref(0)
const A1 = ref(1)
const A2 = computed(() => A0.value + A1.value)

computed 内部用 reactive effect 管理失效与重算:依赖不变时读缓存,依赖变更后标记脏并在下次读取时重算。适合 派生状态;需要发请求、写 DOM 等 副作用 时用 watch / watchEffect

API 选型:ref、reactive、shallow、readonly

API本质默认深度典型用途
ref(x).value 容器;对象值会再走 reactive基本类型与统一写法的首选
reactive(obj)ES Proxy, 原对象对象/数组状态(注意解构与身份)
shallowRef(x)只跟踪 .value 替换大对象、不可变快照、外部状态库
shallowReactive(obj)仅根属性响应式根级大表/性能边界(慎嵌套)
readonly(x)只读深代理,仍可被 track对外暴露不可变视图
toRaw / markRaw逃逸/标记永不代理读原对象、第三方实例、性能 opt-out

关键事实(官方 API 文档):

  • reactive() 深转换嵌套属性;嵌套 ref 在作为对象属性访问时会 自动解包;作为 数组元素或 Map 访问时 不解包,仍需 .value
  • 对同一对象多次 reactive() 返回 同一 proxy;对已有 proxy 再 reactive() 仍返回该 proxy。
  • reactive 代理与原对象用 === 比较为 false。务必始终通过 proxy 访问,不要混用原对象与代理。
  • shallowRef:只改 state.value.count 不会触发依赖;替换 state.value = {...} 才会。需要在浅层 ref 上做深变更后强制更新时,用 triggerRef(shallow)

实践一:最小可运行依赖图

下面用 Node + Vue 运行时演示 track/trigger(无需浏览器):

# 任选一种安装方式
npm init -y
npm i vue@3
// lab-reactivity.mjs
import {
  ref, reactive, computed, watch, watchEffect,
  shallowRef, triggerRef, toRaw, isReactive
} from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

const logs = []
const stop = watchEffect(() => {
  logs.push(`effect: count=${count.value}, doubled=${doubled.value}`)
})

count.value++
count.value++
stop()

console.log(logs)
// 首次 + 两次变更,共 3 条(具体实现细节可能批量,但依赖关系清晰)

// Proxy 身份
const raw = { n: 1 }
const state = reactive(raw)
console.log('proxy===raw?', state === raw)           // false
console.log('toRaw(state)===raw?', toRaw(state) === raw) // true
console.log('isReactive(state)', isReactive(state))  // true

// 浅层 ref
const bag = shallowRef({ items: [1, 2, 3] })
let shallowHits = 0
watchEffect(() => {
  shallowHits++
  // 仅读取 .value 建立依赖;内部字段变更不自动 trigger
  console.log('bag size hint', bag.value.items.length)
})
bag.value.items.push(4) // 通常不会再次触发 effect
triggerRef(bag)         // 手动 trigger
console.log('shallowHits after triggerRef', shallowHits)

运行:

node lab-reactivity.mjs

观察点:

  1. watchEffect 首次即执行,读 count / doubled 完成订阅。
  2. state === raw 为 false,但 toRaw(state) === raw 为 true。
  3. shallowRef 内部 push 不自动更新依赖,triggerRef 后才会。

实践二:组件里常见写法与正确边界

解构与“丢响应”

reactive 对象的属性赋给局部变量后,再读写局部变量 不再经过 Proxy trap,因此不再响应式。若局部变量指向对象,修改该对象内部仍可能触发(因为仍是代理对象),但“换绑”到另一个值不会:

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, user: { name: 'Ada' } })

// 丢:基本类型解构成局部变量
let { count } = state
count++ // 不触发视图

// 保:保持在 proxy 上访问
state.count++

// 保:需要解构时用 toRefs,得到一堆 ref
const { count: countRef } = toRefs(state)
countRef.value++

watch vs watchEffect

watchEffectwatch
首次执行立即默认懒执行(immediate: true 可立刻跑)
依赖声明执行时自动收集显式 source(ref / getter / reactive / 数组)
新旧值回调拿到 newValue / oldValue
深度读到即收集直接 watch reactive 对象时隐式 deep;getter 默认浅
import { ref, reactive, watch, watchEffect } from 'vue'

const id = ref('a')
const state = reactive({ nested: { n: 0 } })

// 自动收集 id
watchEffect(() => {
  console.log('load', id.value)
})

// 精确 source + 新旧值
watch(id, (next, prev) => {
  console.log('id changed', prev, '->', next)
})

// 直接 watch reactive → 深监听;深层 mutation 时 new===old
watch(state, (nv, ov) => {
  console.log('deep mutate', nv === ov) // true(对象引用未换)
})

// getter 默认只在返回值“变”时触发;要深层需 { deep: true }
watch(
  () => state.nested,
  () => console.log('nested replaced or deep'),
  { deep: true }
)

flush 时序:pre / post / sync

状态变更可能同时触发组件更新与用户 watcher。默认会 批量,避免数组连续 push 一千次就回调一千次。

  • 默认 flush: 'pre':在父组件更新之后、拥有者组件 DOM 更新之前调用。此时读自己的 DOM 仍是更新前状态。
  • flush: 'post'(或 watchPostEffect):在 DOM 更新后执行,适合量尺寸、聚焦输入框。
  • flush: 'sync'(或 watchSyncEffect):依赖一变立刻同步跑;官方提醒可能带来性能与一致性问题,仅在少数缓存失效场景使用。
watch(source, cb, { flush: 'post' })

组件渲染本身也不是同步刷 DOM:变更后会缓冲到更新周期的 next tick,保证同一轮同步代码里多次改状态只渲染一次。需要等 DOM 落地时用 nextTick()

异步创建 watcher 必须手动 stop

setup / <script setup> 同步创建的 watch / watchEffect 会绑定组件实例,卸载时自动停止。若在 setTimeoutthen 等异步回调里创建,不会自动绑定,需保存返回句柄并 stop(),否则泄漏:

// 危险
setTimeout(() => {
  watchEffect(() => { /* ... */ })
}, 0)

// 正确:尽量同步创建;或
const stop = watchEffect(() => {})
// 不再需要时
stop()

副作用清理(取消上一次请求)可用回调第三个参数 onCleanup,或 Vue 3.5+ 的 onWatcherCleanup(须在 watcher 同步执行阶段注册,不能写在 await 之后)。

外部状态 / 大列表:shallowRef 模式

对接 Immer、XState、不可变快照时,官方推荐 shallowRef 持有外部状态,状态变更时 替换 .value 触发更新,而不是深代理整棵树:

import { shallowRef } from 'vue'
import { produce } from 'immer'

export function useImmer(baseState) {
  const state = shallowRef(baseState)
  const update = (updater) => {
    state.value = produce(state.value, updater)
  }
  return [state, update]
}

实践三:排错清单(从现象到机制)

现象可能原因排查/修法
改了字段 UI 不动改的是原对象而非 proxy;或 Options API 里运行时往 this 上动态加未在 data 声明的字段始终通过 reactive/ref/组件状态访问;预声明字段
解构后失效基本类型离开 proxy traptoRefs / 保持 state.x / 用 ref
=== 意外 falseproxy 与 raw 身份不同toRaw 比对;不要缓存 raw 再与 proxy 混比
深 watch 里 new===old同一对象引用上的深层 mutation预期行为;需要 diff 时自行对比字段或换不可变更新
watch 不跑默认 lazy;或 getter 只返回同一对象引用immediate: truedeep: true 或 watch reactive 本体
大列表卡顿深代理整表shallowRef + 替换引用;markRaw 固定第三方实例
卸载后仍请求异步创建的 effect 未 stop / 未 onCleanup同步创建或手动 stop;请求取消挂到 cleanup
Dev 难查谁触发渲染依赖图不透明onRenderTracked / onRenderTriggeredcomputed/watchonTrack/onTrigger(仅开发模式)

调试钩子事件形状(开发模式)大致包含:effecttargettype(track: get|has|iterate;trigger: set|add|delete|clear)、key、新旧值等,便于在 DevTools 里下断点。

与 Signals / 编译期响应式的位置

官方把 Vue ref 与 Solid/Angular/Preact 等 signals 归为同类原语:值容器 + 访问时收集依赖 + 变更时触发副作用。Vue 目前渲染仍以 Virtual DOM 为主,并借助编译优化;运行时响应式的优点是可无构建步骤、边界情况相对少;代价是受 JS 语法限制,需要 .value 这类容器。团队曾探索 Reactivity Transform,最终未作为项目方向推进——工程上仍应以稳定的 runtime API 为准。

总结

  1. 机制Proxy(对象)与 getter/setter(ref)在读时 track、写时 trigger;订阅表概念上是 WeakMap → Map → SetactiveEffect 把“当前执行的副作用”挂到读路径上。
  2. 选型:日常优先 ref;对象图用 reactive 但警惕身份与解构;大对象/外部库用 shallowRef/markRaw;对外暴露用 readonly
  3. 副作用:派生用 computed;带新旧值与精确 source 用 watch;自动依赖用 watchEffect;注意 flush、批量更新与 nextTick
  4. 生命周期:同步创建的 watcher 随组件卸载;异步创建必须手动 stop,请求类副作用要 onCleanup
  5. 排错:先问“这次读写有没有经过 trap?依赖是不是期望的那一个 key?触发的是替换引用还是原地 mutation?”

理解 track/trigger 之后,很多“玄学不更新”会变成可定位的契约问题,而不是框架黑盒。

参考资料

  1. Vue.js — Reactivity in Depth(机制伪代码、WeakMap 依赖结构、调试钩子、外部状态集成)
  2. Vue.js — Reactivity Fundamentals(ref/reactive、深响应、Proxy 与原对象、DOM 更新时序)
  3. Vue.js — Watchers(watch/watchEffect、deep/immediate、flush、停止与泄漏)
  4. Vue.js — Reactivity API: Core(ref/computed/reactive/readonly/watch* 类型与行为)
  5. Vue.js — Reactivity API: Advanced(shallowRef、triggerRef、customRef、toRaw、markRaw、effectScope)
  6. MDN — Proxy(属性访问拦截的语言基础)
使用 Hugo 构建
主题 StackJimmy 设计