Vue3 响应式原理
响应式原理
Vue3 的响应式系统解决的是一个很具体的问题:数据变了,用到这个数据的地方要自动重新执行。手写的话我们得在每次改数据后手动调用更新函数,数据一多就完全维护不动。
整套机制拆开就三步:拦截读写、收集依赖、触发更新。
Proxy 取代 defineProperty
Vue2 用 Object.defineProperty 给每个属性装 getter/setter,它的局限很明确:只能拦截已存在的属性。新增属性、删除属性、数组下标赋值、修改 length 都拦不到,所以 Vue2 得提供 Vue.set、Vue.delete 这类 API 打补丁,还得重写数组的 7 个变更方法。
Vue3 换成 Proxy,代理的是整个对象而不是单个属性,所有操作都能被 handler 捕获:
const obj = { a: 1 };
const proxy = new Proxy(obj, {
get(target, key, receiver) {
console.log('读取', key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log('设置', key, value);
return Reflect.set(target, key, value, receiver);
},
deleteProperty(target, key) {
console.log('删除', key);
return Reflect.deleteProperty(target, key);
},
has(target, key) {
console.log('in 判断', key);
return Reflect.has(target, key);
},
});
proxy.b = 2; // 设置 b 2,新增属性也能拦到
delete proxy.a; // 删除 a
console.log('b' in proxy); // in 判断 b
这里必须用 Reflect 而不是直接 target[key]。原因是 Reflect.get 的第三个参数 receiver 能正确传递 this:当被代理对象内部有 get fullName() { return this.first + this.last } 这种访问器时,直接返回 target[key] 会让 this 指向原始对象,于是 this.first 的读取不经过代理,依赖收集不到;用 Reflect.get(target, key, receiver) 才能让 this 指向代理对象。
副作用函数与依赖收集
拦截只是手段,核心问题是「数据变了要重新执行谁」。Vue3 把这个「谁」叫做副作用函数(effect),组件的渲染函数就是一个 effect。
要建立「数据 → effect」的关联,需要一个全局变量记录当前正在执行的 effect:
let activeEffect = null;
function effect(fn) {
const run = () => {
activeEffect = run;
fn(); // 执行 fn 时,里面读到的响应式数据会把 run 收集进去
activeEffect = null;
};
run();
return run;
}
依赖存在一个三层结构里:WeakMap<target, Map<key, Set<effect>>>。用 WeakMap 是为了让对象被回收时依赖关系自动释放,不造成内存泄漏。
const targetMap = new WeakMap();
// getter 里调用:把当前 effect 记到 target.key 名下
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);
}
// setter 里调用:把 target.key 名下所有 effect 拿出来重跑
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
// 复制一份再遍历,避免执行过程中增删导致无限循环
[...dep].forEach(fn => fn());
}
用 Set 存 effect 是为了自动去重——同一个 effect 里多次读同一个属性只会被收集一次。
拼起来的最小实现
把上面的部分组合成一个能跑的 reactive:
function reactive(target) {
return new Proxy(target, {
get(t, key, receiver) {
track(t, key);
const res = Reflect.get(t, key, receiver);
// 深层响应:取到的值是对象就继续代理(Vue3 是懒代理,读到才转换)
return typeof res === 'object' && res !== null ? reactive(res) : res;
},
set(t, key, value, receiver) {
const old = t[key];
const res = Reflect.set(t, key, value, receiver);
// 值没变就不触发,避免无意义的重新渲染
if (!Object.is(old, value)) trigger(t, key);
return res;
},
});
}
// 验证
const state = reactive({ price: 10, quantity: 2 });
let total = 0;
effect(() => {
total = state.price * state.quantity;
console.log('total:', total);
});
// total: 20
state.quantity = 5;
// total: 50,自动重新执行了
注意 getter 里的深层代理是懒执行的:只有真正读到嵌套对象时才会给它套 Proxy。Vue2 是在初始化时递归遍历整个对象装 getter/setter,对象很深时初始化开销大。这是 Vue3 性能提升的一个来源。
ref 为什么需要 .value
Proxy 代理不了原始值——new Proxy(1, {}) 会直接抛 TypeError。所以要让一个数字、字符串变成响应式的,必须包一层对象,把值放在属性上才有 get/set 可拦:
function ref(value) {
const wrapper = {
get value() {
track(wrapper, 'value');
return value;
},
set value(v) {
if (Object.is(value, v)) return;
value = v;
trigger(wrapper, 'value');
},
};
return wrapper;
}
.value 不是设计上的多余负担,而是原始值无法被代理的必然结果。模板里能直接写 {{ count }} 不写 .value,是因为编译期做了自动解包(setup 返回的 ref 会被 proxyRefs 处理)。
这也解释了两个常见的坑:解构 reactive 对象会丢响应性,因为解构出来的是原始值,脱离了 Proxy;toRefs 就是把每个属性转成一个保留了 .value 通路的 ref 来解决它。
const state = reactive({ count: 0 });
let { count } = state; // count 是普通数字 0,之后 state.count 变了它不变
const { count: c2 } = toRefs(state); // c2.value 始终跟 state.count 同步
更新是异步批量的
trigger 里如果直接同步执行渲染函数,一次事件里改五个属性就会渲染五次。Vue3 的做法是把 effect 推进一个微任务队列,同步代码跑完后统一执行一次,并且队列内去重:
const state = reactive({ a: 1 });
state.a = 2;
state.a = 3;
state.a = 4;
// DOM 只会更新一次,且是在微任务里更新
console.log(document.querySelector('#app').textContent); // 还是旧值
await nextTick();
console.log(document.querySelector('#app').textContent); // 4
这就是为什么改完数据要拿更新后的 DOM 必须等 nextTick。
几个实际的注意点
reactive 只对对象有效,传原始值会原样返回并在开发模式下警告。
reactive 返回的是代理对象,和原对象不是一个引用。reactive(obj) === obj 是 false,所以做身份比较时要统一用代理对象。对同一个对象重复调用 reactive 会返回缓存的同一个代理。
集合类型走单独的 handler。Map、Set 的读写是通过 get、set、add、delete 这些方法进行的,不是属性访问,Vue3 为它们准备了专门的 mutableCollectionHandlers 去重写这些方法。
数组的 length 有隐式依赖。arr.push(x) 会同时改下标和 length,Vue3 在 trigger 里对数组做了特殊处理,length 变小时要通知所有下标 ≥ 新 length 的依赖。
不要在 effect 里既读又写同一个属性,比如 state.count++,这会形成自我触发的无限循环。Vue3 在 trigger 时会跳过当前正在执行的 effect 来避免这种情况,但逻辑上还是应该避开这种写法。

还没有留言,来发表第一条吧~