创建日期:2026-09-08 | 最近更新:2026-09-08 本文所有「实验输出」均为本机对 Vue 3.5.42 的
@vue/reactivity实际运行结果(Node 24),不是背文档。原理依据:Vue 源码reactivity/src(reactive/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.defineProperty 把 data 的每个属性就地变成 getter/setter。它有三个绕不过的洞:
- 后加属性不响应(没有 setter)→ 必须
this.$set; delete不响应 → 必须$delete;- 数组按下标改、
length变化不响应 → 只能 hackpush/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/ownKeystrap)——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 到它身上——再出栈。 - trigger:
set/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 也 OK:
ref({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/set、Set.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 熟手的「判断力清单」
| 场景 | Vue2 | Vue3 |
|---|---|---|
| 给对象加属性 | this.$set | 直接赋值 |
| 删属性 | $delete | delete 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 包装、批量更新——方向完全一致。
关联
- 上一篇(上手版):Composition API 与 script setup
- 下一篇:模板编译与渲染优化——「模板每次渲染都重新读响应式」跟编译期优化如何配合
- 实验都在 /tmp/vue3-lab(
probe-reactivity.mjs),可复跑
参考
- 官方响应式进阶:cn.vuejs.org/guide/extras/reactivity-in-depth
- 源码:
@vue/reactivity(reactive.ts / ref.ts / effect.ts) - 本文实验:Vue 3.5.42 + Node 24 实测(2026-09-08)