各位同仁,各位技术爱好者,大家好!
今天,我们将深入探讨一个在软件设计和JavaScript语言中都极具魅力的概念——“代理”(Proxy)。在软件工程的广阔天地中,“代理”以其独特的魅力,帮助我们实现对对象行为的控制、增强和优化。然而,当我们谈论“代理”时,我们可能会遇到两种截然不同但又有所关联的实现方式:一种是经典的代理模式(Proxy Pattern),它是设计模式家族中的一员;另一种则是JavaScript ES6引入的语言特性——ES6 Proxy。
这两种“代理”虽然在名称上相似,但其本质、实现方式、应用场景及所提供的能力却有着显著的差异。本次讲座,我将带领大家抽丝剥茧,深入剖析这两种“代理”的异同,特别是在实现方法拦截和延迟加载这两个核心功能上的应用。通过详尽的理论阐述、丰富的代码示例以及严谨的逻辑对比,期望能帮助大家透彻理解它们,并在实际开发中做出明智的技术选型。
1. 代理模式 (Proxy Pattern): 经典设计模式的深度解析
首先,我们从软件工程的基石——设计模式谈起。代理模式(Proxy Pattern)是 GoF(Gang of Four)23种经典设计模式之一,属于结构型模式。它的核心思想是:为另一个对象提供一个替身或占位符以控制对这个对象的访问。
1.1 定义与核心思想
代理模式的定义是:“为其他对象提供一种代理以控制对这个对象的访问。” 简单来说,就是当我们不希望或不能直接访问某个对象时,可以通过一个代理对象来间接访问。这个代理对象和真实对象实现相同的接口,使得客户端在调用时感觉不到差异,但代理对象可以在客户端和真实对象之间插入额外的逻辑,例如权限验证、远程调用、延迟加载等。
为什么我们需要代理模式?
- 控制访问: 在访问真实对象之前或之后执行特定的操作,例如权限检查、日志记录。
- 添加额外行为: 不修改真实对象的前提下,为其增加新的功能。
- 解耦: 将客户端与真实对象的复杂性或特定方面(如网络通信、资源加载)解耦。
1.2 结构与角色
代理模式通常包含以下几个核心角色:
- Subject (抽象主题角色): 这是一个接口或抽象类,定义了真实主题和代理主题共同实现的接口。客户端通过这个接口与真实主题或代理主题交互。
- RealSubject (真实主题角色): 实现了Subject接口,是代理模式所代表的真实对象,负责执行业务逻辑。
- Proxy (代理主题角色): 实现了Subject接口,并持有一个对RealSubject的引用。它负责控制对RealSubject的访问,并在访问RealSubject之前或之后执行额外的逻辑。
- Client (客户端角色): 使用Subject接口与代理主题进行交互,而无需关心它是在与真实主题还是代理主题交互。
我们可以用TypeScript(因为其类型系统有助于清晰表达接口和类结构)来模拟这种结构:
// Subject (抽象主题角色)
interface Image {
display(): void;
}
// RealSubject (真实主题角色)
class RealImage implements Image {
private fileName: string;
constructor(fileName: string) {
this.fileName = fileName;
this.loadFromDisk(); // 模拟耗时操作,例如从磁盘加载图片
}
private loadFromDisk(): void {
console.log(`Loading image: ${this.fileName} from disk...`);
// 模拟加载时间
// for (let i = 0; i < 1000000000; i++) {} // 真实项目中不这样写
console.log(`Image ${this.fileName} loaded.`);
}
display(): void {
console.log(`Displaying image: ${this.fileName}`);
}
}
// Proxy (代理主题角色)
class ProxyImage implements Image {
private realImage: RealImage | null = null; // 延迟创建真实对象
private fileName: string;
constructor(fileName: string) {
this.fileName = fileName;
}
display(): void {
if (this.realImage === null) {
console.log(`Proxy: Real image for ${this.fileName} not yet created. Creating now...`);
this.realImage = new RealImage(this.fileName); // 第一次调用时才创建真实对象
}
this.realImage.display(); // 调用真实对象的display方法
}
}
// Client (客户端角色)
function clientCode(image: Image) {
console.log("Client: Requesting image display for the first time.");
image.display(); // 第一次调用,会触发RealImage的创建和加载
console.log("nClient: Requesting image display for the second time.");
image.display(); // 第二次调用,直接使用已创建的RealImage
}
console.log("--- Using Proxy Pattern for Lazy Loading ---");
const imageProxy = new ProxyImage("large_photo.jpg");
clientCode(imageProxy);
/*
输出示例:
--- Using Proxy Pattern for Lazy Loading ---
Client: Requesting image display for the first time.
Proxy: Real image for large_photo.jpg not yet created. Creating now...
Loading image: large_photo.jpg from disk...
Image large_photo.jpg loaded.
Displaying image: large_photo.jpg
Client: Requesting image display for the second time.
Displaying image: large_photo.jpg
*/
在这个例子中,ProxyImage 就是一个虚拟代理,它实现了 Image 接口,并在第一次调用 display() 方法时才真正创建和加载 RealImage 对象,从而实现了延迟加载。
1.3 典型应用场景
代理模式的应用场景非常广泛,根据其具体用途,可以分为多种类型:
- 远程代理 (Remote Proxy): 为位于不同地址空间(例如远程服务器)的对象提供本地代表。它负责将本地请求转换为网络请求,并将远程响应转换回本地结果。例如,RPC(远程过程调用)框架中的Stub和Skeleton。
- 虚拟代理 (Virtual Proxy): 延迟创建开销大的对象,直到真正需要使用它时才创建。这在处理大型图片、复杂文档或数据库连接等资源时非常有用,可以提高应用程序的启动速度和响应性能。我们上面
ProxyImage的例子就是典型的虚拟代理。 -
保护代理 (Protection Proxy): 控制对敏感对象的访问权限。它根据调用者的身份或权限,决定是否允许访问真实对象或其特定方法。
// RealSubject: Document class Document { private content: string; constructor(initialContent: string) { this.content = initialContent; } read(): string { return `Document content: "${this.content}"`; } edit(newContent: string): void { this.content = newContent; console.log("Document content updated."); } } // Proxy: Protection Proxy class DocumentProtectionProxy { private realDocument: Document; private userRole: 'Admin' | 'User' | 'Guest'; constructor(realDocument: Document, userRole: 'Admin' | 'User' | 'Guest') { this.realDocument = realDocument; this.userRole = userRole; } read(): string { console.log(`ProtectionProxy: User '${this.userRole}' attempting to read.`); return this.realDocument.read(); } edit(newContent: string): void { console.log(`ProtectionProxy: User '${this.userRole}' attempting to edit.`); if (this.userRole === 'Admin') { this.realDocument.edit(newContent); } else { console.log("Access Denied: Only Admins can edit documents."); } } } console.log("n--- Using Proxy Pattern for Protection Proxy ---"); const sensitiveDoc = new Document("Initial sensitive information."); const adminProxy = new DocumentProtectionProxy(sensitiveDoc, 'Admin'); console.log(adminProxy.read()); adminProxy.edit("Updated sensitive information by Admin."); console.log(adminProxy.read()); const userProxy = new DocumentProtectionProxy(sensitiveDoc, 'User'); console.log(userProxy.read()); userProxy.edit("Attempting to edit by User."); // 应该被拒绝 console.log(userProxy.read()); // 内容不变 - 智能引用代理 (Smart Reference Proxy): 当访问真实对象时,执行一些附加操作,例如对真实对象的引用计数、加锁以防止其他进程访问等。
1.4 优点与局限性
优点:
- 职责分离: 代理对象和真实对象各自关注不同的职责,代理负责控制访问和额外行为,真实对象负责核心业务逻辑。
- 控制访问: 可以在客户端和真实对象之间插入一层控制,实现权限、日志、缓存等功能。
- 性能优化: 通过虚拟代理实现延迟加载,避免不必要的资源消耗,提高系统响应速度。
- 不修改真实对象: 可以在不改变真实对象代码的前提下对其进行功能增强。
局限性:
- 引入额外类: 每引入一个代理,就需要额外定义一个代理类,增加了类的数量和系统的复杂性。
- 增加复杂性: 客户端代码需要理解代理的存在,尽管它们通过相同的接口交互。
- 硬编码代理行为: 代理的行为通常在编译时确定,修改代理行为需要修改代理类的代码,不够动态和灵活。
- 对真实对象的依赖: 代理对象必须持有真实对象的引用,并委托其执行核心业务。
2. ES6 Proxy: JavaScript 的原生元编程能力
接下来,我们将目光转向JavaScript语言本身。ES6(ECMAScript 2015)引入了一个强大的新特性——Proxy 对象。它与经典的代理模式在概念上有所重叠,但其实现方式和所提供的能力却大相径庭。ES6 Proxy 不是一个设计模式,而是一个语言层面的元编程(Metaprogramming)特性。它允许你在对象上定义自定义行为,这些行为可以在对对象进行基本操作时被拦截。
2.1 定义与核心思想
ES6 Proxy 的核心思想是:创建一个对象的代理,允许你拦截并自定义该对象的几乎所有基本操作。 这些基本操作包括属性查找、赋值、枚举、函数调用、构造函数调用等等。
Proxy 对象作为目标对象的“看门人”,在目标对象上执行任何操作之前,Proxy 会先“询问”它的 handler 对象,看是否有对应的拦截器(trap)。如果有,就执行拦截器的逻辑;如果没有,就将操作转发给目标对象。
2.2 基本用法与结构
Proxy 的基本语法非常简洁:
const proxy = new Proxy(target, handler);
target:要代理的原始对象(可以是任何对象,包括函数、数组、另一个Proxy)。handler:一个对象,其属性是各种“陷阱”(trap)方法,用于定义当对代理对象执行特定操作时要执行的自定义行为。
2.3 核心概念:陷阱 (Traps)
“陷阱”(Traps)是 handler 对象中的方法,它们对应着对代理对象进行的不同操作。当对代理对象进行某种操作时,如果 handler 中定义了相应的陷阱方法,该方法就会被自动调用,从而拦截并自定义该操作的行为。
以下是一些常用的陷阱方法及其用途:
get(target, property, receiver): 拦截属性读取操作(例如proxy.foo)。target: 目标对象。property: 被访问的属性名。receiver: Proxy 或继承 Proxy 的对象。
set(target, property, value, receiver): 拦截属性设置操作(例如proxy.foo = bar)。target: 目标对象。property: 被设置的属性名。value: 新的属性值。receiver: Proxy 或继承 Proxy 的对象。
apply(target, thisArg, argumentsList): 拦截函数调用操作(例如proxy(...args))。target: 目标函数。thisArg:apply方法的this参数。argumentsList:apply方法的arguments参数列表。
construct(target, argumentsList, newTarget): 拦截new操作符(例如new proxy(...args))。target: 目标构造函数。argumentsList: 构造函数的参数列表。newTarget:new表达式中最初被调用的构造函数。
has(target, property): 拦截in操作符(例如'foo' in proxy)。deleteProperty(target, property): 拦截delete操作符(例如delete proxy.foo)。ownKeys(target): 拦截Object.keys(),Object.getOwnPropertyNames(),Object.getOwnPropertySymbols(),for...in循环。defineProperty(target, property, descriptor): 拦截Object.defineProperty()。getOwnPropertyDescriptor(target, property): 拦截Object.getOwnPropertyDescriptor()。getPrototypeOf(target): 拦截Object.getPrototypeOf()。setPrototypeOf(target, prototype): 拦截Object.setPrototypeOf()。
当一个陷阱方法没有被定义时,默认行为是直接将操作转发给目标对象。为了在陷阱内部方便地调用目标对象的默认行为,ES6 提供了 Reflect 对象,它提供了与 Proxy 陷阱方法同名的静态方法,可以方便地执行默认操作。例如,在 get 陷阱中,Reflect.get(target, property, receiver) 会执行目标对象的默认属性读取行为。
2.4 实现方法拦截
ES6 Proxy 在实现方法拦截方面表现出极高的灵活性。我们可以使用 get 陷阱来拦截属性访问,当访问到的是一个函数时,可以返回一个包装过的函数来执行额外的逻辑。更直接的方式是使用 apply 陷阱来拦截函数调用。
代码示例: 拦截方法调用 (apply trap)
假设我们有一个服务类,我们希望记录所有对其方法的调用,包括方法名、参数和返回值。
// 目标对象:一个普通的LoggerService
class LoggerService {
log(message: string): void {
console.log(`[SERVICE LOG] ${message}`);
}
warn(message: string): void {
console.warn(`[SERVICE WARNING] ${message}`);
}
processData(data: any): string {
console.log(`[SERVICE] Processing data: ${JSON.stringify(data)}`);
return `Processed: ${JSON.stringify(data)}`;
}
}
console.log("n--- Using ES6 Proxy for Method Interception (Logging) ---");
const realLoggerService = new LoggerService();
const loggingProxyHandler: ProxyHandler<LoggerService> = {
get(target: LoggerService, property: string | symbol, receiver: any): any {
// 检查属性是否是函数
const value = Reflect.get(target, property, receiver);
if (typeof value === 'function') {
// 返回一个包装过的函数,用于拦截方法调用
return function (...args: any[]): any {
console.log(`[PROXY LOG] Method '${String(property)}' called with arguments: ${JSON.stringify(args)}`);
// 确保方法内部的this指向正确的target
const result = Reflect.apply(value, target, args); // 注意这里使用target而不是receiver
console.log(`[PROXY LOG] Method '${String(property)}' returned: ${JSON.stringify(result)}`);
return result;
};
}
return value; // 非函数属性直接返回
}
};
const proxiedLoggerService = new Proxy(realLoggerService, loggingProxyHandler);
proxiedLoggerService.log("User logged in successfully.");
proxiedLoggerService.warn("Deprecated feature used.");
const processedResult = proxiedLoggerService.processData({ id: 1, name: "Test" });
console.log(`Client received: ${processedResult}`);
/*
输出示例:
--- Using ES6 Proxy for Method Interception (Logging) ---
[PROXY LOG] Method 'log' called with arguments: ["User logged in successfully."]
[SERVICE LOG] User logged in successfully.
[PROXY LOG] Method 'log' returned: null
[PROXY LOG] Method 'warn' called with arguments: ["Deprecated feature used."]
[SERVICE WARNING] Deprecated feature used.
[PROXY LOG] Method 'warn' returned: null
[PROXY LOG] Method 'processData' called with arguments: [{"id":1,"name":"Test"}]
[SERVICE] Processing data: {"id":1,"name":"Test"}
[PROXY LOG] Method 'processData' returned: "Processed: {"id":1,"name":"Test"}"
Client received: Processed: {"id":1,"name":"Test"}
*/
在这个例子中,我们通过 get 陷阱拦截了对 LoggerService 实例属性的访问。当访问的属性是一个函数时,我们返回了一个新的函数。这个新函数在调用真实方法前后打印日志,并通过 Reflect.apply 确保 this 上下文的正确性以及原始方法的执行。这种方式极大地简化了为所有方法添加日志的逻辑,而无需手动修改每个方法。
2.5 实现延迟加载 (Virtual Proxy 替代)
ES6 Proxy 同样能优雅地实现延迟加载,特别适合于对对象中某个或某些特定属性的延迟加载,而不是整个对象的延迟加载。
代码示例: 延迟加载数据
假设我们有一个 UserConfig 对象,其中包含一个 settings 属性,这个 settings 对象可能非常大,并且只在用户第一次访问时才需要从远程服务器加载。
// 模拟一个异步加载耗时数据的函数
async function fetchUserSettingsFromServer(userId: string): Promise<any> {
console.log(`[SIMULATED API] Fetching settings for user ${userId}...`);
return new Promise(resolve => {
setTimeout(() => {
const settings = {
theme: 'dark',
fontSize: 14,
notifications: { email: true, sms: false },
lastLogin: new Date().toISOString()
};
console.log(`[SIMULATED API] Settings for user ${userId} fetched.`);
resolve(settings);
}, 1500); // 模拟1.5秒的网络延迟
});
}
// 目标对象:一个普通的UserConfig,settings属性初始为null
interface UserConfig {
userId: string;
username: string;
settings: any | null; // 初始为null,待加载
}
console.log("n--- Using ES6 Proxy for Lazy Loading (Property-specific) ---");
const userConfigTarget: UserConfig = {
userId: "user123",
username: "Alice",
settings: null // 初始为空
};
let settingsPromise: Promise<any> | null = null;
const lazyLoadingProxyHandler: ProxyHandler<UserConfig> = {
get(target: UserConfig, property: string | symbol, receiver: any): any {
if (property === 'settings' && target.settings === null) {
if (!settingsPromise) {
console.log(`[PROXY] First access to 'settings'. Initiating lazy load.`);
settingsPromise = fetchUserSettingsFromServer(target.userId)
.then(data => {
target.settings = data; // 加载完成后更新真实对象
settingsPromise = null; // 清除promise,下次可以重新加载(如果需要)或直接返回缓存
return data;
});
}
// 返回Promise,让客户端可以await
return settingsPromise;
}
return Reflect.get(target, property, receiver);
},
set(target: UserConfig, property: string | symbol, value: any, receiver: any): boolean {
// 如果settings正在加载中,且客户端尝试设置settings,可以进行额外处理
if (property === 'settings' && settingsPromise) {
console.warn(`[PROXY] Attempted to set 'settings' while it's still loading. Operation ignored for now.`);
return false; // 或者抛出错误
}
return Reflect.set(target, property, value, receiver);
}
};
const proxiedUserConfig = new Proxy(userConfigTarget, lazyLoadingProxyHandler);
// 客户端访问其他属性,不会触发settings加载
console.log(`User ID: ${proxiedUserConfig.userId}`);
console.log(`Username: ${proxiedUserConfig.username}`);
// 第一次访问settings,触发加载
(async () => {
console.log("nClient: Accessing settings for the first time...");
const settings = await proxiedUserConfig.settings;
console.log("Client: Settings loaded and accessed:", settings);
// 第二次访问settings,直接返回已加载的数据
console.log("nClient: Accessing settings for the second time...");
const cachedSettings = await proxiedUserConfig.settings; // 注意这里仍然是await,因为返回的是Promise
console.log("Client: Settings (cached) accessed:", cachedSettings);
})();
/*
输出示例:
--- Using ES6 Proxy for Lazy Loading (Property-specific) ---
User ID: user123
Username: Alice
Client: Accessing settings for the first time...
[PROXY] First access to 'settings'. Initiating lazy load.
[SIMULATED API] Fetching settings for user user123...
[SIMULATED API] Settings for user user123 fetched.
Client: Settings loaded and accessed: { theme: 'dark', fontSize: 14, notifications: { email: true, sms: false }, lastLogin: '2023-10-27T...' }
Client: Accessing settings for the second time...
Client: Settings (cached) accessed: { theme: 'dark', fontSize: 14, notifications: { email: true, sms: false }, lastLogin: '2023-10-27T...' }
*/
在这个例子中,当客户端第一次访问 proxiedUserConfig.settings 属性时,get 陷阱被触发。它检测到 target.settings 为 null,然后异步调用 fetchUserSettingsFromServer 函数来加载数据。在加载过程中,它返回一个 Promise。一旦数据加载完成,target.settings 会被更新,后续的访问将直接返回已加载的数据(或其 Promise)。这种方式非常适合于异步数据加载的延迟处理。
2.6 优点与局限性
优点:
- 极度灵活: 可以拦截几乎所有对象操作,提供细粒度的控制,实现数据校验、格式化、ORM、状态管理、日志记录、权限控制等多种功能。
- 元编程能力: 允许在运行时动态地改变对象的底层行为,而无需修改原始对象的定义。
- 代码简洁: 无需创建额外的代理类,直接通过
handler对象定义拦截逻辑,代码更集中、简洁。 - 语言原生支持: 作为JavaScript语言特性,性能通常经过引擎优化。
- 透明性: 对客户端来说,代理对象几乎与真实对象无异,提高了封装性。
局限性:
- 性能开销: 每次对代理对象的操作都会经过陷阱方法的处理,可能比直接操作目标对象产生略微的性能开销。在高性能敏感的场景下需要权衡。
- 调试复杂: 对象的行为在运行时被动态拦截和修改,有时难以追踪问题和调试。
this问题: 在陷阱方法内部,this的指向可能不是预期,需要使用ReflectAPI(如Reflect.apply,Reflect.get,Reflect.set)来确保this的正确绑定和操作的转发。- 兼容性: ES6 Proxy 是较新的特性,不支持IE浏览器和部分旧版Node.js环境。
3. 代理模式与 ES6 Proxy 的核心区别与对比
现在,我们已经分别深入了解了代理模式和ES6 Proxy。是时候将它们放在一起,进行一次全面而细致的对比了。
3.1 本质区别
- 代理模式: 是一种结构型设计模式。它关注的是通过引入一个结构(一个代理类)来控制对另一个对象(真实主题类)的访问。其核心是“类”和“接口”的抽象与实现。
- ES6 Proxy: 是一种JavaScript语言特性(或称元编程能力)。它提供了一种在语言层面拦截和自定义对象基本操作的机制,与具体的类结构无关,更多地关注“运行时行为的拦截和修改”。
3.2 实现方式
- 代理模式: 需要手动定义一个与真实主题实现相同接口的代理类。这个代理类在内部持有真实主题的实例,并在自己的方法中调用真实主题的对应方法,同时插入额外的逻辑。
- ES6 Proxy: 通过
new Proxy(target, handler)在运行时动态创建。无需预先定义额外的代理类,只需提供一个handler对象来定义拦截逻辑。
3.3 粒度与灵活性
- 代理模式: 通常拦截的是方法调用或属性访问的有限集合。你需要在代理类中显式地实现所有你希望代理的方法。如果真实主题有100个方法,而你只想代理其中2个,你仍然需要在代理类中实现这2个方法,并为其他98个方法编写转发逻辑(或不实现它们,导致客户端无法访问)。
- ES6 Proxy: 可以拦截对象的几乎所有基本操作,包括属性的读取 (
get)、设置 (set)、删除 (deleteProperty),方法的调用 (apply),构造函数的调用 (construct),甚至in操作符 (has) 和Object.keys()(ownKeys) 等。这种粒度是代理模式难以比拟的,提供了极高的灵活性。
3.4 侵入性
- 代理模式: 对客户端代码有一定侵入性。尽管代理对象和真实对象实现相同的接口,但客户端通常需要明确地知道它正在与一个代理对象交互,或者至少需要通过代理来实例化真实对象。
- ES6 Proxy: 对客户端代码几乎无侵入性。代理对象可以完全模拟真实对象,对客户端来说,两者在行为上是透明的,客户端无需知道它是否在与代理交互。
3.5 用途侧重
- 代理模式: 更侧重于结构控制和访问控制。它适用于构建如远程代理(处理跨进程/网络通信)、虚拟代理(延迟加载整个对象)、保护代理(权限控制)等场景。它的设计意图是为真实对象提供一个“替身”。
- ES6 Proxy: 更侧重于行为控制和元编程。它适用于实现数据校验、ORM(对象关系映射)、响应式状态管理(如Vue 3的响应式系统)、日志记录、性能监控、以及实现更细粒度的延迟加载等。它的设计意图是允许你“重新定义对象的基本操作”。
3.6 适用语言
- 代理模式: 是一种通用的设计原则,适用于所有支持面向对象编程(OOP)的语言,如Java、C#、Python、C++、TypeScript等。
- ES6 Proxy: 是JavaScript语言独有的特性(以及其他实现了ECMAScript规范的语言)。
对比表格
为了更直观地展现两者的差异,我们制作一个对比表格:
| 特性 | 代理模式 (Proxy Pattern) | ES6 Proxy |
|---|---|---|
| 本质 | 结构型设计模式 | JavaScript 语言特性 (元编程) |
| 实现方式 | 显式创建代理类,实现相同接口 | new Proxy(target, handler) 动态创建 |
| 拦截粒度 | 需在代理类中显式实现被拦截的方法/属性 | 拦截几乎所有对象基本操作 (get, set, apply…) |
| 灵活性 | 相对固定,修改行为需修改代理类 | 极度灵活,可运行时动态修改行为 |
| 侵入性 | 客户端可能知道是代理(接口相同,但对象不同) | 客户端通常无感知,行为透明 |
| 应用场景 | 远程、虚拟、保护、智能引用代理 | 数据校验、ORM、状态管理、日志、性能监控、延迟加载 |
| 适用语言 | 各种 OOP 语言 | JavaScript (ES6+) |
| 性能 | 额外一层方法调用开销 | 每次操作都通过陷阱,可能略有开销 |
| 调试 | 相对直接,行为在类中定义 | 行为可能被动态修改,调试稍复杂 |
4. 实例深入:方法拦截与延迟加载的异同实现
我们将通过更具体的案例,再次对比两者在实现方法拦截和延迟加载时的异同。
4.1 案例一:方法调用日志记录 (Method Interception)
这个场景要求我们记录一个服务中所有方法被调用的情况。
代理模式实现 (TypeScript/JS Class):
// 抽象接口
interface ICalculator {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
}
// 真实主题
class RealCalculator implements ICalculator {
add(a: number, b: number): number {
console.log(`[RealCalculator] Adding ${a} and ${b}`);
return a + b;
}
subtract(a: number, b: number): number {
console.log(`[RealCalculator] Subtracting ${b} from ${a}`);
return a - b;
}
}
// 代理主题:添加日志功能
class LoggingCalculatorProxy implements ICalculator {
private realCalculator: RealCalculator;
constructor(calculator: RealCalculator) {
this.realCalculator = calculator;
}
add(a: number, b: number): number {
console.log(`[Proxy] Before calling add(${a}, ${b})`);
const result = this.realCalculator.add(a, b);
console.log(`[Proxy] After calling add, result is ${result}`);
return result;
}
subtract(a: number, b: number): number {
console.log(`[Proxy] Before calling subtract(${a}, ${b})`);
const result = this.realCalculator.subtract(a, b);
console.log(`[Proxy] After calling subtract, result is ${result}`);
return result;
}
}
console.log("n--- Method Interception with Proxy Pattern ---");
const realCalc = new RealCalculator();
const loggingCalc = new LoggingCalculatorProxy(realCalc);
console.log(`Result of add: ${loggingCalc.add(10, 5)}`);
console.log(`Result of subtract: ${loggingCalc.subtract(10, 5)}`);
/*
输出示例:
--- Method Interception with Proxy Pattern ---
[Proxy] Before calling add(10, 5)
[RealCalculator] Adding 10 and 5
[Proxy] After calling add, result is 15
Result of add: 15
[Proxy] Before calling subtract(10, 5)
[RealCalculator] Subtracting 5 from 10
[Proxy] After calling subtract, result is 5
Result of subtract: 5
*/
分析: 这种方式要求我们为 LoggingCalculatorProxy 类中的每个方法都手动编写日志逻辑。如果 ICalculator 接口有几十个方法,这将变得非常冗余且容易出错。每次新增或修改方法,都需要同步更新代理类。
ES6 Proxy 实现:
// 真实主题 (可以是一个类,也可以是一个普通对象)
class RealCalculatorES6 {
add(a: number, b: number): number {
console.log(`[RealCalculatorES6] Adding ${a} and ${b}`);
return a + b;
}
subtract(a: number, b: number): number {
console.log(`[RealCalculatorES6] Subtracting ${b} from ${a}`);
return a - b;
}
}
console.log("n--- Method Interception with ES6 Proxy ---");
const realCalcES6 = new RealCalculatorES6();
const loggingHandler: ProxyHandler<RealCalculatorES6> = {
get(target: RealCalculatorES6, prop: string | symbol, receiver: any) {
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function') {
return function (...args: any[]) {
console.log(`[ES6 Proxy] Calling method '${String(prop)}' with args: ${JSON.stringify(args)}`);
const result = Reflect.apply(value, target, args); // 确保正确的this
console.log(`[ES6 Proxy] Method '${String(prop)}' returned: ${JSON.stringify(result)}`);
return result;
};
}
return value;
}
};
const proxiedCalcES6 = new Proxy(realCalcES6, loggingHandler);
console.log(`Result of add: ${proxiedCalcES6.add(20, 10)}`);
console.log(`Result of subtract: ${proxiedCalcES6.subtract(20, 10)}`);
/*
输出示例:
--- Method Interception with ES6 Proxy ---
[ES6 Proxy] Calling method 'add' with args: [20,10]
[RealCalculatorES6] Adding 20 and 10
[ES6 Proxy] Method 'add' returned: 30
Result of add: 30
[ES6 Proxy] Calling method 'subtract' with args: [20,10]
[RealCalculatorES6] Subtracting 10 from 20
[ES6 Proxy] Method 'subtract' returned: 10
Result of subtract: 10
*/
对比分析: ES6 Proxy 在方法拦截方面展现出压倒性的优势。我们只需一个 get 陷阱,就能统一处理所有方法的调用,而无需关心具体有多少个方法。代码更简洁、更通用、更易于维护。如果 RealCalculatorES6 增加了一个 multiply 方法,loggingHandler 无需任何修改即可自动为其添加日志。
4.2 案例二:延迟加载大型数据对象 (Lazy Loading)
这个场景要求我们延迟加载一个耗时创建或获取的对象。
代理模式实现 (TypeScript/JS Class):
我们再次使用之前 ProxyImage 的例子,它完美地演示了虚拟代理如何延迟加载整个对象。
// 抽象接口
interface IHeavyResource {
getData(): string;
}
// 真实主题:模拟一个耗时创建的大型资源
class HeavyResource implements IHeavyResource {
private data: string;
constructor() {
console.log("[HeavyResource] Initializing HeavyResource... (takes time)");
// 模拟耗时操作
for (let i = 0; i < 500000000; i++) {}
this.data = "This is some very heavy data.";
console.log("[HeavyResource] HeavyResource initialized.");
}
getData(): string {
return this.data;
}
}
// 代理主题:虚拟代理实现延迟加载
class LazyResourceProxy implements IHeavyResource {
private realResource: HeavyResource | null = null;
getData(): string {
if (this.realResource === null) {
console.log("[LazyResourceProxy] Real resource not yet created. Creating now...");
this.realResource = new HeavyResource(); // 第一次访问时才创建真实对象
}
console.log("[LazyResourceProxy] Accessing data from real resource.");
return this.realResource.getData();
}
}
console.log("n--- Lazy Loading with Proxy Pattern ---");
const lazyResource = new LazyResourceProxy();
console.log("Client: Before first data access.");
console.log(`Client: Data: ${lazyResource.getData()}`); // 第一次访问,触发资源创建
console.log("Client: After first data access.");
console.log("nClient: Before second data access.");
console.log(`Client: Data: ${lazyResource.getData()}`); // 第二次访问,直接使用已创建资源
console.log("Client: After second data access.");
/*
输出示例:
--- Lazy Loading with Proxy Pattern ---
Client: Before first data access.
[LazyResourceProxy] Real resource not yet created. Creating now...
[HeavyResource] Initializing HeavyResource... (takes time)
[HeavyResource] HeavyResource initialized.
[LazyResourceProxy] Accessing data from real resource.
Client: Data: This is some very heavy data.
Client: After first data access.
Client: Before second data access.
[LazyResourceProxy] Accessing data from real resource.
Client: Data: This is some very heavy data.
Client: After second data access.
*/
分析: 代理模式非常适合延迟加载“整个对象”。它通过引入一个代理对象作为真实对象的占位符,将真实对象的创建和初始化推迟到第一次被使用时。这种方式要求真实对象和代理对象实现相同的接口。
ES6 Proxy 实现:
我们再次使用之前 proxiedUserConfig 的例子,它演示了如何延迟加载对象的一个特定属性。
// 模拟异步获取数据
async function fetchLargeReportData(reportId: string): Promise<string> {
console.log(`[SIMULATED API] Fetching large report for ID ${reportId}...`);
return new Promise(resolve => {
setTimeout(() => {
const data = `Report data for ${reportId}: This is a very large and complex report content, fetched from a remote server after significant processing.`;
console.log(`[SIMULATED API] Report data for ID ${reportId} fetched.`);
resolve(data);
}, 2000); // 模拟2秒的网络和处理延迟
});
}
// 目标对象:一个包含报告ID但报告内容为空的ReportManager
interface ReportManager {
reportId: string;
reportContent: string | Promise<string>; // 初始为空或Promise
}
console.log("n--- Lazy Loading (Property-specific) with ES6 Proxy ---");
const reportManagerTarget: ReportManager = {
reportId: "Q4_SALES_2023",
reportContent: "" // 初始为空字符串
};
let reportContentPromise: Promise<string> | null = null;
const lazyReportProxyHandler: ProxyHandler<ReportManager> = {
get(target: ReportManager, prop: string | symbol, receiver: any): any {
if (prop === 'reportContent' && target.reportContent === "") {
if (!reportContentPromise) {
console.log(`[ES6 Proxy] First access to 'reportContent'. Initiating lazy load.`);
reportContentPromise = fetchLargeReportData(target.reportId)
.then(data => {
target.reportContent = data; // 更新真实对象
reportContentPromise = null; // 清除promise,下次可以重新加载或直接返回缓存
return data;
});
}
return reportContentPromise; // 返回Promise,让客户端可以await
}
return Reflect.get(target, prop, receiver);
}
};
const proxiedReportManager = new Proxy(reportManagerTarget, lazyReportProxyHandler);
console.log(`Client: Report ID: ${proxiedReportManager.reportId}`);
(async () => {
console.log("nClient: Accessing reportContent for the first time...");
const content1 = await proxiedReportManager.reportContent;
console.log(`Client: Report Content (first access): ${content1.substring(0, 100)}...`); // 截取一部分显示
console.log("nClient: Accessing reportContent for the second time...");
const content2 = await proxiedReportManager.reportContent; // 仍然是await,因为get返回的是Promise
console.log(`Client: Report Content (second access, cached): ${content2.substring(0, 100)}...`);
})();
/*
输出示例:
--- Lazy Loading (Property-specific) with ES6 Proxy ---
Client: Report ID: Q4_SALES_2023
Client: Accessing reportContent for the first time...
[ES6 Proxy] First access to 'reportContent'. Initiating lazy load.
[SIMULATED API] Fetching large report for ID Q4_SALES_2023...
[SIMULATED API] Report data for ID Q4_SALES_2023 fetched.
Client: Report Content (first access): Report data for Q4_SALES_2023: This is a very large and complex report content, fetched fr...
Client: Accessing reportContent for the second time...
Client: Report Content (second access, cached): Report data for Q4_SALES_2023: This is a very large and complex report content, fetched fr...
*/
对比分析:
- 代理模式的延迟加载更侧重于整个对象的创建。它隐藏了真实对象的初始化细节,在需要时才创建。
- ES6 Proxy的延迟加载可以更细粒度地作用于对象内部的特定属性。这在处理异步数据加载或按需计算属性值时非常强大。客户端可以像访问普通属性一样访问,但底层可能是异步加载的。
5. 最佳实践与选择考量
理解了代理模式和ES6 Proxy的异同后,我们如何在实际项目中做出选择呢?
-
何时选择代理模式:
- 当你的项目是使用强类型、面向对象语言(如Java, C#, TypeScript),且需要严格的类型检查和接口约束时。
- 当代理的职责比较明确,且代理对象与真实对象有清晰的结构关系(例如,代理是真实对象的一个同接口的包装器)时。
- 当需要实现远程代理、跨进程通信的场景时,代理模式的结构化特点更为适用。
- 当你需要延迟加载“整个”复杂对象,且该对象的创建成本很高时。
-
何时选择 ES6 Proxy:
- 当你正在使用JavaScript开发,且不需要兼容旧版浏览器(如IE)时。
- 当需要在运行时动态地修改或增强对象的行为,而无需修改原始对象定义时。
- 当需要对对象的底层操作(如属性访问、赋值、函数调用、构造函数等)进行细粒度控制时。
- 当你在构建框架、库,或者实现元编程功能时(例如Vue 3的响应式系统、ORM库、数据校验器等)。
- 当需要实现属性级别的延迟加载或异步属性访问时。
- 当需要为对象添加统一的日志、权限、缓存等横切关注点,且这些关注点需要应用于多个方法或属性时。
-
结合使用:
有时,经典代理模式的某些结构和思想,可以用ES6 Proxy更优雅、更动态地实现。例如,一个虚拟代理的逻辑完全可以用ES6 Proxy的get陷阱来实现,从而避免创建额外的代理类。ES6 Proxy提供的是一种能力,这种能力可以用来实现代理模式所定义的某些场景,但其应用远超代理模式的范畴。
代理模式和ES6 Proxy都是软件开发中处理对象访问和行为的重要工具,但它们服务于不同的目标,并在不同的抽象层次上运作。代理模式是一种通用的设计原则,通过结构化的方式引入代理对象;而ES6 Proxy是JavaScript语言提供的一种强大的元编程能力,允许在运行时动态拦截和修改对象的基本操作。深入理解它们的本质区别和适用场景,能够帮助开发者在实际项目中做出更明智的技术选型,写出更健壮、更灵活、更高效的代码。