Vue 3 响应式原理:Proxy 的魔法,Vue 是怎么“自动更新”的?
你改了 data,页面就自己变了——Vue 3 到底怎么做到的?小明用最小可运行的响应式模型讲清楚 Proxy、依赖收集、派发更新(track/trigger)和 effect 的本质,让你不仅会用 reactive/ref,还能看懂源码思路。
Vue 3 响应式原理:Proxy 的魔法,Vue 是怎么“自动更新”的?
你有没有经历过这种爽感:
- 你只写了一句
state.count++ - 然后页面上的数字就自己变了
- 你甚至没写任何 DOM 操作
如果你是第一次接触 Vue,你会觉得它像会读心术。
但如果你写了半年 Vue,还停留在“反正它会更新”的阶段——那你迟早会在这几个地方栽跟头:
- 为什么我改了对象的某个属性,页面没更新?
reactive和ref到底有什么区别?- 为什么
watch有时候会触发两次? - 组件为什么会“多渲染”?如何定位性能问题?
所以今天我们不背源码、不背概念,我们做一件更工程师的事:
亲手写一个迷你版 Vue 3 响应式核心。
写完你会明白:Vue 的“魔法”其实就是一套非常严谨的流程。
1. 响应式系统到底想解决什么问题?
先用一句人话定义响应式:
数据变了,依赖它的地方自动重新执行。
关键是“依赖它的地方”是谁?
- 在 Vue 里,通常是组件渲染函数(render)或模板编译后的更新逻辑
- 在更抽象的系统里,就是某个函数:只要它读了某个数据,那它就依赖这个数据
所以响应式系统需要做到两件事:
- 依赖收集(track):谁在读这个数据?把它记下来。
- 派发更新(trigger):数据被改了,通知所有依赖者重新跑。
Vue 3 选择用 Proxy 来做这件事,原因很朴素:
- “读”(get)和“写”(set)都能被拦截
- 不需要像 Vue 2 那样对每个属性
defineProperty - 能覆盖更多场景(新增/删除属性、数组、Map/Set 等)
2. 先造最小的舞台:effect 是谁?
在 Vue 3 里,effect 可以理解为:
一个会自动追踪依赖的函数。
你可以把它想成“自动更新的订阅者”。
我们先定义一个全局变量,用来表示“当前正在运行的 effect”。
let activeEffect = null;
function effect(fn) {
const wrapped = () => {
activeEffect = wrapped;
try {
fn();
} finally {
activeEffect = null;
}
};
wrapped();
return wrapped;
}
解释一下这段代码在干嘛:
effect(fn)会立即执行一次fn()- 执行期间,把
activeEffect设为自己 - 这样当
fn()里读取响应式数据时,我们就知道“是谁在读”(也就是依赖者是谁)
这就是依赖收集的起点。
3. 用 Proxy 拦截读取:track(依赖收集)
我们要把“数据的某个属性”映射到“一堆依赖它的 effect”。
最常见的数据结构是三层 Map:
targetMap: WeakMap<object, Map<key, Set<effect>>>
const targetMap = new WeakMap();
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
dep.add(activeEffect);
}
你可以把它想成一张“通讯录”:
state.count被谁读过?把那些 effect 记到count的 Set 里。
然后我们写一个 reactive:
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
track(obj, key);
return result;
}
});
}
到这里,我们已经能做到“谁读了我,我就记住谁”。
4. 用 Proxy 拦截修改:trigger(派发更新)
现在做第二件事:数据变了,通知依赖重新执行。
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
for (const eff of dep) {
eff();
}
}
把 reactive 的 set 补上:
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
track(obj, key);
return result;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const result = Reflect.set(obj, key, value, receiver);
// 简化:值没变就不触发
if (oldValue !== value) {
trigger(obj, key);
}
return result;
}
});
}
恭喜你:一个能工作的“响应式系统最小闭环”已经完成了。
5. 跑起来:让代码自己“更新”
let activeEffect = null;
const targetMap = new WeakMap();
function effect(fn) {
const wrapped = () => {
activeEffect = wrapped;
try {
fn();
} finally {
activeEffect = null;
}
};
wrapped();
return wrapped;
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
for (const eff of dep) eff();
}
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
track(obj, key);
return result;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const result = Reflect.set(obj, key, value, receiver);
if (oldValue !== value) trigger(obj, key);
return result;
}
});
}
const state = reactive({ count: 0 });
effect(() => {
console.log('render:', state.count);
});
state.count += 1; // render: 1
state.count += 1; // render: 2
你会看到 effect 自动重新运行。
把这段“render”想象成 Vue 的组件渲染:数据一变,它就重新跑,从而生成新的 UI。
6. 真实的 Vue 3 不止 track/trigger:它还解决了 3 个“工程级难题”
我们写的迷你版本能跑,但离 Vue 3 还有一段距离。Vue 3 还必须解决这三个工程级问题:
- 依赖清理(cleanup):条件分支变化时,旧依赖要移除,否则会“多余更新”。
- 调度与批处理(scheduler + batching):同一轮同步代码里多次 set,应该合并成一次渲染。
- 可停止与嵌套 effect(stop + effect stack):组件卸载要停掉 effect;effect 里再触发 effect 也要正确。
6.1 依赖清理:为什么 Vue 不会越用越“多订阅”?
看一个典型场景:
const state = reactive({ ok: true, text: 'hello', count: 0 });
effect(() => {
// ok 为 true 时依赖 text;为 false 时依赖 count
console.log(state.ok ? state.text : state.count);
});
state.ok = false;
state.text = 'world'; // 这次其实不应该触发 effect(因为分支变了)
如果没有 cleanup,你第一次运行时订阅了 text,后来分支切换订阅了 count,但 text 的订阅没删掉,于是你会得到“我明明不读 text 了,它还让我更新”。
Vue 3 的做法(概念级描述):
- 每次运行 effect 前,先把它从旧的依赖集合里移除
- 运行过程中重新 track,得到新的依赖集合
这就是为什么 Vue 的响应式在复杂条件渲染下仍能保持“依赖正确”。
6.2 调度与批处理:为什么连续 count++ 不会渲染 100 次?
如果 trigger 里直接执行 effect,下面代码会很惨:
for (let i = 0; i < 100; i++) {
state.count += 1;
}
真实的 Vue 3 会把更新放进队列,同一轮同步任务只渲染一次。
一个很像 Vue 思路的极简调度器(可运行心智模型):
const jobQueue = new Set();
let isFlushing = false;
function queueJob(job) {
jobQueue.add(job);
if (isFlushing) return;
isFlushing = true;
Promise.resolve().then(() => {
try {
for (const j of jobQueue) j();
} finally {
jobQueue.clear();
isFlushing = false;
}
});
}
在 Vue 里,trigger 不是“立刻跑 effect”,而是“把 effect 的 runner 扔给 scheduler”。
6.3 stop:组件卸载后,effect 必须能停
组件卸载后如果 effect 还在跑,轻则多余渲染,重则内存泄漏。
Vue 的 effect runner 会有一个“激活/停止”的状态,停止时会把自己从所有依赖集合里移除。
7. computed 为什么快?核心是 lazy + dirty(不是“缓存”两个字那么简单)
很多人把 computed 记成“带缓存的 computed”,但真正的关键是两个机制:
- lazy(惰性):没人读它,它就不算。
- dirty(脏标记):依赖变了先把自己标脏,不立刻重算;等下次有人读时再算。
极简心智模型:
依赖变了 → computed 不马上算 → 只把 dirty=true
有人读取 computed.value → 如果 dirty=true 才重新计算
这也是为什么 computed 适合“参与渲染的派生状态”:它把计算成本推迟到真正需要的时候。
8. watch 的本质:你以为它在“监听”,其实它在“重新跑 getter”
watch(source, cb) 的 source 通常是:
- 一个 getter:
() => state.count - 或一个 ref/reactive
watch 的核心思路是:
- 用 effect 跑一遍 getter,完成依赖收集
- 依赖变化时触发 scheduler
- scheduler 里重新跑 getter,拿到新旧值,再调用回调
为什么 watch 有 flush: 'pre' | 'post' | 'sync'?
- 因为它要控制回调发生在渲染的哪个阶段(这直接影响你能不能拿到最新 DOM)
这也是为什么很多“watch 触发两次/时机不对”的问题,根源在于:
- 你监听的 source 是不是稳定?
- 你是不是在 effect/渲染期间又改了依赖?
- 你选择的 flush 阶段是否符合预期?
9. Vue 3 为什么还需要 ref?reactive 不够吗?
你可能会问:既然 reactive 这么好用,为啥还要 ref?
原因一句话:
reactive适合对象;ref适合原始值(number/string/boolean)和“需要稳定引用”的场景。
在 JavaScript 里,Proxy 只能代理对象。
reactive(1) // ❌ 代理不了原始值
所以 Vue 3 用 ref 包了一层:
const count = ref(0)
count.value++
.value 这层看起来麻烦,但它解决了“原始值无法被 Proxy 代理”的问题,也让引用在解构时更可控。
10. 真实世界的坑:为什么你的页面没更新?
7.1 解构丢失响应式(最常见)
const state = reactive({ count: 0 });
const { count } = state;
effect(() => {
console.log(count); // ❌ 这里不会被 track
});
state.count++;
原因:
count在解构时已经变成普通值- 后续
effect里并没有触发Proxy.get
在 Vue 组件里,这类问题通常通过 toRefs/toRef 解决。
你可以把 toRefs 理解成:
把
reactive对象的每个属性都变成一个“带 .value 的引用”,让解构后仍能保持响应式。
10.2 “更新了但没渲染”:你可能被批处理骗了
Vue 3 的更新通常是异步批处理的(同一轮任务合并)。所以你经常会遇到:
- 我明明
state.count++了,怎么console.log里 DOM 还没变?
这不是没更新,而是更新被安排到微任务队列里了。
工程里常见的正确姿势是:
- 需要等 DOM 更新再读:用
nextTick
10.3 递归更新与“无限触发”
如果你在 effect/watch 回调里又去写自己依赖的状态,很容易变成:
触发更新 → 回调执行 → 再次写入 → 再次触发更新 → …
Vue 内部会做一些保护与调度,但你仍需要在业务逻辑上避免“自触发回路”。
10.4 依赖没被“读到”,就不会被收集
响应式系统不是“你改了就更新所有东西”,而是“你改了就更新依赖它的东西”。
如果某个分支下的属性在首次渲染时没被读取,它就不会被追踪。
11. 面试怎么讲 Vue 3 响应式?(可直接背,但你得先理解)
你可以按这个顺序回答:
- Vue 3 用
Proxy拦截对象的get/set。 - 读取时执行
track:把当前运行的effect收集到target -> key的依赖集合里。 - 修改时执行
trigger:从依赖集合里取出所有 effect 重新执行。 reactive负责对象响应式,ref解决原始值响应式与稳定引用。
面试官追问你“为什么 Vue 2 不行?”你可以补一句:
- Vue 2 主要基于
Object.defineProperty,无法天然覆盖新增/删除属性、数组索引等,需要各种补丁;Vue 3 的Proxy更完整。
总结
- Vue 3 响应式核心就两件事:track 收集依赖、trigger 派发更新。
Proxy是关键:它让“读/写”都可拦截,依赖收集更自然。reactive适合对象;ref适合原始值与稳定引用。- 常见踩坑:解构丢响应式、没读就没依赖。
小明金句收尾:
Vue 的响应式不是魔法,是一套“你读我记,你改我追”的规矩。