行为型模式(上)—— 观察者、策略、模板方法、迭代器
如果说创建型模式管”对象怎么来”,结构型模式管”对象怎么组合”,那行为型模式管的就是”对象之间怎么协作”。这一章介绍四个最高频的行为型模式,它们几乎在每个中大型项目中都会出现。
📋 开篇自测:你已经知道多少?
- DOM 的
addEventListener背后对应的是哪种设计模式?- 如果一段业务逻辑的整体流程固定,但其中某些步骤需要定制化,你会用什么模式?
- 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 链。压缩算法选择是典型场景。
- 模板方法模式:在父类中定义流程骨架,子类实现具体步骤。数据导出是典型场景。
- 迭代器模式:统一遍历接口,隐藏内部数据结构。分页数据遍历是典型场景。
这四个模式的共同点是:它们都在对象之间建立了清晰的协作契约,让代码的职责更分明、耦合度更低。
下一章我们继续行为型模式的后六位——责任链、命令、状态、中介者、备忘录、访问者。
📝 结尾自测
- 观察者模式中,Subject 为什么不应该直接调用观察者的具体方法,而要通过接口?
- 策略模式和简单 if-else 相比,增加了什么成本?在什么情况下这种成本是值得的?
- 模板方法模式中的”钩子方法”是什么?它和抽象方法有什么区别?
- TypeScript 中的
Symbol.asyncIterator协议是如何让for await...of语法工作的?- 如果你要为一个电商平台实现”多种排序方式”(按价格、按销量、按评分、按综合),你会选择策略模式还是简单 if-else?请说明理由。
购买课程解锁全部内容
写出优雅代码:10 章掌握 23 种设计模式
¥29.90