Vue3 源码简介

Proxy 响应式原理:reactive 的实现

掌握 Vue3 如何用 Proxy 实现 reactive,理解拦截读写、懒代理与缓存的设计,以及它解决了 Vue2 的哪些局限。

🎯 引言

Vue3 使用课程里,我们每天都在用 reactive 创建响应式对象:改一个字段,模板自动更新。这个「自动」是怎么来的?数据变了,Vue 又是怎么知道该更新哪里的?

答案就是 ES6 的 Proxy。本篇我们打开 packages/reactivity 的源码,看看 reactive 内部到底做了什么。学完之后,你能够:

  • 说清楚 Proxyget / set 是如何拦截对象读写的。
  • 理解 reactive 源码里 WeakMap 缓存和递归代理这两个设计细节。
  • 说出 Vue3 相对 Vue2 响应式方案解决了哪些局限。
  • 知道 reactive 自身的限制,以及为什么还需要 ref

本篇源码基于 Vue 3.5.x(GitHub vuejs/core monorepo),主要涉及 packages/reactivity/src/reactive.tspackages/reactivity/src/baseHandlers.ts。示例代码可以直接用 Node.js v22 运行。


🧱 Proxy 基础回顾

要读懂 reactive,先要认识它的基础 Proxy。这一节我们只解决一个问题:Proxy 是什么,它能干什么?Proxy 可以给对象包一层「代理」:之后外界对对象的读写都不直接作用于原对象,而是先经过代理层,我们可以在代理层里插入自己的逻辑(比如打印一句话),再把操作转交给原对象。

这种在读写中间插入逻辑的做法,源码里叫拦截;定义了各种拦截方法的对象,叫拦截器(handler)。基本用法是 new Proxy(target, handler)

demo.js
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.nameuser.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,就是实际执行拦截逻辑的地方。它在 getset 里各做了三件事:

// 简化自 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.$setsplice 绕开。

根源在于 defineProperty 只能拦截初始化时已存在的 key,新加的 key 身上没有拦截逻辑,Vue 自然感知不到。而 Proxy 拦截的是整个对象,任何 key 的读写都会经过 get / set,这些坑在 Vue3 下直接消失了:

App.vue
<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() 又是如何精确通知更新的。