结构型模式(上)—— 适配器、装饰器、代理模式
创建型模式解决”对象从哪来”的问题,结构型模式解决”对象怎么组合在一起”的问题。适配器让不兼容的接口协同工作,装饰器在不改原有代码的前提下增强功能,代理则在访问对象之前插入一道”关卡”。
📋 开篇自测:你已经知道多少?
- 你在项目中是否遇到过”两个库的接口不一致,需要写一层转换”的情况?
- TypeScript 的
@decorator语法和装饰器模式是什么关系?- 你能列举三种常见的”代理”场景吗?
一、适配器模式:接口不兼容?加个转换插头
1.1 从第三方地图服务迁移说起
假设你的打车应用最初使用了 A 地图服务来计算路线。整个应用有几十个模块都在调用 A 地图的 API:
// A 地图的接口
interface AMapService {
calculateRoute(startLng: number, startLat: number, endLng: number, endLat: number): {
distance: number; // 米
duration: number; // 秒
polyline: [number, number][];
};
}
// 应用中到处都是这样的调用
function estimatePrice(aMap: AMapService, start: Coord, end: Coord): number {
const route = aMap.calculateRoute(start.lng, start.lat, end.lng, end.lat);
return route.distance * 0.003 + route.duration * 0.01;
}
现在公司决定切换到 B 地图服务。B 地图的 API 长这样:
// B 地图的接口——完全不同的方法签名和返回格式
interface BMapService {
getDirections(origin: { longitude: number; latitude: number }, destination: { longitude: number; latitude: number }): {
totalDistanceKm: number;
estimatedTimeMin: number;
path: Array<{ lng: number; lat: number }>;
};
}
方法名不同、参数格式不同、返回值结构不同。如果直接替换,你需要改动几十个模块的调用代码。
适配器模式的核心思想是:创建一个中间层,将一个类的接口转换成客户端期望的另一个接口。 就像出国旅行时带的电源转换插头——你的充电器插头不变,转换插头负责适配当地的插座。
1.2 对象适配器实现
// 目标接口:我们的应用期望的地图接口(保持和 A 地图一致)
interface MapService {
calculateRoute(startLng: number, startLat: number, endLng: number, endLat: number): {
distance: number;
duration: number;
polyline: [number, number][];
};
}
// 适配器:把 B 地图包装成 MapService 接口
class BMapAdapter implements MapService {
constructor(private bMap: BMapService) {}
calculateRoute(startLng: number, startLat: number, endLng: number, endLat: number) {
// 1. 转换参数格式
const origin = { longitude: startLng, latitude: startLat };
const destination = { longitude: endLng, latitude: endLat };
// 2. 调用 B 地图的真实方法
const result = this.bMap.getDirections(origin, destination);
// 3. 转换返回值格式
return {
distance: result.totalDistanceKm * 1000, // km → m
duration: result.estimatedTimeMin * 60, // min → s
polyline: result.path.map(p => [p.lng, p.lat] as [number, number]),
};
}
}
使用时,应用代码几乎不需要改动:
// 之前
const mapService: MapService = new AMapServiceImpl();
// 之后——只改这一行
const mapService: MapService = new BMapAdapter(new BMapServiceImpl());
// 其他所有调用代码完全不变
const price = estimatePrice(mapService, start, end);
1.3 适配器的变体:函数适配器
在 TypeScript 中,很多时候不需要搞一个完整的适配器类,一个转换函数就够了:
// 函数适配器:把 B 地图的回调风格 API 适配成 Promise 风格
function adaptCallbackToPromise<T>(
fn: (callback: (err: Error | null, result: T) => void) => void
): Promise<T> {
return new Promise((resolve, reject) => {
fn((err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}
// Node.js 中经典的 util.promisify 就是这种思路
🤔 想一想 前端项目中的”API 层”(把后端返回的蛇形命名转成驼峰命名、把时间戳转成 Date 对象等)是不是一种适配器模式的应用?
二、装饰器模式:不改源码,动态增强
2.1 从日志增强说起
假设你有一个数据访问层,提供基本的 CRUD 操作:
interface DataService {
query(sql: string): Promise<any[]>;
execute(sql: string): Promise<number>;
}
class PostgresDataService implements DataService {
async query(sql: string): Promise<any[]> {
// 实际执行 SQL 查询...
return [];
}
async execute(sql: string): Promise<number> {
// 实际执行 SQL 命令...
return 0;
}
}
现在你需要给所有数据库操作加上日志记录。最直接的做法是修改 PostgresDataService,在每个方法里加上日志代码。但这样做违反了单一职责原则——数据访问和日志记录是两件不同的事。而且如果你还有 MySQLDataService、SQLiteDataService,每个都要改一遍。
更棘手的是:后面可能还要加上性能监控、错误重试、缓存……难道每加一个功能都去改所有的 Service 类?
装饰器模式的核心思想是:动态地给对象添加额外的职责,而不改变其接口。 装饰器和被装饰对象实现同一个接口,通过”包一层”的方式增强功能。
2.2 用装饰器层层增强
// 日志装饰器
class LoggingDataService implements DataService {
constructor(private wrapped: DataService) {}
async query(sql: string): Promise<any[]> {
console.log(`[SQL Query] ${sql}`);
const startTime = Date.now();
const result = await this.wrapped.query(sql);
console.log(`[SQL Query] 完成,耗时 ${Date.now() - startTime}ms,返回 ${result.length} 行`);
return result;
}
async execute(sql: string): Promise<number> {
console.log(`[SQL Execute] ${sql}`);
const startTime = Date.now();
const affected = await this.wrapped.execute(sql);
console.log(`[SQL Execute] 完成,耗时 ${Date.now() - startTime}ms,影响 ${affected} 行`);
return affected;
}
}
// 重试装饰器
class RetryDataService implements DataService {
constructor(
private wrapped: DataService,
private maxRetries: number = 3
) {}
async query(sql: string): Promise<any[]> {
return this.withRetry(() => this.wrapped.query(sql));
}
async execute(sql: string): Promise<number> {
return this.withRetry(() => this.wrapped.execute(sql));
}
private async withRetry<T>(operation: () => Promise<T>): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err as Error;
console.warn(`操作失败,第 ${attempt} 次重试...`);
await new Promise(r => setTimeout(r, attempt * 1000));
}
}
throw lastError;
}
}
// 缓存装饰器
class CachingDataService implements DataService {
private cache = new Map<string, { data: any[]; timestamp: number }>();
private ttl: number;
constructor(private wrapped: DataService, ttlSeconds: number = 60) {
this.ttl = ttlSeconds * 1000;
}
async query(sql: string): Promise<any[]> {
const cached = this.cache.get(sql);
if (cached && Date.now() - cached.timestamp < this.ttl) {
console.log('[Cache Hit]', sql);
return cached.data;
}
const result = await this.wrapped.query(sql);
this.cache.set(sql, { data: result, timestamp: Date.now() });
return result;
}
async execute(sql: string): Promise<number> {
// 写操作不走缓存,但需要清除相关缓存
this.cache.clear();
return this.wrapped.execute(sql);
}
}
组合使用——像套娃一样层层包裹:
// 最内层是真正的数据服务
let service: DataService = new PostgresDataService();
// 往外套:先加重试(最内层,离真正的操作最近)
service = new RetryDataService(service, 3);
// 再加缓存
service = new CachingDataService(service, 120);
// 最外层加日志
service = new LoggingDataService(service);
// 使用时完全透明
const users = await service.query('SELECT * FROM users');
执行顺序:日志记录 → 检查缓存 → (缓存未命中) → 重试机制 → 真正的数据库查询。
2.3 TypeScript 装饰器语法
TypeScript 的 @decorator 语法与装饰器模式有着天然的联系,但二者并不完全等同。语法层面的装饰器主要用于类和方法的元编程。
注意:下面的代码使用的是 legacy experimental decorators 语法,需要在
tsconfig.json中设置"experimentalDecorators": true才能编译。自 TypeScript 5.0 起,默认支持的是 TC39 Stage 3 Decorators,其 API 签名与 legacy 版本不同(两参数形式)。本课程选择 legacy 语法是因为 Angular、NestJS 等框架的存量代码仍在使用这套 API。不过,生态系统正在积极向 Stage 3 装饰器迁移,新项目建议优先使用 Stage 3 语法(无需开启experimentalDecorators)。
// 方法装饰器:给任意方法加上性能监控(legacy experimental decorators 语法)
function measureTime(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = performance.now();
const result = await originalMethod.apply(this, args);
const duration = performance.now() - start;
console.log(`${propertyKey} 执行耗时: ${duration.toFixed(2)}ms`);
return result;
};
return descriptor;
}
class ProductService {
@measureTime
async findAll(): Promise<Product[]> {
// 实际查询逻辑...
return [];
}
@measureTime
async findById(id: string): Promise<Product | null> {
// 实际查询逻辑...
return null;
}
}
装饰器语法在声明时应用(装饰器函数在运行时被调用,但应用时机是类/方法定义时而非调用时),装饰器模式则是在使用时组装(客户端显式地将一个对象包裹在另一个对象中)。两者在”不改原有代码而增强功能”这一点上殊途同归。
三、代理模式:在目标之前设一道关卡
3.1 从图片懒加载说起
假设你在开发一个图片墙应用。页面上有几百张高清大图,如果一次性全部加载,用户会看到长时间的白屏。更好的体验是:先显示一个占位符或低分辨率缩略图,当图片进入可视区域时才去加载真正的高清图。
// 真实的图片加载器
interface ImageLoader {
load(url: string): Promise<HTMLImageElement>;
getDisplayUrl(url: string): string;
}
class RealImageLoader implements ImageLoader {
private loadedImages = new Map<string, HTMLImageElement>();
async load(url: string): Promise<HTMLImageElement> {
console.log(`正在加载高清图片: ${url}`);
// 模拟网络请求
const img = new Image();
img.src = url;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`加载失败: ${url}`));
});
this.loadedImages.set(url, img);
return img;
}
getDisplayUrl(url: string): string {
return url; // 直接返回高清 URL
}
}
代理模式的核心思想是:为一个对象提供一个替身或占位符,以控制对这个对象的访问。 代理和真实对象实现同一个接口,客户端无法区分它们。
3.2 虚拟代理:延迟加载
class LazyImageProxy implements ImageLoader {
private realLoader: RealImageLoader | null = null;
private loadingPromises = new Map<string, Promise<HTMLImageElement>>();
private getRealLoader(): RealImageLoader {
if (!this.realLoader) {
this.realLoader = new RealImageLoader();
}
return this.realLoader;
}
async load(url: string): Promise<HTMLImageElement> {
// 避免重复加载同一张图
if (this.loadingPromises.has(url)) {
return this.loadingPromises.get(url)!;
}
const promise = this.getRealLoader().load(url);
this.loadingPromises.set(url, promise);
return promise;
}
getDisplayUrl(url: string): string {
// 在真正的图片加载完成前,返回缩略图 URL
return url.replace('/full/', '/thumb/');
}
}
3.3 保护代理:权限控制
另一个经典场景是权限控制。比如一个文档管理系统,不同角色对文档有不同的操作权限:
interface DocumentService {
read(docId: string): Promise<Document>;
update(docId: string, content: string): Promise<void>;
delete(docId: string): Promise<void>;
}
class RealDocumentService implements DocumentService {
async read(docId: string): Promise<Document> {
console.log(`读取文档: ${docId}`);
return { id: docId, content: '文档内容...' } as Document;
}
async update(docId: string, content: string): Promise<void> {
console.log(`更新文档: ${docId}`);
}
async delete(docId: string): Promise<void> {
console.log(`删除文档: ${docId}`);
}
}
// 保护代理:根据用户角色控制访问
class PermissionProxy implements DocumentService {
constructor(
private realService: DocumentService,
private userRole: 'viewer' | 'editor' | 'admin'
) {}
async read(docId: string): Promise<Document> {
// 所有角色都能读
return this.realService.read(docId);
}
async update(docId: string, content: string): Promise<void> {
if (this.userRole === 'viewer') {
throw new Error('权限不足:查看者无法编辑文档');
}
return this.realService.update(docId, content);
}
async delete(docId: string): Promise<void> {
if (this.userRole !== 'admin') {
throw new Error('权限不足:只有管理员可以删除文档');
}
return this.realService.delete(docId);
}
}
// 使用
const docService: DocumentService = new PermissionProxy(
new RealDocumentService(),
currentUser.role
);
3.4 缓存代理:避免重复计算
// 一个执行代价很高的搜索服务
interface SearchService {
search(keyword: string): Promise<SearchResult[]>;
}
class ElasticSearchService implements SearchService {
async search(keyword: string): Promise<SearchResult[]> {
console.log(`对 ES 集群执行搜索: ${keyword}`);
// 真正的搜索逻辑,可能耗时数百毫秒
return [];
}
}
class CachingSearchProxy implements SearchService {
private cache = new Map<string, { results: SearchResult[]; expiry: number }>();
constructor(
private realService: SearchService,
private ttlMs: number = 30000
) {}
async search(keyword: string): Promise<SearchResult[]> {
const cached = this.cache.get(keyword);
if (cached && Date.now() < cached.expiry) {
console.log(`[缓存命中] ${keyword}`);
return cached.results;
}
const results = await this.realService.search(keyword);
this.cache.set(keyword, {
results,
expiry: Date.now() + this.ttlMs,
});
return results;
}
}
3.5 代理 vs 装饰器:它们看起来好像啊?
代理和装饰器在代码结构上非常相似——都是”包一层”。关键区别在于意图:
| 维度 | 装饰器 | 代理 |
|---|---|---|
| 意图 | 增强功能 | 控制访问 |
| 客户端是否知道 | 通常知道(主动选择要套哪些装饰器) | 通常不知道(以为自己在用真实对象) |
| 谁创建被包装对象 | 客户端创建后传入 | 代理自己创建或管理 |
| 层数 | 常常多层嵌套 | 通常单层 |
| 典型场景 | 加日志、加缓存、加压缩 | 懒加载、权限控制、远程调用 |
一句话总结:装饰器说的是”让它能做更多”,代理说的是”我来决定你能不能做”。
🤔 想一想 JavaScript 的
Proxy对象和代理模式有什么关系?它能实现保护代理和虚拟代理吗?
四、本章小结
本章学习了结构型模式的三个高频成员:
- 适配器模式:解决接口不兼容的问题,像电源转换插头一样在两个不兼容的接口之间搭桥。核心是”转换”。
- 装饰器模式:在不修改原有代码的前提下增强功能,像套娃一样层层包裹。核心是”增强”。
- 代理模式:在访问真实对象之前插入一道关卡,控制访问行为。核心是”控制”。
这三个模式的代码结构都是”包一层”,区别在于意图。理解意图比记住代码模板更重要。
下一章我们继续结构型模式的后四位——外观、桥接、组合、享元,它们处理的是更宏观层面的结构组织问题。
📝 结尾自测
- 适配器模式解决的核心问题是什么?在什么场景下你会首先想到使用适配器?
- 装饰器模式如何做到”不修改原有代码”就能增强功能?它依赖什么前提条件?
- 代理模式有哪几种常见变体?各自的典型场景是什么?
- 装饰器模式和代理模式的代码结构很相似,如何从意图层面区分它们?
- 如果你要为一个旧的 XMLHttpRequest 封装层提供和 Fetch API 一致的接口,你会用哪种结构型模式?请简述实现思路。
购买课程解锁全部内容
写出优雅代码:10 章掌握 23 种设计模式
¥29.90