跳到主要内容
预计阅读 32 分钟

结构型模式(上)—— 适配器、装饰器、代理模式

创建型模式解决”对象从哪来”的问题,结构型模式解决”对象怎么组合在一起”的问题。适配器让不兼容的接口协同工作,装饰器在不改原有代码的前提下增强功能,代理则在访问对象之前插入一道”关卡”。

📋 开篇自测:你已经知道多少?

  1. 你在项目中是否遇到过”两个库的接口不一致,需要写一层转换”的情况?
  2. TypeScript 的 @decorator 语法和装饰器模式是什么关系?
  3. 你能列举三种常见的”代理”场景吗?

一、适配器模式:接口不兼容?加个转换插头

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,在每个方法里加上日志代码。但这样做违反了单一职责原则——数据访问和日志记录是两件不同的事。而且如果你还有 MySQLDataServiceSQLiteDataService,每个都要改一遍。

更棘手的是:后面可能还要加上性能监控、错误重试、缓存……难道每加一个功能都去改所有的 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 对象和代理模式有什么关系?它能实现保护代理和虚拟代理吗?


四、本章小结

本章学习了结构型模式的三个高频成员:

  • 适配器模式:解决接口不兼容的问题,像电源转换插头一样在两个不兼容的接口之间搭桥。核心是”转换”。
  • 装饰器模式:在不修改原有代码的前提下增强功能,像套娃一样层层包裹。核心是”增强”。
  • 代理模式:在访问真实对象之前插入一道关卡,控制访问行为。核心是”控制”。

这三个模式的代码结构都是”包一层”,区别在于意图。理解意图比记住代码模板更重要。

下一章我们继续结构型模式的后四位——外观、桥接、组合、享元,它们处理的是更宏观层面的结构组织问题。


📝 结尾自测

  1. 适配器模式解决的核心问题是什么?在什么场景下你会首先想到使用适配器?
  2. 装饰器模式如何做到”不修改原有代码”就能增强功能?它依赖什么前提条件?
  3. 代理模式有哪几种常见变体?各自的典型场景是什么?
  4. 装饰器模式和代理模式的代码结构很相似,如何从意图层面区分它们?
  5. 如果你要为一个旧的 XMLHttpRequest 封装层提供和 Fetch API 一致的接口,你会用哪种结构型模式?请简述实现思路。

购买课程解锁全部内容

写出优雅代码:10 章掌握 23 种设计模式

¥29.90