第六章:元编程与代理(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 对象新增一个属性 roleloggedUser 对此一无所知,无法进行拦截。同样,它也无法拦截 deletein 等操作。

解决方案: 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 陷阱中,验证完数据后,我们还是需要将值赋给目标对象。我们应该如何安全、规范地完成这个默认操作呢?

答案就是 ReflectReflect 是 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);

Reflectreceiver 的重要性

你可能会问:在 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 的实战应用:响应式系统原理

现在我们拥有了 ProxyReflect 这两把利器,我们可以尝试构建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 函数,并让它重新执行。ProxyReflect 的组合,让我们能够以一种非常干净和全面的方式实现这种“依赖收集”和“触发更新”的模式。


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 处理。