Vue 3 的响应性基础是理解组合式 API 与工程化状态管理的前提。它不再像 Vue 2 那样遍历对象属性并逐个定义 getter 和 setter,而是直接使用 ES6 的 Proxy 对象对整个目标进行代理。这样带来的好处是,新增属性、删除属性、修改数组索引等原本难以拦截的操作,现在都能被统一捕获。下面从 Proxy 与 Reflect 的配合开始,逐步拆解这套机制。

响应性基础的核心:Proxy 与 Reflect
Vue 2 的响应式实现存在一个明显短板:它需要提前遍历对象的每个属性,通过 Object.defineProperty 设置 getter 和 setter。这意味着如果你直接给一个响应式对象新增属性,Vue 2 无法自动侦测到这次变化,必须借助 Vue.set 或 this.$set 手动处理。数组的索引赋值和长度修改也存在类似问题。Vue 3 改用 Proxy 之后,这一层限制被彻底消除。
Proxy 的核心能力是拦截对象的基本操作。无论是读取属性、设置属性、删除属性,还是查询属性描述符,都能在代理层被捕获。Vue 3 的 reactive 函数本质上就是返回一个 Proxy 实例,在 get 拦截函数中收集依赖,在 set 拦截函数中触发更新。为了让拦截行为更加稳定,Vue 3 同时使用了 Reflect API。Reflect.get 和 Reflect.set 的返回值能够准确反映操作是否成功,而且在涉及原型链和访问器属性时,Reflect 的行为比直接操作 target 更符合预期。
下面是一个极简的 reactive 实现骨架,它展示了 Proxy 与 Reflect 是如何配合工作的。
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key);
return result;
}
});
}
这段代码中的 track 和 trigger 还没有实现,但它们分别负责依赖收集和触发更新。可以看到,代理本身没有修改原始对象的任何属性,所有操作都通过反射转发回原对象。这样做既能感知变化,又不会破坏对象原有的行为。对于数组而言,通过 Proxy 拦截索引赋值和 length 变化也自然可行,不再需要像 Vue 2 那样重写数组方法。
依赖收集与触发更新:track 与 trigger
只实现代理还不够,响应性系统必须知道谁在读取数据,以及数据变化后应该通知谁。Vue 3 使用 effect 函数来包裹副作用逻辑,例如渲染函数、计算属性、侦听器回调等。当 effect 执行时,会把自己设为当前活跃的依赖,随后任何响应式属性被读取,都会把当前 effect 记录到对应属性的依赖集合中。这个过程称为依赖收集,通常由 track 完成。
数据变化时,trigger 会找到这个属性对应的依赖集合,并重新执行其中的 effect。Vue 3 内部使用一个 WeakMap 作为全局容器,结构是 targetMap 保存每个响应式对象对应的 depsMap,depsMap 再保存属性名到依赖集合的映射。依赖集合使用 Set,避免同一个 effect 被重复收集。下面的代码演示了这个简化版本的实现。
let activeEffect;
const targetMap = new WeakMap();
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}
在这个简化模型里,effect 会立即执行传入的函数。执行过程中访问到的响应式属性会调用 track,把当前 effect 收集起来。当属性通过 set 被修改时,trigger 从 targetMap 中取出对应依赖集合,并重新运行这些 effect。真实的 Vue 3 实现要比这复杂得多,它还处理了嵌套 effect、依赖清理、调度器、避免重复触发等问题,但核心思路完全一致。
理解 track 和 trigger 之后,就能解释很多看起来奇怪的行为。例如,为什么在 watch 或 computed 里访问一个响应式属性,之后属性变化时回调会重新执行;为什么在 setup 里创建一个局部变量并修改它,模板不会更新。因为只有在 effect 上下文中访问响应式数据,依赖收集才会发生;脱离了 effect,普通的函数或全局代码不会自动建立响应关系。
常见误区与保持响应性的正确姿势
第一个容易踩坑的地方是解构 reactive 对象。很多人习惯把 state 的字段直接解构出来使用,例如 const { count } = state。这样 count 拿到的是一个普通数值,后续修改 count 不会触发任何更新。原因是响应性建立在代理对象上,通过属性读取返回的是原始值,原始值本身不携带响应能力。正确的做法是使用 toRefs 把 reactive 对象的每个属性转成 ref,这样解构出来的仍然是 ref 引用,修改时通过 .value 才能保持响应。
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// 错误:解构后 count 只是普通数值
const { count } = state;
count++;
// 正确:使用 toRefs 保留响应性
const { count: countRef, name } = toRefs(state);
countRef.value++;
第二个常见误区是整体替换 reactive 对象。如果你在组件里写了 let state = reactive({ count: 0 }),之后又执行 state = reactive({ count: 1 }),这并不会修改原来的响应式对象,而是让 state 变量指向了一个新的代理。模板里如果仍然引用旧对象,就不会感知到这次替换。正确的做法是修改现有对象的属性,例如 state.count = 1;如果需要整体替换,可以把 state 声明为 ref,通过 state.value 来持有对象并替换。
第三个容易混淆的点是 ref 在模板中的自动解包行为。在 setup 中访问 ref 必须写 .value,但在模板里 Vue 会自动解包,直接写变量名即可。如果 ref 放在 reactive 对象的属性里,模板中也会自动解包,但在 JavaScript 代码里通过该对象访问时,得到的依旧是 ref 对象。这种差异会让新手感到困惑。解决之道是明确区分使用场景:模板中享受自动解包,逻辑代码中始终用 .value 访问,除非你刻意使用 computed 或 watch 的自动解包能力。
第四个误区涉及数组和 Map、Set 等集合类型。虽然 Proxy 可以拦截数组索引的修改,但直接修改数组长度或者通过下标给数组赋值,在 Vue 3 中已经可以被侦测到,这比 Vue 2 进步明显。不过对于 Map 和 Set,Vue 3 也提供了响应式支持,但推荐使用 reactive 包装后的实例,并调用对应的 API 来操作数据。如果直接使用原始 Map 的实例,修改内部数据不会自动触发更新。开发时优先使用响应式容器,而不是把普通集合当作响应式对象来用。
理解响应性基础之后,再回头看组件的渲染更新、computed 缓存、watch 侦听等高级特性,很多行为都能找到底层依据。遇到响应性丢失时,不妨先检查数据是否通过解构变成了普通值,再看是否替换了整体引用,最后确认访问方式是否处于 effect 上下文之内。把这三个检查点记牢,大部分响应性相关的隐性错误都可以提前规避。