牛刀小试:Vue 3的响应式系统和Proxy?
引言
众所周知,Vue3 的响应式系统主要依赖于 ES6 的 Proxy 对象来实现。相比于 Vue2 使用的 Object.defineProperty,Proxy提供了更强大的功能和更好的性能。但我们在日常使用框架时却很少注意到背后的底层原理,本文就旨在通过重温Vue3的响应式系统,学习和回顾ES6的相关特性。
Proxy概述
ES6 Proxy 是一种新的 JavaScript 功能,它允许你创建一个对象的代理,从而可以拦截和自定义基本操作,例如属性查找、赋值、枚举和函数调用等。Proxy 可以被视为在目标对象之前设置的一层拦截,所有对该对象的访问都必须首先通过这层拦截,这提供了一种机制,可以对外界的访问进行过滤和改写。
语法
const p = new Proxy(target, handler)
target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。
例子
拦截器
这里我们创建了一个get handler,当程序试图访问对象时,如果属性存在于对象,则返回其对象的值,否则返回37。
以下是传递给 get 方法的参数,this 上下文绑定在handler 对象上。
target目标对象。property被获取的属性名。receiver是Proxy 或者继承 Proxy 的对象
jsx
const handler = {
get: function (obj, prop) {
return prop in obj ? obj[prop] : 37;
},
};
const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;
console.log(p.a, p.b); // 1, undefined
console.log("c" in p, p.c); // false, 37
无操作转发
在以下例子中,我们使用了一个原生 JavaScript 对象,代理会将所有应用到它的操作转发到这个对象上。
jsx
let target = {};
let p = new Proxy(target, {});
p.a = 37; // 操作转发到目标
console.log(target.a); // 37 操作已经被正确地转发
验证功能
通过代理,你可以轻松地验证向一个对象的传值。向Proxy的handler设置相关的setter方法,可以拦截对对象的赋值操作,并确保预期之内的结果。
以下是传递给 set() 方法的参数。this 绑定在 handler 对象上。
target目标对象。
value新属性值。
receiver最初接收赋值的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。
jsx
// Proxy & setter拦截器
let validator = {
set: function (obj, prop, value) {
if (prop === "age") {
if (!Number.isInteger(value)) {
throw new TypeError("The age is not an integer");
}
if (value > 200) {
throw new RangeError("The age seems invalid");
}
}
// 保存属性值的默认行为!
obj[prop] = value;
// 表示成功
return true;
},
}
let person = new Proxy({},validator)
person.age = 50
console.log(person.age)
// TypeError: The age is not an integer
person.age = 11.4
// RangeError: The age seems invalid
person.age = 3000
Reflect概述
ES6 Reflect 是一个内置的对象,它提供了一系列静态方法,用于执行可拦截的 JavaScript 操作。Reflect 并不是一个函数对象,因此它是不可构造的。
Reflect 的设计目的之一是与 Proxy 配合使用。Proxy handler 中的方法(如 get, set)与 Reflect 对象上的方法具有相同的名称和参数。这使得在 Proxy handler 中调用 Reflect 对应的方法来执行默认行为变得非常方便和规范。
主要方法
Reflect 上的所有方法都是静态的,与 Proxy 的 handler 方法一一对应。
Reflect.get(target, propertyKey[, receiver]): 获取对象属性的值,类似于target[propertyKey]。Reflect.set(target, propertyKey, value[, receiver]): 设置对象属性的值,类似于target[propertyKey] = value。它会返回一个布尔值表示是否设置成功。Reflect.has(target, propertyKey): 判断一个对象是否存在某个属性,类似于propertyKey in target。Reflect.deleteProperty(target, propertyKey): 删除对象的属性,类似于delete target[propertyKey]。
为什么与Proxy是最佳搭档?
在 Proxy 的拦截器中,我们通常需要执行原始操作。直接使用 obj[prop] = value 这样的语法虽然可行,但存在一些问题,尤其是在处理继承和 getter/setter 时。
Reflect 方法提供了执行这些默认操作的标准方式,并能正确处理 this 指向(通过 receiver 参数)。
例子
在这个例子中,我们使用 Reflect.set 来完成属性的赋值。这确保了即使对象有 setter,this 也会正确地指向代理对象 p。
jsx
const target = {
_name: 'Guest',
get name() {
return this._name;
},
set name(val) {
console.log('Setter called!');
this._name = val;
}
};
const handler = {
set(obj, prop, value, receiver) {
console.log(`Setting ${prop} to ${value}`);
// 使用 Reflect.set 来调用原始的 setter,并确保 this 指向 receiver (代理对象 p)
return Reflect.set(obj, prop, value, receiver);
}
};
const p = new Proxy(target, handler);
p.name = 'Admin';
// 输出:
// Setting name to Admin
// Setter called!
console.log(p.name); // Admin
使用 Reflect 不仅代码更简洁,而且更健壮,能够正确处理 JavaScript 复杂的内部机制。
手动挡响应式
了解完基本的原理后,我们将"手动挡"开始一步步实现Vue的响应式。
实现单个值的响应式
下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:
① 初始化一个 Set 类型的 dep (Dependency 依赖)变量,用来存放需要执行的副作用( effect 函数),即修改 total 值的方法;
② 创建 track() 函数,用来将需要执行的副作用(Effect)保存到 dep 变量中(也称收集副作用);
③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;
在每次修改 price 或 quantity 后,调用 trigger() 函数(扳机,触发器)执行所有副作用后, total 值将自动更新为最新值。
jsx
let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ①
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) }; // ②
const trigger = () => { dep.forEach( effect => effect() )}; // ③
track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40
实现单个对象的响应式
我们的对象具有多个属性,并且每个属性都需要自己的 dep(Dependency)。
我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象)
下面的代码通过 3 个步骤,实现对 total 数据进行响应式变化:
① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);
② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);
③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;
这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。
jsx
let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // 1 创建依赖映射表
const effect = () => { total = product.price * product.quantity };
const track = key => { // 2 查找该属性已有的依赖,若没有则自行创建一个
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}
const trigger = key => { // 3 找到该属性对应的所有依赖,并执行相关的副作用函数
let dep = depsMap.get(key);
if(dep) {
dep.forEach( effect => effect() );
}
};
track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40
为什么是Map充当依赖映射?
Map与Set的区别:
- Set只储存唯一的值,只有值没有键,重复值不会被添加。
- Map存储键值对,存在键值对的映射关系。
jsx
// Set 存储唯一值的集合
const effects = new Set(); // 只存储值,不存储键
effects.add(() => console.log('effect1'));
effects.add(() => console.log('effect2'));
effects.add(() => console.log('effect1')); // 重复值不会被添加
// Set 的内部结构
[effect1函数, effect2函数]
// 只有值,没有键
// Map 存储键值对
const depsMap = new Map(); // key -> value 的映射关系
depsMap.set('price', new Set([effect1, effect2]));
depsMap.set('quantity', new Set([effect3]));
// Map 的内部结构
{
'price' -> Set([effect1, effect2]),
'quantity' -> Set([effect3])
}
// 有明确的键值对应关系
故Set用于储存副作用函数运算,Map用于保存依赖的键值对。
实现多个对象的响应式
下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:
① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;
② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);
③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;
这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。
jsx
let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap(); // 1 初始化 targetMap,保存观察对象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => { // 2 收集依赖
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(effect);
}
const trigger = (target, key) => { // 3 执行指定对象的指定属性的所有副作用
const depsMap = targetMap.get(target);
if(!depsMap) return;
let dep = depsMap.get(key);
if(dep) {
dep.forEach( effect => effect() );
}
};
track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40
"自动挡"的响应式系统:Proxy与Reflect的结合
到目前为止,我们已经建立了一个依赖追踪系统,但它还是"手动挡"的:
- 我们需要在代码中手动调用
track()来收集依赖。 - 我们需要在数据更新后手动调用
trigger()来触发更新。
这显然不够智能。现在,让我们利用 Proxy 和 Reflect 将它升级为"自动挡"。我们的目标是:当访问一个对象的属性时,自动执行 track();当修改一个对象的属性时,自动执行 trigger()。
第一步:创建 reactive 函数
我们先创建一个 reactive 函数,它接收一个普通对象,并返回一个该对象的代理。所有的操作都将发生在这个代理的 handler 中。
jsx
const targetMap = new WeakMap(); // 依赖存储保持不变
// 副作用函数也保持不变
let activeEffect = null; // 我们需要一个变量来存储当前正在运行的副作用函数
function reactive(target) {
const handler = {
// get 拦截器:当读取属性时触发
get(target, key, receiver) {
console.log(`GET: 访问属性 ${key}`);
// ... 在这里自动收集依赖
},
// set 拦截器:当设置属性时触发
set(target, key, value, receiver) {
console.log(`SET: 设置属性 ${key} 为 ${value}`);
// ... 在这里自动触发更新
}
};
return new Proxy(target, handler);
}
第二步:在 get 拦截器中自动收集依赖
当代码访问代理对象的属性时(例如 product.price),get 拦截器会被触发。这正是我们调用 track() 的时机。
同时,为了让 track 函数知道要收集哪个副作用,我们引入一个全局变量 activeEffect,用于存储当前正在执行的副作用函数。
jsx
// track 函数现在需要知道当前激活的副作用是哪个
function track(target, key) {
if (activeEffect) { // 只有在 activeEffect 存在时才进行追踪
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); // 收集当前激活的副作用
}
}
// reactive 函数的 get handler
const handler = {
get(target, key, receiver) {
track(target, key); // 自动收集依赖
// 使用 Reflect.get 返回属性的原始值,确保 this 指向正确
return Reflect.get(target, key, receiver);
},
// ... set handler
};
第三步:在 set 拦截器中自动触发更新
当代码修改代理对象的属性时(例如 product.price = 20),set 拦截器会被触发。这时则调用 trigger() 。
jsx
// trigger 函数保持不变
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
let dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
// reactive 函数的 set handler
const handler = {
// ... get handler
set(target, key, value, receiver) {
// 使用 Reflect.set 设置新值
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 自动触发更新
return result; // 返回设置操作是否成功
}
};
第四步:整合与测试
现在,我们把所有部分整合起来,并创建一个 watchEffect 函数来管理 activeEffect。
jsx
const targetMap = new WeakMap();
let activeEffect = null;
function track(target, key) {
if (activeEffect) {
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);
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
let dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
function reactive(target) {
const handler = {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key);
return result;
}
};
return new Proxy(target, handler);
}
// watchEffect 用于注册副作用函数
function watchEffect(effect) {
activeEffect = effect;
effect(); // 立即执行一次,以触发 get 从而收集依赖
activeEffect = null;
}
// --- 测试 ---
let product = reactive({ price: 10, quantity: 2 });
let total = 0;
watchEffect(() => {
// 这个函数现在是我们的副作用
total = product.price * product.quantity;
});
console.log(`total: ${total}`); // total: 20
// 当我们修改 price 时,不再需要手动调用 trigger
product.price = 20;
console.log(`total: ${total}`); // total: 40 (自动更新!)
// 当我们修改 quantity 时,也同样会自动更新
product.quantity = 3;
console.log(`total: ${total}`); // total: 60 (自动更新!)
现在,我们拥有了一个真正的"自动挡"响应式系统。我们只需要用 reactive() 包裹我们的数据,并用 watchEffect() 注册依赖于这些数据的操作,剩下的依赖收集和触发更新都由 Proxy 自动完成了。
总结
在本文中,我们通过一个循序渐进的过程,亲手实现了一个迷你版的Vue 3响应式系统。让我们回顾一下这个旅程:
- 从基础开始 :我们首先理解了响应式的核心概念------当数据变化时,依赖该数据的代码应该自动重新执行。我们用一个简单的
Set来存储单个依赖(副作用函数),并手动调用track和trigger。 - 支持对象属性 :为了处理对象,我们引入了
Map,建立了从"属性名"到"依赖集合"的映射关系,使得每个属性都能独立追踪自己的依赖。 - 支持多个对象 :为了管理多个响应式对象,我们引入了
WeakMap,构建了targetMap -> depsMap -> dep的三层依赖存储结构。至此,我们的"手动挡"响应式系统已经成型。 - 迈向自动化 :我们认识到手动调用
track和trigger的繁琐和不可靠。于是,我们引入了 ES6 的Proxy和Reflect。- Proxy 允许我们拦截对象的
get和set操作。我们在get拦截器中自动调用track,在set拦截器中自动调用trigger。 - Reflect 则作为
Proxy的最佳搭档,提供了执行对象默认操作的标准方法,确保了操作的健壮性和this指向的正确性。
- Proxy 允许我们拦截对象的
通过这个过程,我们不仅深入理解了Vue 3响应式系统的核心原理,还掌握了 Proxy 和 Reflect 这两个强大的JavaScript特性。
参考
- 稀土掘金-探索 Vue3 响应式原理
- MDN-Proxy
- MDN-Reflect
- Google-Gemini2.5Pro参与校验和部分编纂工作