Proxy 响应式原理:reactive 的实现
🎯 引言
在 Vue3 使用课程里,我们每天都在用 reactive 创建响应式对象:改一个字段,模板自动更新。这个「自动」是怎么来的?数据变了,Vue 又是怎么知道该更新哪里的?
答案就是 ES6 的 Proxy。本篇我们打开 packages/reactivity 的源码,看看 reactive 内部到底做了什么。学完之后,你能够:
- 说清楚
Proxy的get/set是如何拦截对象读写的。 - 理解
reactive源码里 WeakMap 缓存和递归代理这两个设计细节。 - 说出 Vue3 相对 Vue2 响应式方案解决了哪些局限。
- 知道
reactive自身的限制,以及为什么还需要ref。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core monorepo),主要涉及 packages/reactivity/src/reactive.ts 和 packages/reactivity/src/baseHandlers.ts。示例代码可以直接用 Node.js v22 运行。
🧱 Proxy 基础回顾
要读懂 reactive,先要认识它的基础 Proxy。这一节我们只解决一个问题:Proxy 是什么,它能干什么?Proxy 可以给对象包一层「代理」:之后外界对对象的读写都不直接作用于原对象,而是先经过代理层,我们可以在代理层里插入自己的逻辑(比如打印一句话),再把操作转交给原对象。
这种在读写中间插入逻辑的做法,源码里叫拦截;定义了各种拦截方法的对象,叫拦截器(handler)。基本用法是 new Proxy(target, handler):
const user = { name: 'noxussj' }; // target:被代理的原始对象
const proxyUser = new Proxy(user, {
// 有人读属性时,先走这里
get(target, key) {
console.log('读取了', key);
return target[key]; // 转交给原始对象,正常返回
},
// 有人改属性时,先走这里
set(target, key, value) {
console.log('修改了', key, '为', value);
target[key] = value; // 转交给原始对象,正常修改
return true; // 告诉外界:赋值成功
},
});
proxyUser.name; // 打印:读取了 name
proxyUser.name = '小明'; // 打印:修改了 name 为 小明
用 Node.js v22 运行 node demo.js,就能看到读写都被代理层拦下来了。注意我们从头到尾没有改 user 本身,只是在外面包了一层。
set 拦截器记得 return true,表示赋值成功,否则严格模式下会抛错。另外代理对象和原始对象共享同一份数据,改 proxyUser.name,user.name 也会跟着变。这个思路和 Vue2 源码课程里 Object.defineProperty 的 getter/setter 一脉相承:都是拦截读写。区别在于 defineProperty 一次只能拦截一个已存在的 key,而 Proxy 拦截的是整个对象,这正是后面一切变化的基础。
🧱 reactive 的实现
知道了 Proxy 的用法,来看 Vue3 是怎么用它实现 reactive 的。源码主要分两块:reactive.ts 里的入口函数,和 baseHandlers.ts 里的拦截器。我们一块一块看。
第一块:入口函数,负责「创建代理」。 reactive 内部调用 createReactiveObject,逻辑很简单:能代理就代理,代理过的直接复用。
// 简化自 packages/reactivity/src/reactive.ts
const reactiveMap = new WeakMap(); // 缓存:原始对象 → 代理对象
function reactive(target) {
return createReactiveObject(target, reactiveMap);
}
function createReactiveObject(target, proxyMap) {
// 第一道关:不是对象,直接原样返回
if (typeof target !== 'object' || target === null) {
return target;
}
// 第二道关:查一下缓存,代理过就直接复用
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
// 都没命中,才真正创建代理,并登记进缓存
const proxy = new Proxy(target, mutableHandlers);
proxyMap.set(target, proxy);
return proxy;
}
这里的 reactiveMap 是一个 WeakMap,作用是做缓存:每创建一个代理,就记下「这个原始对象,对应的代理是谁」。下次再传入同一个对象,直接从缓存里拿,不用重复创建。至于 WeakMap 和普通 Map 的区别(key 只能是对象、不阻止垃圾回收),「了解即可」,把它当成能按对象存东西的 Map 就行。
第二块:拦截器 mutableHandlers,响应式的核心。 上面创建代理时传入的 mutableHandlers,就是实际执行拦截逻辑的地方。它在 get 和 set 里各做了三件事:
// 简化自 packages/reactivity/src/baseHandlers.ts
const mutableHandlers = {
get(target, key, receiver) {
// 1. 正常取回结果(Reflect 的作用和 target[key] 一样)
const res = Reflect.get(target, key, receiver);
// 2. 收集依赖:登记「谁在用这个属性」
// 细节下一篇讲,这里先占位:track(target, key)
// 3. 取到的值还是对象?顺手把它也变成响应式
if (typeof res === 'object' && res !== null) {
return reactive(res);
}
return res;
},
set(target, key, value, receiver) {
// 1. 正常更新数据
const result = Reflect.set(target, key, value, receiver);
// 2. 触发更新:通知用到这个属性的地方,下一篇细讲
// trigger(target, key)
return result;
},
};
代码里还有三个新概念,逐个解释一下:
- Reflect:一组和对象操作配套的工具方法。
Reflect.get(target, key)效果等同target[key],Vue 源码统一用它,是为了在一些继承场景下行为更正确。细节「了解即可」,你把它读成「正常取值 / 正常赋值」就行。 track()(收集依赖):读取时登记「谁在用这个数据」。「依赖」指的就是用到这个数据的地方,比如一个渲染函数。目前它只是占位注释,下一篇讲effect时展开。trigger()(触发更新):修改时反过来通知这些使用者:数据变了,该更新了。同样下一篇细讲。
整个流程可以概括成一句话:读的时候收集依赖,写的时候触发更新,中间靠 Proxy 拦截。
🧱 懒代理与缓存:两个设计细节
上面的简化代码里藏着两个容易忽略的设计:嵌套对象什么时候被代理?同一个对象会不会被重复代理?这一节回答这两个问题,它们都是为性能服务的。
其一,懒代理:嵌套对象在 get 时才递归 reactive。
回忆 get 拦截器的第 3 步:取到的值如果是对象,当场才调 reactive(res) 包装。也就是说,Vue3 不会在初始化时一口气把整棵对象树都代理掉,而是访问到哪层,才代理哪层:
const state = reactive({
user: { info: { city: '杭州' } },
});
// 只读 state.user 时,info 这层还是普通对象
// 直到读 state.user.info,info 才被包装成代理
生活化一点说,这就像装修房子:不是买房当天把每个房间都装好,而是你要进哪个房间,才装哪个。对于嵌套很深但只用到一部分的大对象,这个策略能省下不少初始化开销。
reactive(res) 内部会查 WeakMap 缓存,所以同一层对象不管被读多少次,只会被包装一次。其二,缓存:已代理过的对象直接复用。
createReactiveObject 里的 WeakMap 缓存保证了「同一个原始对象,全局只有一个代理」。这带来一个实际效果:
const raw = { name: 'noxussj' };
reactive(raw) === reactive(raw); // true,两次拿到的是同一个代理
⚡ 对比 Vue2:解决了哪些局限
学完了实现,我们回头看一个更实际的问题:这套 Proxy 方案,到底比 Vue2 好在哪里?回顾一下 Vue2 响应式的两个经典局限(详见 Vue2 源码课程):对象新增属性不触发更新、数组下标赋值或改 length 不触发更新,当时得靠 this.$set 或 splice 绕开。
根源在于 defineProperty 只能拦截初始化时已存在的 key,新加的 key 身上没有拦截逻辑,Vue 自然感知不到。而 Proxy 拦截的是整个对象,任何 key 的读写都会经过 get / set,这些坑在 Vue3 下直接消失了:
<script setup>
import { reactive } from 'vue';
const user = reactive({ name: 'noxussj' });
const list = reactive(['a', 'b', 'c']);
function test() {
user.age = 18; // Vue2 里不触发更新,Vue3 正常
list[0] = 'z'; // Vue2 里不触发更新,Vue3 正常
list.length = 1; // Vue2 里不触发更新,Vue3 正常
}
</script>
新增 age 时,Proxy 的 set 照常触发;list[0] 和 list.length 对 Proxy 来说也只是普通的属性读写,一样被拦截。Vue2 里需要特殊记忆的 $set 用法,在 Vue3 里成为了历史。
另外 Vue2 为数组重写七个方法的那套复杂逻辑,Vue3 也不再需要了:Proxy 连 push 触发的下标写入和 length 变化都能感知,统一走同一套拦截机制,心智负担小了很多。
🪤 注意事项:reactive 的局限
Proxy 方案虽然强大,但 reactive 也不是万能的。这一节说说它的两条边界,帮你避开日常开发里容易踩的坑。
其一,只能代理对象。
createReactiveObject 开头就做了判断:不是对象直接原样返回。所以基本类型值(字符串、数字等)没法用 reactive:
const count = reactive(0); // 无效,0 不是对象,原样返回
其二,解构会丢失响应式。
reactive 的响应式能力来自 Proxy 的拦截,而解构是把属性值「取出来」赋给新变量。取出来的那一刻,新变量就和代理对象没有关系了:
const state = reactive({ count: 0 });
const { count } = state; // count 只是一个普通数字 0
count++; // 改的是这个新变量,视图不会更新
这两条限制正是 ref 存在的动机:基本类型想响应式怎么办?解构后想保持响应式怎么办?答案是包一层带 .value 的对象,具体实现我们在「ref 与 computed 原理」一篇会展开。
toRefs,用法细节见 Vue3 使用课程。🧾 小节总结
- Vue3 用
Proxy替代Object.defineProperty,拦截的是整个对象而不是单个 key。 reactive内部是createReactiveObject:用 WeakMap 缓存代理,保证同一个原始对象只有一个代理。- 拦截器
mutableHandlers的核心:get 里取回结果、递归reactive嵌套对象、track()收集依赖;set 里更新数据、trigger()触发更新。 - 嵌套对象是懒代理:访问到哪层才代理哪层,节省初始化开销。
- Proxy 让 Vue2 的两个经典局限(新增属性、数组下标赋值、改 length)自然消失,
$set成为历史。 reactive的局限:只能代理对象、解构会丢失响应式,这正是ref存在的动机。
❓ 知识问答
Q1:Proxy 为什么能拦截「新增属性」,defineProperty 就不行?
A:defineProperty 是在初始化时给已有的每个 key 单独安装 getter/setter,后来新增的 key 身上没有任何拦截逻辑,自然感知不到。Proxy 则不同,它拦截的是对整个对象的操作行为,set 拦截器不关心 key 是新增的还是已有的,赋值动作都会经过它。
Q2:为什么嵌套对象不一次性全部代理,而是访问时才代理?
A:主要是性能考虑。大对象可能嵌套很深,但实际只用到其中一部分,一次性全代理会做大量无用功。懒代理把开销摊到实际访问时,配合 WeakMap 缓存,同一层也只包装一次,整体更划算。
Q3:为什么用 Reflect.get / Reflect.set,而不直接写 targetkey?
A:两者在简单场景下效果相同,但 Reflect 的方法可以传入 receiver 参数,保证访问器属性(getter)里的 this 指向代理对象而不是原始对象,在原型继承等场景下行为更正确。这个细节「了解即可」,读源码时把 Reflect 当成正常取值赋值来理解,不影响主线。
Q4:Proxy 的浏览器兼容性怎么样?
A:Proxy 无法通过 polyfill 完整模拟,所以 Vue3 不支持 IE 等老浏览器,这也是 Vue3 放弃 IE 的原因之一。现代浏览器(Chrome、Firefox、Safari、Edge)都已支持,日常使用没有问题。
Q5:多次对同一个对象调 reactive,会创建多个代理吗?
A:不会。createReactiveObject 会先查 WeakMap 缓存,同一个原始对象只会创建一个代理,之后都是直接复用,reactive(raw) === reactive(raw) 结果为 true。
🧪 小练习
不查资料,补全下面的迷你 reactive:用 Proxy 实现读取时打印「读取了 xxx」,修改时打印「修改了 xxx 为 新值」。要求带上 WeakMap 缓存,同一个对象重复代理时复用。用 Node.js v22 保存为 .js 文件直接运行验证。
const proxyMap = new WeakMap();
function reactive(target) {
// 请在这里编写代码(提示:不是对象直接返回;查缓存;创建 Proxy)
}
const user = { name: 'noxussj' };
const proxyUser = reactive(user);
// 期望输出:
// 读取了 name
// 修改了 name 为 小明
console.log(proxyUser.name);
proxyUser.name = '小明';
// 验证缓存:两次代理应该是同一个对象
console.log(reactive(user) === proxyUser); // true
完成后给自己加个挑战:如果 user 里再嵌套一层对象(如 info: { city: '杭州' }),修改 proxyUser.info.city 时你的实现能打印日志吗?需要参考「懒代理」的思路改动哪里?
🎉 恭喜你已经掌握 Vue3 的 Proxy 响应式原理啦!下一篇我们接着讲 effect 与依赖收集触发,看看 track() 到底把「谁在用这个数据」登记到了哪里,trigger() 又是如何精确通知更新的。
