如果你用过 Vue 3,一定知道 ref() 和 reactive() 很好用——改数据,视图自动更新。但你想过没有:为什么改一个变量,组件就知道该重新渲染了? Vue 3 的响应式系统从零被重写,背后依赖的是 ES6 的 Proxy 和 Reflect,以及一套精妙的依赖追踪机制。
本文带你从零开始,一步步构建一个迷你 Vue 3 响应式系统。读完你会真正理解 ref、reactive、computed 这些 API 到底是怎么工作的。
适合读者: 有 Vue 使用经验、想深入原理的前端开发者。
一、Vue 2 的痛点:为什么需要重写?
Vue 2 基于 Object.defineProperty 实现响应式,这带来了三个致命问题:
1. 无法监听对象属性的新增和删除
// Vue 2 中,动态添加的属性不是响应式的
this.obj.newProp = 'hello'; // ❌ 视图不更新
// 必须用 Vue.set(this.obj, 'newProp', 'hello')
2. 数组操作拦截不完整
// Vue 2 必须重写 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse)
this.arr[0] = 'new'; // ❌ 索引赋值无法拦截,视图不更新
this.arr.length = 0; // ❌ 修改长度无法拦截,视图不更新
3. 初始化时递归遍历,性能开销大
Object.defineProperty 在初始化时就要递归遍历对象的所有层级,每层每属性都要劫持。嵌套越深初始化越慢,对于大对象或深层数据,这就是性能灾难。
二、Proxy:ES6 的"拦截器"
Vue 3 选择 Proxy 作为响应式的基石。Proxy 可以代理一个对象,拦截对它的任意操作——不只是读写,还包括删除属性、in 操作符、for...in 遍历等,共计 13 种基础操作。
基本用法
const handler = {
get(target, key, receiver) {
console.log(`读取了 ${String(key)}`);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`设置了 ${String(key)} = ${value}`);
return Reflect.set(target, key, value, receiver);
},
deleteProperty(target, key) {
console.log(`删除了 ${String(key)}`);
return Reflect.deleteProperty(target, key);
}
};
const obj = new Proxy({ name: 'Vue', version: 3 }, handler);
obj.name; // 读取了 name
obj.version = 4; // 设置了 version = 4
delete obj.name; // 删除了 name
Proxy 的 13 个捕获器
| 捕获器 | 拦截的操作 |
|---|---|
get() | 属性读取 |
set() | 属性赋值 |
has() | in 操作符 |
deleteProperty() | delete 操作符 |
ownKeys() | Object.keys()、for...in |
getOwnPropertyDescriptor() | Object.getOwnPropertyDescriptor() |
defineProperty() | Object.defineProperty() |
preventExtensions() | Object.preventExtensions() |
getPrototypeOf() | Object.getPrototypeOf() |
setPrototypeOf() | Object.setPrototypeOf() |
isExtensible() | Object.isExtensible() |
apply() | 函数调用 |
construct() | new 操作符 |
Vue 3 响应式系统主要用到其中 get、set、deleteProperty、has、ownKeys 这 5 个。
相比 Object.defineProperty,Proxy 有三个压倒性优势:
- 数组索引赋值和
length修改天然可拦截 - 属性新增和删除自动检测,告别
Vue.set/Vue.delete - 初始化时不需要递归遍历所有层级(懒代理策略——用到再代理)
三、Reflect:为什么不能只用 Proxy?
很多教程把 Proxy 和 Reflect 绑在一起讲,却很少解释为什么要用 Reflect。看这段反例:
// ❌ 直接在 handler 中操作 target,this 绑定出错
const obj = {
_count: 0,
get count() { return this._count; },
set count(v) { this._count = v; }
};
const proxy = new Proxy(obj, {
get(target, key) {
console.log('get:', key);
return target[key]; // this 指向原始对象 obj,而非代理 proxy!
}
});
proxy.count; // get: 'count' → 但 getter 内 this._count 绕过了 Proxy
这里有隐晦的 Bug:当对象属性是通过 getter/setter 定义的,在 handler 中直接访问 target[key],getter 内部的 this 指向的是原始对象 obj,而不是代理对象 proxy。这意味着 getter 中对 this._count 的读取会绕过代理的 get 拦截,无法被 track() 收集依赖。
Reflect 的 receiver 参数正是为此而生:
// ✅ 使用 Reflect 并传递 receiver
const proxy = new Proxy(obj, {
get(target, key, receiver) {
console.log('get:', key);
return Reflect.get(target, key, receiver); // receiver = proxy,this 正确绑定
}
});
receiver 确保在访问 getter 时,this 正确指向代理对象,这样 getter 内部对 this._count 的读取也会被代理拦截,依赖收集才完整。
一句话总结:Proxy 负责拦截,Reflect 负责执行默认行为并保持
this指向正确。二者配合才是完整的响应式基石。
四、核心数据结构:依赖如何存储?
Vue 3 使用三层嵌套数据结构来管理依赖关系:
WeakMap<target, Map<key, Set<effectFn>>>
↑ ↑ ↑
原始对象 属性名 副作用函数集合
- WeakMap:键是原始对象,值是依赖 Map。WeakMap 的 key 是弱引用——当对象其他地方不再使用时,可以被 GC 回收,防止内存泄漏。
- Map:键是属性名,值是该属性的依赖集合。
- Set:存储所有依赖该属性的副作用函数,Set 天然去重,避免同一函数被重复收集。
三层依赖存储结构
// 全局依赖存储
const targetMap = new WeakMap();
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
if (activeEffect && !deps.has(activeEffect)) {
deps.add(activeEffect);
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => effect());
}
}
五、effect:副作用注册引擎
effect 是响应式系统的心脏。它注册一个函数,并在该函数执行期间自动收集它访问了哪些响应式数据。
let activeEffect = null;
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn(); // fn 中访问响应式数据 → get → track,自动收集依赖
};
effectFn();
}
使用示例:
const state = reactive({ count: 0 });
effect(() => {
console.log(state.count); // 执行时,state.count 的 get 将 effectFn 收集为依赖
});
state.count = 1; // 触发 set → trigger → 自动重新执行 effectFn,输出 1
Vue 组件的 render 函数本质上就是一个 effect。组件渲染时访问的响应式数据一旦变化,组件便自动重新渲染——这就是响应式驱动视图的核心原理。
六、reactive:将一切组装起来
有了前面的铺垫,实现 reactive 就水到渠成了:
function reactive(target) {
if (typeof target !== 'object' || target === null) return target;
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
// 依赖收集
track(target, key);
// 懒代理:嵌套对象按需代理
if (typeof value === 'object' && value !== null) {
return reactive(value);
}
return value;
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
// 值没变,不触发更新
if (oldValue === value) return true;
const result = Reflect.set(target, key, value, receiver);
// 触发依赖更新
trigger(target, key);
return result;
},
deleteProperty(target, key) {
const hadKey = Reflect.has(target, key);
const result = Reflect.deleteProperty(target, key);
// 仅当确实删除了已存在的 key 时才触发
if (hadKey) trigger(target, key);
return result;
}
});
}
几个设计细节值得品味:
- 懒代理:不在初始化时递归遍历所有层级,而是在
get中按需代理。绝大多数场景访问不到深层属性,这节省了大量初始化开销——这是 Vue 3 相比 Vue 2 的一大性能优势。 - 相同值不触发:
oldValue === value的比较避免了无意义的重复渲染。 - delete 防护:只有确实删除已有 key 才派发更新,多余触发一次也不要。
七、ref:基本类型的响应式方案
reactive 只能代理对象,基本类型(string、number、boolean)怎么办?ref 用“对象包装 + getter/setter”巧妙地解决了这个问题:
class RefImpl {
_value;
constructor(rawValue) {
// 如果是对象,委托给 reactive 统一处理
this._value = isObject(rawValue) ? reactive(rawValue) : rawValue;
}
get value() {
track(this, 'value');
return this._value;
}
set value(newValue) {
if (newValue === this._value) return;
this._value = isObject(newValue) ? reactive(newValue) : newValue;
trigger(this, 'value');
}
}
function ref(rawValue) {
return new RefImpl(rawValue);
}
注意 track(this, 'value') 和 trigger(this, 'value')——key 固定为 'value',因为所有 ref 都通过 .value 访问。
模板中 .value 为什么可以省略? Vue 编译器在处理 <template> 时,会自动为顶层 ref 变量插入 .value 访问。同时,当 ref 作为 reactive 对象的属性时,reactive 的 get 拦截中也会自动解包 ref,所以你写 state.count 实际上拿到的是 ref.value。
八、computed:惰性求值的派生状态
computed 的核心设计是惰性求值 + 缓存——只有依赖变化后、再次访问 .value 时才重新计算,而不是每次依赖变化都立刻计算。
function computed(getter) {
let cachedValue;
let dirty = true;
// 用 effect 包装 getter,并在 scheduler 中标记 dirty
const runner = effect(() => {
if (dirty) {
cachedValue = getter();
dirty = false;
}
});
return {
get value() {
if (dirty) {
cachedValue = getter();
dirty = false;
}
track(this, 'value');
return cachedValue;
}
};
}
完整的 Vue 3 实现还需要一个 scheduler 机制:当 computed 依赖的数据变化时,scheduler 只做一件事——将 dirty 置为 true,然后通知依赖该 computed 的外部 effect(如 render effect)。等到实际读取 .value 时,才真正执行 getter 计算新值。
执行链路如下:
state.a 改变
→ trigger(state.a 的依赖)
→ computed 的 scheduler:dirty = true(只打标记!)
→ trigger(computed 的依赖)
→ 渲染 effect 重新执行
→ 访问 computed.value
→ dirty === true → 执行 getter 重算 → dirty = false
这就是为什么 computed 在依赖未变时不会重新计算,而在依赖变了之后也不会“立即”计算——它等到有人真正需要结果时才动手。
九、reactive vs ref:什么时候用哪个?
| 特性 | reactive | ref |
|---|---|---|
| 适用类型 | 对象、数组 | 任意类型(包括基本类型) |
| 访问方式 | 直接 obj.x | 通过 .value |
| 深层响应 | 自动递归代理 | 对象值自动转 reactive |
| 解构 | 丢失响应性(需 toRefs) | ref 变量本身保持响应性 |
| 整体替换 | 需 Object.assign 合并 | 直接 .value = newVal |
实践建议:
- 单个基本值用
ref,复杂对象/表单用reactive - 需要解构或传递单个字段时,用
toRefs()将 reactive 对象的属性转为 ref - 大型只读数据集用
shallowRef/shallowReactive,避免深层代理开销 - 第三方类实例(地图、图表对象)用
shallowRef,避免 Proxy 与第三方内部逻辑冲突
十、完整流程一览
component.render()
│
▼
effect(fn) → activeEffect = effectFn
│
▼
fn() 调用 render → 访问响应式数据 → get 捕获 → track(target, key)
│
▼
写入 state.count = 1
│
▼
set 捕获 → trigger(target, key) → 唤醒所有依赖的 effectFn → 组件重新渲染
环环相扣的五个步骤:
- Proxy — 拦截读写操作
- Reflect — 执行默认行为并保持 receiver 正确
- track — 在 get 时将
activeEffect收集为依赖 - trigger — 在 set 时通知所有依赖重新执行
- effect — 包装渲染函数,执行前将自己设为
activeEffect
十一、常见面试追问
Q1: 为什么用 WeakMap 而不是普通 Map?
WeakMap 的 key 是弱引用。当响应式对象被销毁、没有其他引用时,WeakMap 中对应的条目可以被 GC 自动回收,不会造成内存泄漏。普通 Map 的 key 是强引用,只要 targetMap 存在,对象就永远不会被回收。
Q2: 数组方面 Vue 3 比 Vue 2 好在哪里?
Proxy 可以直接拦截 arr[0] = x、arr.length = 0 等操作,所有原生数组方法都是响应式的。Vue 2 必须重写 7 个数组方法来实现近似效果,是修补方案。
Q3: 为什么 reactive 对象解构会丢失响应性?
const state = reactive({ count: 0 });
const { count } = state; // count = 0,一个普通数字
解构等同于 const count = state.count,取出的只是当前值。此后 count 不再通过 Proxy 访问,自然无法被追踪。解决方案是用 toRefs() 将每个属性转为 ref 后再解构。
Q4: 为什么 computed 不支持异步?
三个原因:① 缓存无法兑现——第一次读到的是 Promise 而非最终值;② 渲染需要同步值,异步结果导致视图空档;③ 依赖追踪不确定——多个依赖先后变化时,无法确定哪次异步结果最新。需要异步计算时,推荐 watch + ref 的组合模式。
写在最后
Vue 3 的响应式系统是一次彻底的重新设计——用 Proxy 取代 Object.defineProperty,用 Reflect 保证语义正确,用三层 WeakMap → Map → Set 结构管理依赖,用 effect 驱动视图更新。理解这套机制,不仅能帮你更自信地使用 Vue 3,也能让你在面试中从容应对原理题。
建议直接在浏览器控制台中把文中的简化代码跑一遍。当你亲眼看到 effect 随数据变化自动执行的那一刻,响应式就不再是魔法了。
原文出处:Vue 3 Reactivity — Vue Mastery 本文基于 Vue Mastery 课程主题框架,结合 Vue 3 源码与 ES6 规范,重新组织并以中文撰写。