Vue 3 把响应式底层从 Object.defineProperty 换成了 Proxy。这个替换不只是换个 API,它顺带解决了一批历史遗留问题,也引入了新的边界情况。
Proxy 相比 defineProperty 强在哪
defineProperty 要拦截属性读写,就必须对每个已知属性逐个定义 getter/setter。这带来三个麻烦:
- 新增属性拦不到,必须调
Vue.set; - 删除属性拦不到,必须调
Vue.delete; - 数组只能通过重写
push/pop/splice等七个方法来间接拦截,直接arr[0] = x或改arr.length都不触发更新。
另外它必须在初始化时递归遍历整个对象,把每一层都转成 getter/setter,深层对象初始化成本高,而且这种一次性转换对”只用到一两个字段的大对象”是纯粹的浪费。
Proxy 拦截的是对象本身的操作,不是某个属性:
const proxy = new Proxy(target, {
get(target, key, receiver) { /* 读取拦截 */ },
set(target, key, value, receiver) { /* 写入拦截 */ },
deleteProperty(target, key) { /* 删除拦截 */ },
has(target, key) { /* in 操作符 */ },
ownKeys(target) { /* Object.keys / for...in */ },
});
于是 obj.newProp = 1、delete obj.a、arr[5] = x、arr.length = 0、Object.keys() 全部可拦截。而且 Proxy 是惰性的:只有真正访问到某个嵌套对象时,才在 getter 里把它包一层代理,深层数据不访问就不转换。
Proxy 的代价也不小:
- 无法被 polyfill,只支持 ES2015+ 的运行时环境;
- 代理对象与原对象不相等,
raw === reactive(raw)为false,比较身份时要用toRaw; this指向问题。通过代理访问方法时this是代理对象,若方法内部持有原对象引用,会造成”同一个逻辑两套身份”。Vue 用Reflect.get(target, key, receiver)传 receiver 来修正。
依赖收集:track 与 trigger
核心机制只有两步:读取时把当前正在运行的副作用记为依赖(track),写入时把记录的副作用重新执行(trigger)。
存储结构是这样一层层套下去的:
WeakMap<target, Map<key, Set<effect>>>
用 WeakMap 是为了让 target 被回收时依赖关系也自动释放,不会内存泄漏。
一个最小实现:
let activeEffect = null;
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);
activeEffect.deps.push(dep); // 反向记录,便于清理
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
[...dep].forEach(effect => {
if (effect !== activeEffect) effect.run(); // 防止自身递归
});
}
function effect(fn, options = {}) {
const run = () => {
cleanup(runner); // 执行前先断开旧依赖
activeEffect = runner;
try { return fn(); } finally { activeEffect = null; }
};
const runner = Object.assign(run, { deps: [], scheduler: options.scheduler });
runner();
return runner;
}
const reactive = (obj) => new Proxy(obj, {
get(t, k, r) { track(t, k); const v = Reflect.get(t, k, r);
return typeof v === 'object' && v !== null ? reactive(v) : v; },
set(t, k, v, r) { const old = t[k]; const ok = Reflect.set(t, k, v, r);
if (!Object.is(old, v)) trigger(t, k); return ok; },
deleteProperty(t, k) { const ok = Reflect.deleteProperty(t, k); trigger(t, k); return ok; },
});
几个容易漏掉的细节:
cleanup必须有。否则条件分支里a ? obj.x : obj.y在切换分支后,旧依赖仍留在集合里,导致无关更新触发渲染。Reflect.get要传 receiver,否则 getter 里的this指向原始对象,嵌套依赖收集会失效。- 值没变就不触发。
Object.is判断能避免obj.a = obj.a引发的无意义更新;注意NaN与+0/-0的处理依赖Object.is的语义。
为什么需要 effect 栈
上面的实现只有单个 activeEffect,遇到嵌套 effect 就崩了:
effect(() => {
effect(() => console.log(obj.b)); // 内层执行完 activeEffect 被置 null
console.log(obj.a); // 此时 track 拿不到 effect,依赖丢失
});
正确做法是用栈保存父子关系:
const effectStack = [];
const run = () => {
cleanup(runner);
effectStack.push(runner);
activeEffect = runner;
try { return fn(); } finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
};
组件渲染函数本身就是嵌套 effect 的典型场景:父组件 render 过程中子组件渲染,形成自然的嵌套链。没有栈,整棵组件树的依赖收集都会错乱。
调度器与批量更新
如果每次 trigger 都同步执行 effect,下面这段代码会渲染三次:
state.a++; state.b++; state.c++;
调度器解决这个问题:trigger 时不直接跑 effect,而是把它推入一个微任务队列去重执行。
const queue = new Set();
let flushing = false;
function queueJob(job) {
queue.add(job);
if (!flushing) {
flushing = true;
Promise.resolve().then(() => {
flushing = false;
queue.forEach(j => j());
queue.clear();
});
}
}
用微任务而非宏任务,是为了在当前同步代码跑完、但在浏览器渲染之前完成更新,用户不会看到中间态。nextTick 就是往这个队列后面插一个回调。
调度器还给 watch 的 flush: 'pre' | 'post' | 'sync' 提供了实现基础——组件更新前的回调与 DOM 更新后的回调,本质只是插入队列的时机不同。
ref 与 reactive 怎么选
reactive 用于对象,ref 用于任意值——包括原始类型。原始类型无法被 Proxy 代理,所以 ref 用一个带 .value 的包装对象实现:
function ref(raw) {
const r = { get value() { track(r, 'value'); return raw; },
set value(v) { if (!Object.is(v, raw)) { raw = v; trigger(r, 'value'); } } };
return r;
}
实际使用中的取舍点:
- 替换整个对象:
reactive创建的代理不能直接被替换(state = newObj会丢掉响应性),只能逐字段赋值;ref可以直接state.value = newObj。所以”整体替换”场景优先ref。 - 解构:
reactive对象解构出来的属性丢失响应性(脱离了代理),必须配合toRefs;ref在模板里会自动解包,用起来更省心。 - 函数间传递:传
ref对象本身能保持响应性链路,传reactive对象的某个字段则会断开。 reactive深层代理是惰性的,嵌套层级极深时仍有代理开销;shallowRef/shallowReactive是给大对象准备的逃生口。
computed 的懒计算与缓存
计算属性本质是一个特殊的 effect:惰性求值、带脏标记,且自己也是被依赖的对象。
function computed(getter) {
let value, dirty = true;
const runner = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) { dirty = true; trigger(obj, 'value'); } // 通知依赖 computed 的一方
},
});
const obj = {
get value() {
if (dirty) { value = runner(); dirty = false; }
track(obj, 'value'); // computed 自身也要被追踪
return value;
},
};
return obj;
}
三个关键设计:一是 lazy: true,创建时不执行,第一次读 .value 才算;二是 dirty 标记,依赖没变就返回缓存值;三是读取 computed 时也要 track,这样”依赖 computed 的组件”才会更新。
少了第三步就会出现”computed 变了但用它的组件没更新”,这是手写响应式最容易漏的地方。
数组与 Map/Set 的拦截细节
数组方法调用会同时触发多次 get 和 set。比如 arr.push(x) 会读 length、写索引、再写 length。不加保护的话一次 push 会触发多轮更新。
Vue 的做法是对数组的读方法(includes、indexOf、lastIndexOf)和改变自身的方法(push、pop、shift、unshift、splice)做特殊处理:避免追踪那些不稳定的中间访问,写入时避免重复 trigger。
对 Map/Set 用了另一套代理:拦截 get、set、add、delete、has、size,并且对 forEach、keys、values 这类迭代器方法专门绑定 ITERATE_KEY,让”遍历”本身成为一个可追踪的依赖。这样新增一个键时,依赖遍历的 effect 才会重新执行——只追踪具体 key 的话,for...of 是不会更新的。
Object.keys() 与 for...in 走 ownKeys 加 ITERATE_KEY,逻辑同理。
readonly、toRaw 与 markRaw 的边界
readonly 是 reactive 的只读包装,set 时给出警告而不写入。它的价值在跨模块边界时防止误改,但要注意它是浅层的——深层嵌套对象仍可变,需要深层只读得递归包装。
toRaw 拿到代理背后的原对象,用于比较身份或传给不接受代理的第三方库。代价是对原对象的修改不触发更新。
markRaw 标记一个对象永不代理,适合放大型第三方实例(地图对象、编辑器实例、图表实例),能避免深层代理带来的性能损耗与身份错乱。
watch 与 watchEffect 的差异
watchEffect 立即执行一次,自动收集回调里访问过的所有依赖,不需要声明源:
watchEffect(() => { document.title = state.title; });
watch 需要显式指定源,惰性执行(默认不立即跑),能拿到新旧值:
watch(() => state.user.id, async (newId, oldId) => {
cache.delete(oldId);
profile.value = await fetchProfile(newId);
}, { immediate: true, deep: false });
选择依据:
- 需要新旧值对比(如清理旧缓存、撤销操作)→
watch; - 只是”某个数据变了就做点副作用” →
watchEffect更简洁; - 依赖动态分支的场景 →
watchEffect天然支持,因为每次执行重新收集; - 监听多个源 →
watch([a, b], ([na, nb], [oa, ob]) => ...)。
一个真实的坑:watch 一个 reactive 对象默认是深层监听(隐式 deep),而 watch 一个返回对象的 getter 则不是——除非显式写 deep: true。这个不一致经常导致”为什么没触发”的困惑。深层监听要遍历整棵对象树收集依赖,对大对象是实打实的开销,能指定具体字段就别开 deep。
另一个坑是异步 watch 的竞态:连续两次请求,先发的后返回会覆盖新值,需要在回调里做过期判断:
watch(id, async (newId, oldId, onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; }); // 下一次触发前调用
const data = await fetchProfile(newId);
if (!cancelled) profile.value = data;
});
理解这套机制之后,很多”为什么没更新”的问题会直接变成”哪里断了链路”:是解构丢了代理、是替换了 reactive 引用、还是用了非响应式 API 写入。
评论(2)
有个地方想确认:这个是不是只对大流量有效?
有个错别字,「部署」写成了「布署」,不影响阅读。
请 登录 后参与评论。