跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本文所有「实验输出」均为本机对 Vue 3.5.42 的 @vue/reactivity 实际运行结果(Node 24),不是背文档。原理依据:Vue 源码 reactivity/srcreactive/ref/effect)与官方 响应式进阶

深 · Vue3 响应式系统拆解:从 $set 到 Proxy

一句话:Vue 2 的响应式是「对一个普通对象逐属性改写」(Object.defineProperty 加 getter/setter),所以后加的属性、数组下标天生管不到——于是有了 $set。Vue 3 改用 ES Proxy 把整个对象包一层代理,读写都过代理的 trap,新增/删除/数组任意操作都能被侦测,$set 就失业了。

这篇把它拆到底:track / trigger / effect 三条线 + ref 的 .value 真相,并给你「为什么能、为什么不能」的判断力。

1. Vue2 的极限 vs Vue3 的底气(为什么要换引擎)

Vue 2 在实例初始化时,用 Object.definePropertydata 的每个属性就地变成 getter/setter。它有三个绕不过的洞:

  1. 后加属性不响应(没有 setter)→ 必须 this.$set
  2. delete 不响应 → 必须 $delete
  3. 数组按下标改、length 变化不响应 → 只能 hack push/splice… 方法,arr[3]=x 无效。

Vue 3 直接不碰原对象——reactive(obj) 返回的是包在原对象外的一层 Proxy,对对象的所有操作都先经过 Proxy 的 trap(get/set/has/ownKeys/deleteProperty/…)。因此在 Vue 3 里:

state.newKey = 1 // get/set trap 里完成「收集+通知」,天然响应
delete state.oldKey // deleteProperty trap 同样处理
state.arr[3] = 99 // 越界下标也走 set trap(Vue 2 的盲区)

实验输出(本机 Vue 3.5.42)

A1 初始 effect 运行次数 = 1
A2 后加 state.meta.x 触发 effect 吗? runs = 2 (>1 即触发)
A3 越界下标赋值触发? runs = 3 (>1 即触发)
A4 初始 hasWatch = 0
A5 in 操作符也被追踪? hasWatch = 1 (>0 即触发)
A6 reactive 去重:同一对象代理返回同一引用 = true
A7 数组是浅代理+惰性深代理:state.list 是 Proxy? true

A5 尤其值得说:Vue3 连 'key' in obj 这种操作都能追踪(靠 has/ownKeys trap)——defineProperty 时代根本做不到,这是 Proxy 带来的一整类新能力。

2. 响应式的引擎:track / trigger / effect

Vue 3 响应式本质就是一套**「读取时登记、修改时广播」**的订阅系统:

读数据(state.x) → get trap → track(target, key) // 把「正在运行的 effect」记到 key 的依赖桶
改数据(state.x=1)→ set trap → trigger(target, key) // 把 key 的依赖桶里的 effect 全部重跑
  • track:靠一个全局的「当前正在执行的 effect」栈(activeEffect)。effect(fn) 先把自己压栈、执行 fn()——fn 里所有 get 就都会被 track 到它身上——再出栈。
  • triggerset/delete 时,找到该 key 收集到的 effects,逐个重跑。
  • 依赖是以 key 为粒度存的(WeakMap<目标对象, Map<key, Set<effect>>>),所以改 a 不会重跑只读了 b 的 effect。

同步 vs 调度

effect(fn)(裸 effect)在改动时同步立刻重跑(实验 A 里每次赋值后计数立刻 +1 就证明了这点)。而组件渲染、watchEffect 这些「重活」会传一个 scheduler:改动只把任务排进队列,同一轮里多次改动合并成一次、在微任务里统一执行——这是「改十个字段只重渲一次」的机制。

effect(fn) // 同步、每次触发都跑(底层原语)
watchEffect(() =>) // 带调度:同轮去重,默认 flush:'pre'

3. ref 的 .value 真相:为什么不能省

基本类型(number/string/…没法被 Proxy 代理(Proxy 只作用于对象)。Vue 需要一个「装着基本类型的对象」才能走 track/trigger,于是有了 ref

// ref 内部大致是一个带 getter/setter 的类
class RefImpl {
get value() { track(...); return this._value } // 读 .value → 收集
set value(v) { this._value = v; trigger(...) } // 写 .value → 广播
}

所以 .value 不是仪式,而是让「基本类型读写」也能进入订阅系统的载体。这也是为什么:

  • 模板里不用写 .value:编译/渲染时代码自动解包(模板顶层 ref 自动 .value);
  • ref 套进 reactive 对象顶层也会自动解包——实验:const n = ref(1); reactive({ n }).n 得到的是 1(number),不是 ref;
  • 放进数组/普通集合的 ref 不解包reactive([ref(1)])[0] 仍是 ref,要手动 .value
  • 对象塞进 ref 也 OKref({a:1}) 内部其实用 reactive 包了 .value

那用 reactive 还是 ref?

reactive 适合「一整坨互相引用的对象/配置」;ref 适合任何「想随时整体替换」的值。不确定就 ref——它能装对象,行为最不容易错。

4. 三个「藏着的设计」,用实验证明

① 惰性深代理(lazy deep)

Proxy 是整层代理,但 Vue 不会在 reactive() 时就把深层全包好——它是懒的:深层对象在你真正读取它的时候才被包成 Proxy,平时保持原样(省掉初始化时的深层递归)。这也是为什么 toRaw 能拿回原始对象。

P4 toRaw(reactive(x)) === x ? true
A7 …惰性深代理:state.list 是 Proxy? true // 读过才变 Proxy

② 原生 Map/Set 也是公民

Proxy 连 Map.get/setSet.has 这类方法调用都能拦(Vue 对集合做了方法代理)。

P2 Map.set 触发 effect? hits = 2 | 值 = 2
P3 原生 Set 也能 reactive, 且去重同一原始对象: true

Vue2 里对 Map/Set 的响应式支持几乎为零;Vue3 直接可用——做缓存、去重、标记集合理多了。

③ readonly / markRaw / shallow 都是「同一套 Proxy 换 trap」

API本质实验
readonly(obj)Proxy 拦截 set/delete,写入抛警告P5 isReadonly=true, isReactive=false
markRaw(obj)打标记,reactive() 见了直接返回原对象P6 reactive(markRaw(x)) === x
shallowReactive只代理第一层,深层改动不追踪P7 换整对象触发(sh=2);P8 深改不触发
shallowRef只有 .value 是响应式的(配 triggerRef适合「大对象整体替换」省开销
P7 shallowReactive 换整对象触发 sh = 2
P8 shallowReactive 深改不触发 sh = 2

5. 给 Vue2 熟手的「判断力清单」

场景Vue2Vue3
给对象加属性this.$set直接赋值
删属性$deletedelete obj.x
arr[i] = x / arr.length = n不行(要 splice)直接可用
访问 'k' in obj不能追踪可追踪(has trap)
对象解构后用{a,b}=data(本就基本类型)reactive 对象解构丢响应 → toRefs
ref 套进 reactive——顶层自动解包(别再加 .value
Map/Set几乎不支持支持
深层监听默认整棵树watch 默认不深(reactive 源除外),要 deep
想绕过代理——toRaw

最常见的新手反模式:把整个 reactive 对象当 Vue2 的 data 用,然后在模板里解构/到处传——解构即断响应。记住一句:响应性跟着「对象引用」走,不跟「值」走;想拆分就用 ref/toRefs,别用解构。

6. 想再深一层:自己造一个迷你响应式

把上面三条线压缩成 ~20 行就能演示原理(示意,非 Vue 源码):

let activeEffect
const bucket = new WeakMap() // target -> (key -> effects)

function track(target, key) {
if (!activeEffect) return
let deps = bucket.get(target) ?? new Map()
let set = deps.get(key) ?? new Set(); set.add(activeEffect); deps.set(key, set)
bucket.set(target, deps)
}
function trigger(target, key) {
bucket.get(target)?.get(key)?.forEach((fn) => fn())
}
function effect(fn) { activeEffect = fn; fn(); activeEffect = undefined }

function reactive(obj) {
return new Proxy(obj, {
get(t, k, r) { track(t, k); return Reflect.get(t, k, r) },
set(t, k, v, r) { const ok = Reflect.set(t, k, v, r); trigger(t, k); return ok },
})
}

跑一下就会看到:track)登记的是「当时的执行者」,trigger)把登记的全部重放。Vue 的正式实现在此基础上加了:调度、去重、集合代理、ref 包装、批量更新——方向完全一致。

关联

参考