第六章:元编程与代理(Proxy)

第六章:元编程与代理(Proxy)

摘要: 欢迎来到 JavaScript 中最接近“魔法”的领域。本章我们将探讨 元编程 的概念,即让代码有能力在运行时检查、修改甚至创造自身的行为。我们将深入学习实现这一思想的核心工具——Proxy 对象。您将了解到 Proxy 如何让我们能够“代理”一个对象,并拦截对其所有基本操作(如读取、赋值、删除),这与我们之前学习的 Object.defineProperty 相比,是一次彻底的、颠覆性的升级。最后,我们将通过一个简化的模型,揭示 Vue 3.x 响应式系统是如何基于 Proxy 构建的。


在本章,我们将像剥洋葱一样,层层深入 Proxy 的世界:

  1. 首先,我们将从 元编程的概念 入手,理解 Proxy 试图解决的根本问题是什么。
  2. 接着,我们将学习 Proxy 的基础语法,了解“目标 (target)”与“处理器 (handler)”的核心关系。
  3. 然后,我们将逐一深入 Proxy 提供的 13 种核心“陷阱” (Traps),学习如何拦截并自定义对象的每一种底层操作。
  4. 最后,我们将聚焦于 Proxy 的实战应用,特别是它如何优雅地解决了数据校验、访问控制,并成为了现代响应式框架的基石。

6.1. 元编程概念入门

元编程 的核心思想是:让代码去操作代码。

在传统的编程模式中,我们的代码主要操作数据(数字、字符串、对象等)。而元编程则将代码本身也视为一种数据,允许我们编写出能够 在运行时分析、修改甚至生成其他代码 的程序。这赋予了语言极大的动态性和扩展性。

历史痛点: 在 ES6 的 Proxy 出现之前,如果我们想实现一个元编程的基本需求——“当一个对象的属性被读取或修改时,自动执行某些逻辑(如打印日志)”,该怎么做?我们只能依赖 ES5 的 Object.defineProperty()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Pre-Proxy 时代的痛苦尝试
const user = { name: 'Alice', age: 25 };
const loggedUser = {};

// 我们必须手动遍历每一个属性
Object.keys(user).forEach(key => {
Object.defineProperty(loggedUser, key, {
enumerable: true,
configurable: true,
get() {
console.log(`[LOG] 读取用户对象的属性: ${key}`);
return user[key];
},
set(newValue) {
console.log(`[LOG] 写入用户对象的属性: ${key} to ${newValue}`);
user[key] = newValue;
}
});
});

loggedUser.age = 26;
console.log(loggedUser.name);

这种方式的致命缺陷:

  1. 侵入性强且繁琐: 需要创建一个新对象并手动遍历、定义所有属性。
  2. 无法监听新增/删除: 如果后续为 user 对象新增一个属性 role,loggedUser 对此一无所知,无法进行拦截。同样,它也无法拦截 delete 或 in 等操作。

解决方案: Proxy 的出现,正是为了提供一个 全面的、非侵入性的 元编程解决方案。它允许我们在目标对象之上架设一个虚拟的“代理层”,所有施加于该对象的操作,都会先经过这层代理,给了我们一个统一的、强大的拦截入口。


6.2. Proxy 基础

Proxy 是一个构造函数,用于生成代理实例。

语法: const proxy = new Proxy(target, handler);

  • target: 被代理的原始对象(可以是任何类型的对象,包括数组、函数等)。
  • handler: 一个配置对象,其属性是各种“陷阱”(trap)函数,用于定义代理的具体行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 原始对象 (target)
const target = {
message1: "hello",
message2: "world"
};

// 2. 处理器对象 (handler)
const handler = {
// 定义一个 get 陷阱
// target:原始对象 prop:读取的属性名 receiver:代理对象
get: function(target, prop, receiver) {
console.log(`拦截读取属性: ${prop}`);
return target[prop];
}
};


// 3. 创建代理
const proxy = new Proxy(target, handler);

// 4. 所有通过 proxy 的读取操作都会被 get 陷阱拦截
console.log(proxy.message1);
console.log(proxy.message2);

注意:直接操作 target 对象(如 target.message1)不会触发代理。只有通过 proxy 实例进行的操作才会被拦截。


6.3. 核心陷阱(Traps)详解

handler 对象可以定义多达 13 种“陷阱”,它们对应了 JavaScript 中对象的各种内部方法。我们来深入几个最核心的。

get(target, prop, receiver)

拦截 读取 属性的操作。
应用: 实现读取时的默认值、数据格式化、访问日志等。

1
2
3
4
5
6
7
8
9
10
11
12
const user = { name: 'Alice' };
const proxy = new Proxy(user, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return `属性 "${prop}" 不存在.`;
}
}
});
console.log(proxy.name); // "Alice"
console.log(proxy.age); // "属性 " age " 不存在."

set(target, prop, value, receiver)

拦截 设置 属性值的操作。它必须返回一个布尔值,true 代表赋值成功。
应用: 数据校验、触发更新、只读属性保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const user = { age: 25 }

const proxy = new Proxy(user, {
set(target, prop, value) {
if (prop === "age") {
if (!Number.isInteger(value) || value < 0) {
throw new TypeError("年龄不合法")
}
}
target[prop] = value
return true
}
})

proxy.age = 26 // 成功
console.log(user.age) // 26
try {
proxy.age = -1; // 将会抛出错误
} catch (e) {
console.error(e.message);
}

has(target, prop)

拦截 prop in proxy 操作。
应用: 隐藏内部属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const config = {
user: 'admin',
_password: 'do-not-access' // _ 前缀约定为私有
};
const proxy = new Proxy(config, {
has(target, prop) {
if (prop.startsWith('_')) {
return false; // 假装它不存在
}
return prop in target;
}
});

console.log('user' in proxy); // true
console.log('_password' in proxy); // false

deleteProperty(target, prop)

拦截 delete proxy[prop] 操作。必须返回布尔值。
应用: 保护重要属性不被删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const User = {
name: "Alice",
age: 25,
_password: "123456"
}

const proxy = new Proxy(User,{
deleteProperty(target,prop) {
if (prop.startsWith("_")) {
throw new Error("不能删除私有属性")
}
delete target[prop]
return true
}
})

try {
delete proxy._password
} catch (e) {
console.error(e.message)
}

console.log(proxy)

apply(target, thisArg, argumentsList)

当代理的目标是一个 函数 时,apply 陷阱会拦截函数的调用。
应用: 函数参数校验、调用日志、性能监控。

1
2
3
4
5
6
7
8
9
function add(a, b) { return a + b }
const proxy = new Proxy(add, {
apply(target, thisarg, argumentList) {
console.log(`函数被调用,参数为: ${argumentList.join(', ')}`);
return target.apply(thisarg, argumentList);
}
})

console.log(proxy(1, 2)) // 函数被调用,参数为: 1, 21

6.4. Reflect:Proxy 的最佳搭档

承上启下: 在我们自定义 Proxy 的陷阱函数时,通常在执行完自定义逻辑后,还需要执行原始的、默认的那个操作。例如,在一个 set 陷阱中,验证完数据后,我们还是需要将值赋给目标对象。我们应该如何安全、规范地完成这个默认操作呢?

答案就是 Reflect。Reflect 是 ES6 引入的一个新的内置对象,它提供了一系列与 Proxy 陷阱函数同名的静态方法。

核心设计哲学:

  1. 方法对应: Reflect 上的 13 个方法与 Proxy 的 13 个陷阱函数一一对应。Reflect.get() 就是 get 陷阱的默认行为,Reflect.set() 就是 set 陷阱的默认行为,以此类推。
  2. 函数式与状态报告: Reflect 将一些原有的、命令式的 Object 操作(如 delete obj.prop)改为了更可靠的函数式操作。例如,Reflect.deleteProperty(obj, 'prop') 会返回一个布尔值来明确告知你操作是否成功,而不是像 delete 那样在非严格模式下静默失败。

最佳实践: 在 Proxy 的陷阱函数中,总是使用对应的 Reflect 方法来执行默认操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const user = { name: 'Alice', age: 25 };

const handler = {
get(target, prop, receiver) {
console.log(`[Reflect] Reading property: ${prop}`);
// 使用 Reflect.get 执行默认的读取操作
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[Reflect] Setting property: ${prop} to ${value}`);
return Reflect.set(target, prop, value, receiver);
}
}

const proxy = new Proxy(user, handler);
proxy.age = 26;
console.log(proxy.name);

Reflect 与 receiver 的重要性

你可能会问:在 get 陷阱里,用 Reflect.get(target, prop, receiver) 和直接用 target[prop] 有什么区别?在大多数情况下没有,但在处理访问器属性 (getter) 时,区别是巨大的。

receiver 参数保证了当原始对象上有 getter 时,getter 内部的 this 会正确地指向代理对象 proxy,而不是原始对象 target。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const target = {
_name: 'Alice',
get name() {
// 这个 getter 依赖 this
return this._name;
}
};

const handler = {
get(target, prop, receiver) {
if (prop === 'name') {
console.log('Intercepting getter...');
// 如果用 target[prop],getter 内的 this 会指向 target,这是不正确的
// return target[prop];

// 必须用 Reflect.get 并传入 receiver,确保 this 指向 proxy
return Reflect.get(target, prop, receiver);
}
return Reflect.get(target, prop, receiver);
}
};

const proxy = new Proxy(target, handler);
console.log(proxy.name); // 正确执行,并被拦截

6.5. Proxy 的实战应用:响应式系统原理

现在我们拥有了 Proxy 和 Reflect 这两把利器,我们可以尝试构建Vue3极简的响应式模型了,Vue3框架就是靠这样类似的代码去实现响应式更新的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 用于存储依赖关系的数据结构
const dependencies = new Map();
let activeEffect = null; // 当前正在执行的“副作用”函数

function effect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (activeEffect) {
// 1. 收集依赖
if (!dependencies.has(target)) dependencies.set(target, new Map());
const depsMap = dependencies.get(target);
if (!depsMap.has(key)) depsMap.set(key, new Set());
depsMap.get(key).add(activeEffect);
}
// 使用 Reflect 执行默认行为,保证 this 指向正确
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
// 使用 Reflect 执行默认行为
const result = Reflect.set(target, key, value, receiver);
// 2. 触发更新
const deps = dependencies.get(target)?.get(key);
if (deps) deps.forEach(effect => effect());
return result;
}
});
}

// --- 实战使用 ---
const state = reactive({ count: 0 });
effect(() => {
console.log(`The count is: ${state.count}`);
});

// 当我们修改 state.count 时...
setTimeout(() => {
state.count++;
}, 1000)

这个模型解释了 Vue 3 的“魔法”:当 state.count++ 发生时,set 陷阱被触发,它找到了之前在 get 陷阱中通过 activeEffect 记录的那个 console.log 函数,并让它重新执行。Proxy 与 Reflect 的组合,让我们能够以一种非常干净和全面的方式实现这种“依赖收集”和“触发更新”的模式。


6.6. Proxy.revocable():创建可撤销的代理

有时,我们希望能够动态地关闭对一个对象的代理访问。Proxy.revocable() 方法可以创建一个可撤销的代理。

它返回一个包含两个属性的对象:

  • proxy: 代理对象本身。
  • revoke: 一个无参数的函数,调用它会撤销该代理。
1
2
3
4
5
6
7
8
9
10
11
12
13
const target = { data: 'secret' };
const { proxy, revoke } = Proxy.revocable(target, {});

console.log(proxy.data); // 'secret'

// 撤销代理
revoke();

try {
console.log(proxy.data); // 再次访问将抛出 TypeError
} catch (e) {
console.error(e.message);
}

6.7. 本章核心原理与高频面试题

核心原理速查

概念核心原理关键点
元编程代码操作代码Proxy 是 JavaScript 元编程的核心工具。
Proxy vs defineProperty代理 vs 劫持Proxy 在对象层面代理,可拦截13种操作,非侵入式;defineProperty 在属性层面劫持,只能拦截 get/set,且无法监听新增/删除属性。
Handler 与 Traps处理器对象与陷阱函数通过在 handler 中定义 get, set 等陷阱函数,可以拦截并自定义对象的底层操作。
Reflect默认行为的函数式实现Reflect 的方法与 Proxy 陷阱一一对应,是执行默认操作的最佳实践,能保证 this (receiver) 的正确传递。
响应式原理依赖收集与触发更新在 get 中收集依赖(谁用了我),在 set 中触发更新(通知用过我的人)。

高频面试题与陷阱

面试官深度追问
2025-08-28
面

为什么在 Proxy 的陷阱函数中,推荐使用 Reflect 来执行默认操作,而不是直接操作 target 对象?

我
我

主要有两个原因。第一是确保 this 指向的正确性。特别是在 get 陷阱中处理带有 getter 的属性时,如果直接用 target.prop 来获取值,getter 内部的 this 会指向原始的 target 对象。而使用 Reflect.get(target, prop, receiver),可以将 receiver(即 proxy 实例本身)传递进去,确保 getter 内的 this 正确地指向 proxy,这对于依赖 this 的复杂对象至关重要。

我
我

第二是为了代码的规范性和一致性。Reflect 对象上的方法与 Proxy 的陷阱函数是一一对应的,这使得 Reflect 成为了执行“默认行为”的天然选择,代码意图更清晰。同时,Reflect 的一些方法提供了比传统操作更可靠的状态报告,例如 Reflect.set 会返回布尔值表示成功与否,这比 target.prop = value 在非严格模式下的静默失败要健壮得多。

面

很好。那么,你能谈谈 Proxy 相对于 ES5 的 Object.defineProperty 有哪些核心优势吗?为什么 Vue 3 要用 Proxy 重写响应式系统?

我
我

Proxy 的优势是全方位、根本性的。主要有三点:

我
我

第一,拦截范围更广。Object.defineProperty 只能劫持对象的属性读取(get)和设置(set),而 Proxy 可以拦截多达 13 种底层操作,包括 in 操作 (has陷阱)、delete 操作 (deleteProperty陷阱)等。

我
我

第二,对原对象非侵入性。Proxy 创建的是一个全新的代理对象,我们对代理对象进行操作,不会对原对象产生任何污染。而 defineProperty 是直接修改原对象的属性描述符。

我
我

第三,也是对 Vue 来说最重要的一点,原生支持对新增属性和数组操作的监听。使用 defineProperty,必须在初始化时就遍历对象的所有属性进行劫持,对于后续新增的属性无能为力,Vue 2 不得不为此设计了特殊的 $set API。而 Proxy 是在整个对象层面进行代理,任何属性(无论何时添加)的访问都会被拦截。同时,它也能原生监听到数组索引的修改和 .length 属性的变化,而这些在 Vue 2 中也需要进行特殊的 hack 处理。