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

行为型模式(上)—— 观察者、策略、模板方法、迭代器

如果说创建型模式管”对象怎么来”,结构型模式管”对象怎么组合”,那行为型模式管的就是”对象之间怎么协作”。这一章介绍四个最高频的行为型模式,它们几乎在每个中大型项目中都会出现。

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

  1. DOM 的 addEventListener 背后对应的是哪种设计模式?
  2. 如果一段业务逻辑的整体流程固定,但其中某些步骤需要定制化,你会用什么模式?
  3. TypeScript 中的 for...of 循环能遍历自定义对象吗?需要满足什么条件?

一、观察者模式:一对多的变化通知

1.1 从实时股票行情说起

假设你在开发一个股票交易平台。股票价格会实时变化,而多个模块都关心价格变动:K 线图需要更新曲线,交易面板需要刷新买卖价,预警系统需要检查是否触发了用户设定的价格阈值。

最直觉的做法是让价格数据源直接调用每个模块的更新方法:

// 反面教材:数据源硬编码所有消费者
class StockPrice {
  private price = 0;

  updatePrice(newPrice: number): void {
    this.price = newPrice;
    // 每加一个消费者就要改这里——严重违反开闭原则
    kLineChart.refresh(newPrice);
    tradePanel.updateQuote(newPrice);
    alertSystem.check(newPrice);
    // 半年后这里有 20 行...
  }
}

观察者模式的核心思想是:定义对象之间的一对多依赖关系,当一个对象状态发生变化时,所有依赖它的对象都会收到通知并自动更新。

1.2 观察者模式实现

// 观察者接口
interface PriceObserver {
  onPriceUpdate(symbol: string, price: number, timestamp: Date): void;
}

// 被观察的主题
class StockTicker {
  private observers: Map<string, Set<PriceObserver>> = new Map();
  private prices: Map<string, number> = new Map();

  // 订阅:关注某只股票的价格变动
  subscribe(symbol: string, observer: PriceObserver): void {
    if (!this.observers.has(symbol)) {
      this.observers.set(symbol, new Set());
    }
    this.observers.get(symbol)!.add(observer);
  }

  // 取消订阅
  unsubscribe(symbol: string, observer: PriceObserver): void {
    this.observers.get(symbol)?.delete(observer);
  }

  // 更新价格并通知所有观察者
  updatePrice(symbol: string, newPrice: number): void {
    const oldPrice = this.prices.get(symbol);
    this.prices.set(symbol, newPrice);

    const subscribers = this.observers.get(symbol);
    if (subscribers) {
      const now = new Date();
      for (const observer of subscribers) {
        observer.onPriceUpdate(symbol, newPrice, now);
      }
    }
  }
}

// 具体观察者:K 线图
class KLineChart implements PriceObserver {
  private dataPoints: Array<{ price: number; time: Date }> = [];

  onPriceUpdate(symbol: string, price: number, timestamp: Date): void {
    this.dataPoints.push({ price, time: timestamp });
    console.log(`[K线图] ${symbol} 新数据点: ¥${price},共 ${this.dataPoints.length} 个点`);
  }
}

// 具体观察者:价格预警
class PriceAlert implements PriceObserver {
  constructor(
    private alertPrice: number,
    private direction: 'above' | 'below'
  ) {}

  onPriceUpdate(symbol: string, price: number, timestamp: Date): void {
    const triggered =
      (this.direction === 'above' && price >= this.alertPrice) ||
      (this.direction === 'below' && price <= this.alertPrice);

    if (triggered) {
      console.log(`[预警触发] ${symbol} 价格 ¥${price} 已${this.direction === 'above' ? '突破' : '跌破'} ¥${this.alertPrice}!`);
    }
  }
}

// 具体观察者:交易面板
class TradePanel implements PriceObserver {
  onPriceUpdate(symbol: string, price: number, timestamp: Date): void {
    console.log(`[交易面板] ${symbol} 最新报价: ¥${price}`);
  }
}

使用:

const ticker = new StockTicker();

const kline = new KLineChart();
const alert = new PriceAlert(150, 'above');
const panel = new TradePanel();

ticker.subscribe('AAPL', kline);
ticker.subscribe('AAPL', alert);
ticker.subscribe('AAPL', panel);

ticker.updatePrice('AAPL', 148.5);
// [K线图] AAPL 新数据点: ¥148.5,共 1 个点
// [交易面板] AAPL 最新报价: ¥148.5

ticker.updatePrice('AAPL', 151.2);
// [K线图] AAPL 新数据点: ¥151.2,共 2 个点
// [预警触发] AAPL 价格 ¥151.2 已突破 ¥150!
// [交易面板] AAPL 最新报价: ¥151.2

// 不再关心预警?随时取消订阅
ticker.unsubscribe('AAPL', alert);

新增一个消费模块?只要实现 PriceObserver 接口再 subscribe 即可,完全不用动 StockTicker 的代码。这就是观察者模式的威力。

1.3 注意事项

内存泄漏:忘记 unsubscribe 是观察者模式最常见的坑。在前端项目中,组件销毁时一定要取消订阅。

通知顺序:虽然本实现中 Set 的遍历顺序是插入顺序(ES 规范保证),但业务逻辑不应该依赖通知顺序——不同的观察者实现可能有不同的遍历策略。

同步 vs 异步:上面的实现是同步通知的——一个观察者的 onPriceUpdate 如果耗时很长,会阻塞其他观察者。在实际项目中,可以考虑异步通知。

🤔 想一想 观察者模式和”发布-订阅模式”有什么区别?提示:观察者模式中,Subject 和 Observer 是否直接引用对方?


二、策略模式:消灭 if-else 的利器

2.1 从文件压缩工具说起

假设你在开发一个文件压缩工具,需要支持多种压缩算法——ZIP、GZIP、BROTLI。用户在界面上选择压缩方式,点击”压缩”按钮。

// 反面教材:所有算法堆在一个函数里
function compressFile(file: Buffer, algorithm: string): Buffer {
  if (algorithm === 'zip') {
    console.log('使用 ZIP 算法压缩...');
    // 50 行 ZIP 压缩逻辑
    return zipCompress(file);
  } else if (algorithm === 'gzip') {
    console.log('使用 GZIP 算法压缩...');
    // 50 行 GZIP 压缩逻辑
    return gzipCompress(file);
  } else if (algorithm === 'brotli') {
    console.log('使用 Brotli 算法压缩...');
    // 50 行 Brotli 压缩逻辑
    return brotliCompress(file);
  }
  throw new Error(`不支持的算法: ${algorithm}`);
}

每种算法 50 行,三种就是 150 行,全塞在一个函数里。要新增算法?继续在 if-else 链里追加。要修改某种算法?在 150 行代码里找到对应的段落。

策略模式的核心思想是:定义一系列算法,把每个算法封装成独立的类,使它们可以互相替换。

2.2 策略模式实现

// 策略接口
interface CompressionStrategy {
  compress(data: Buffer): Promise<Buffer>;
  getExtension(): string;
  getName(): string;
}

// 具体策略
class ZipStrategy implements CompressionStrategy {
  async compress(data: Buffer): Promise<Buffer> {
    console.log(`ZIP 压缩中... 原始大小: ${data.length} bytes`);
    // 实际的 ZIP 压缩逻辑
    const compressed = Buffer.from(data.toString('base64')); // 简化示意
    console.log(`ZIP 压缩完成,压缩后: ${compressed.length} bytes`);
    return compressed;
  }
  getExtension(): string { return '.zip'; }
  getName(): string { return 'ZIP'; }
}

class GzipStrategy implements CompressionStrategy {
  async compress(data: Buffer): Promise<Buffer> {
    console.log(`GZIP 压缩中... 原始大小: ${data.length} bytes`);
    const compressed = Buffer.from(data.toString('base64'));
    console.log(`GZIP 压缩完成,压缩后: ${compressed.length} bytes`);
    return compressed;
  }
  getExtension(): string { return '.gz'; }
  getName(): string { return 'GZIP'; }
}

class BrotliStrategy implements CompressionStrategy {
  async compress(data: Buffer): Promise<Buffer> {
    console.log(`Brotli 压缩中... 原始大小: ${data.length} bytes`);
    const compressed = Buffer.from(data.toString('base64'));
    console.log(`Brotli 压缩完成,压缩后: ${compressed.length} bytes`);
    return compressed;
  }
  getExtension(): string { return '.br'; }
  getName(): string { return 'Brotli'; }
}

// 上下文:文件压缩器
class FileCompressor {
  private strategy: CompressionStrategy;

  constructor(strategy: CompressionStrategy) {
    this.strategy = strategy;
  }

  // 运行时可以切换策略
  setStrategy(strategy: CompressionStrategy): void {
    this.strategy = strategy;
  }

  async compressFile(fileName: string, data: Buffer): Promise<{ name: string; data: Buffer }> {
    console.log(`使用 ${this.strategy.getName()} 策略压缩 ${fileName}`);
    const compressed = await this.strategy.compress(data);
    return {
      name: fileName + this.strategy.getExtension(),
      data: compressed,
    };
  }
}

使用:

const fileData = Buffer.from('这是一段需要压缩的文本内容...');

// 用 Brotli 压缩
const compressor = new FileCompressor(new BrotliStrategy());
const result1 = await compressor.compressFile('report.txt', fileData);

// 运行时切换到 ZIP
compressor.setStrategy(new ZipStrategy());
const result2 = await compressor.compressFile('report.txt', fileData);

2.3 TypeScript 的函数式简化

在 TypeScript 中,如果策略只有一个方法,完全可以用函数代替类:

type CompressionFn = (data: Buffer) => Promise<Buffer>;

const strategies: Record<string, CompressionFn> = {
  zip: async (data) => { /* ZIP 逻辑 */ return data; },
  gzip: async (data) => { /* GZIP 逻辑 */ return data; },
  brotli: async (data) => { /* Brotli 逻辑 */ return data; },
};

async function compressFile(data: Buffer, algorithm: string): Promise<Buffer> {
  const strategy = strategies[algorithm];
  if (!strategy) throw new Error(`不支持的算法: ${algorithm}`);
  return strategy(data);
}

这种”策略 Map”在实际项目中非常常见,是策略模式在函数式风格下的极简体现。


三、模板方法模式:固定骨架,开放细节

3.1 从数据导出功能说起

假设你的系统需要支持多种格式的数据导出——CSV、Excel、PDF。这三种导出的整体流程是一样的:查询数据 → 格式化数据 → 生成文件 → 返回下载链接。但每一步的具体实现不同。

模板方法模式的核心思想是:在父类中定义算法的骨架,将某些步骤延迟到子类中实现。 子类可以重写特定步骤而不改变算法的整体结构。

3.2 模板方法实现

// 抽象类定义导出的"骨架"
abstract class DataExporter {
  // 模板方法:定义算法骨架,声明为 final(TypeScript 没有 final,靠约定)
  async export(query: ExportQuery): Promise<string> {
    console.log(`开始 ${this.getFormatName()} 导出...`);

    // 步骤一:查询数据(通用逻辑,所有格式共享)
    const rawData = await this.fetchData(query);
    console.log(`查询到 ${rawData.length} 条数据`);

    // 步骤二:格式化数据(由子类实现)
    const formatted = this.formatData(rawData);

    // 步骤三:生成文件(由子类实现)
    const filePath = await this.generateFile(formatted);

    // 钩子方法:可选的后处理步骤
    await this.afterExport(filePath);

    console.log(`${this.getFormatName()} 导出完成: ${filePath}`);
    return filePath;
  }

  // 通用步骤:查询数据
  protected async fetchData(query: ExportQuery): Promise<Record<string, any>[]> {
    console.log(`查询条件: ${JSON.stringify(query)}`);
    // 模拟数据库查询
    return [
      { id: 1, name: 'Alice', department: '工程部', salary: 25000 },
      { id: 2, name: 'Bob', department: '产品部', salary: 22000 },
      { id: 3, name: 'Charlie', department: '工程部', salary: 28000 },
    ];
  }

  // 抽象步骤:由子类实现
  protected abstract getFormatName(): string;
  protected abstract formatData(data: Record<string, any>[]): any;
  protected abstract generateFile(formatted: any): Promise<string>;

  // 钩子方法:子类可选择性重写(有默认的空实现)
  protected async afterExport(filePath: string): Promise<void> {
    // 默认什么也不做
  }
}

interface ExportQuery {
  table: string;
  filters?: Record<string, any>;
  dateRange?: { start: Date; end: Date };
}

具体实现:

class CsvExporter extends DataExporter {
  protected getFormatName(): string { return 'CSV'; }

  protected formatData(data: Record<string, any>[]): string {
    if (data.length === 0) return '';
    const headers = Object.keys(data[0]).join(',');
    const rows = data.map(row => Object.values(row).join(','));
    return [headers, ...rows].join('\n');
  }

  protected async generateFile(formatted: string): Promise<string> {
    const filePath = `/exports/report_${Date.now()}.csv`;
    // 写入文件系统
    console.log(`生成 CSV 文件: ${filePath}`);
    return filePath;
  }
}

class ExcelExporter extends DataExporter {
  protected getFormatName(): string { return 'Excel'; }

  protected formatData(data: Record<string, any>[]): any[][] {
    const headers = Object.keys(data[0]);
    const rows = data.map(row => Object.values(row));
    return [headers, ...rows];
  }

  protected async generateFile(formatted: any[][]): Promise<string> {
    const filePath = `/exports/report_${Date.now()}.xlsx`;
    console.log(`生成 Excel 文件: ${filePath},共 ${formatted.length} 行`);
    return filePath;
  }

  // 重写钩子:Excel 导出后发送通知
  protected async afterExport(filePath: string): Promise<void> {
    console.log(`Excel 导出完成,已发送邮件通知管理员`);
  }
}

class PdfExporter extends DataExporter {
  protected getFormatName(): string { return 'PDF'; }

  protected formatData(data: Record<string, any>[]): string {
    // 生成 HTML 表格,后续转 PDF
    const headerCells = Object.keys(data[0]).map(h => `<th>${h}</th>`).join('');
    const bodyRows = data.map(row =>
      `<tr>${Object.values(row).map(v => `<td>${v}</td>`).join('')}</tr>`
    ).join('');
    return `<table><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
  }

  protected async generateFile(formatted: string): Promise<string> {
    const filePath = `/exports/report_${Date.now()}.pdf`;
    console.log(`将 HTML 表格转换为 PDF: ${filePath}`);
    return filePath;
  }
}

使用:

const query: ExportQuery = { table: 'employees', filters: { department: '工程部' } };

const csvExporter = new CsvExporter();
await csvExporter.export(query);

const excelExporter = new ExcelExporter();
await excelExporter.export(query);

注意这里的关键设计:调用者只调用 export() 这一个方法,不需要知道导出流程有几步、每步怎么实现。 而子类只需要关心自己那几个特定步骤的实现。


四、迭代器模式:统一遍历的方式

4.1 从分页数据遍历说起

假设你在开发一个数据分析工具,需要遍历处理数据库中的百万级记录。不可能一次性把所有数据加载到内存里,必须分页读取。但分页的细节(当前页码、是否有下一页、如何翻页)不应该暴露给业务代码。

迭代器模式的核心思想是:提供一种方法顺序访问一个聚合对象中的各个元素,而不暴露该对象的内部表示。

4.2 自定义可迭代对象

TypeScript 支持 Symbol.iterator,我们可以让自定义数据结构直接支持 for...of 循环:

// 分页数据源
interface PagedData<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNext: boolean;
}

// 模拟分页 API
async function fetchPage<T>(
  endpoint: string,
  page: number,
  pageSize: number
): Promise<PagedData<T>> {
  console.log(`请求第 ${page} 页数据...`);
  // 模拟返回数据
  const total = 105;
  const start = (page - 1) * pageSize;
  const items = Array.from({ length: Math.max(0, Math.min(pageSize, total - start)) }, (_, i) => ({
    id: start + i + 1,
    value: `item-${start + i + 1}`,
  })) as T[];

  return {
    items,
    total,
    page,
    pageSize,
    hasNext: start + pageSize < total,
  };
}

// 可异步迭代的分页集合
class PaginatedCollection<T> {
  constructor(
    private endpoint: string,
    private pageSize: number = 20
  ) {}

  // 实现异步迭代器协议
  async *[Symbol.asyncIterator](): AsyncIterableIterator<T> {
    let currentPage = 1;
    let hasNext = true;

    while (hasNext) {
      const pageData = await fetchPage<T>(this.endpoint, currentPage, this.pageSize);

      for (const item of pageData.items) {
        yield item; // 逐条吐出,外部感知不到分页的存在
      }

      hasNext = pageData.hasNext;
      currentPage++;
    }
  }
}

使用——业务代码完全不知道底层是分页的:

const users = new PaginatedCollection<{ id: number; value: string }>('/api/users', 30);

// 用 for-await-of 遍历,就像遍历一个普通数组
for await (const user of users) {
  console.log(`处理用户: ${user.value}`);
  // 当遍历到第 30 条时,会自动请求第 2 页
  // 当遍历到第 60 条时,会自动请求第 3 页
  // 业务代码完全无感知
}

4.3 组合使用:可中断、可过滤的迭代器

// 创建一个带过滤和限制的遍历工具
async function* filter<T>(
  iterable: AsyncIterable<T>,
  predicate: (item: T) => boolean
): AsyncIterableIterator<T> {
  for await (const item of iterable) {
    if (predicate(item)) yield item;
  }
}

async function* take<T>(
  iterable: AsyncIterable<T>,
  count: number
): AsyncIterableIterator<T> {
  let taken = 0;
  for await (const item of iterable) {
    if (taken >= count) return;
    yield item;
    taken++;
  }
}

// 组合使用:从用户列表中取前 10 个 ID 为偶数的用户
const allUsers = new PaginatedCollection<{ id: number; value: string }>('/api/users', 20);
const evenUsers = filter(allUsers, user => user.id % 2 === 0);
const firstTenEven = take(evenUsers, 10);

for await (const user of firstTenEven) {
  console.log(user); // 只会遍历到足够的数据就停止,不会请求多余的页
}

🤔 想一想 RxJS 中的 Observable 和迭代器模式有什么关系?它们都是”按序提供数据”,但在”推”和”拉”的模型上有何不同?


五、本章小结

本章学习了四个最常用的行为型模式:

  • 观察者模式:建立一对多的依赖关系,实现松耦合的事件通知。股票行情系统是典型场景。
  • 策略模式:将算法封装成独立的策略对象,消灭 if-else 链。压缩算法选择是典型场景。
  • 模板方法模式:在父类中定义流程骨架,子类实现具体步骤。数据导出是典型场景。
  • 迭代器模式:统一遍历接口,隐藏内部数据结构。分页数据遍历是典型场景。

这四个模式的共同点是:它们都在对象之间建立了清晰的协作契约,让代码的职责更分明、耦合度更低。

下一章我们继续行为型模式的后六位——责任链、命令、状态、中介者、备忘录、访问者。


📝 结尾自测

  1. 观察者模式中,Subject 为什么不应该直接调用观察者的具体方法,而要通过接口?
  2. 策略模式和简单 if-else 相比,增加了什么成本?在什么情况下这种成本是值得的?
  3. 模板方法模式中的”钩子方法”是什么?它和抽象方法有什么区别?
  4. TypeScript 中的 Symbol.asyncIterator 协议是如何让 for await...of 语法工作的?
  5. 如果你要为一个电商平台实现”多种排序方式”(按价格、按销量、按评分、按综合),你会选择策略模式还是简单 if-else?请说明理由。

购买课程解锁全部内容

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

¥29.90