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

结构型模式(下)—— 外观、桥接、组合、享元模式

上一章的三个模式处理的是单个对象之间的”包装”关系。这一章的四个模式则站在更宏观的视角,解决系统层面的结构问题:如何简化复杂子系统的调用、如何分离两个独立变化的维度、如何统一处理树形结构、如何高效管理海量相似对象。

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

  1. 你在调用第三方 SDK 时,是否封装过一层”门面”来简化调用?
  2. 文件系统中”文件夹包含文件和子文件夹”这种结构,用什么模式来建模最自然?
  3. 一款弹幕游戏中有上万颗子弹在屏幕上飞,它们的外观只有五种,你会如何优化内存?

一、外观模式:给复杂系统提供一个简单的大门

1.1 从智能家居场景说起

假设你开发了一套智能家居系统,包含灯光控制、空调控制、窗帘控制、音响控制等多个子系统。每个子系统都有自己的 API:

class LightSystem {
  private brightness = 0;
  turnOn(): void { this.brightness = 100; console.log('灯光已开启'); }
  turnOff(): void { this.brightness = 0; console.log('灯光已关闭'); }
  dim(level: number): void { this.brightness = level; console.log(`灯光亮度: ${level}%`); }
}

class AirConditioner {
  private running = false;
  start(temperature: number): void { this.running = true; console.log(`空调启动,温度设为 ${temperature}°C`); }
  stop(): void { this.running = false; console.log('空调已关闭'); }
  setMode(mode: 'cool' | 'heat' | 'auto'): void { console.log(`空调模式: ${mode}`); }
}

class CurtainSystem {
  open(): void { console.log('窗帘已打开'); }
  close(): void { console.log('窗帘已关闭'); }
  setPosition(percent: number): void { console.log(`窗帘开合度: ${percent}%`); }
}

class SoundSystem {
  powerOn(): void { console.log('音响已开启'); }
  powerOff(): void { console.log('音响已关闭'); }
  playPlaylist(name: string): void { console.log(`播放歌单: ${name}`); }
  setVolume(level: number): void { console.log(`音量: ${level}%`); }
}

用户想要一个”回家模式”——一键开灯、开空调、关窗帘、播放音乐。如果让用户直接操作四个子系统,代码会非常繁琐:

// 用户不应该知道这些细节
light.turnOn();
light.dim(70);
ac.start(24);
ac.setMode('auto');
curtain.close();
sound.powerOn();
sound.playPlaylist('回家放松');
sound.setVolume(40);

外观模式的核心思想是:为子系统中的一组接口提供一个统一的高层接口,使子系统更加容易使用。

1.2 外观模式实现

class SmartHomeFacade {
  private light: LightSystem;
  private ac: AirConditioner;
  private curtain: CurtainSystem;
  private sound: SoundSystem;

  constructor() {
    this.light = new LightSystem();
    this.ac = new AirConditioner();
    this.curtain = new CurtainSystem();
    this.sound = new SoundSystem();
  }

  // 场景模式:回家
  activateHomeMode(): void {
    console.log('=== 启动回家模式 ===');
    this.light.turnOn();
    this.light.dim(70);
    this.ac.start(24);
    this.ac.setMode('auto');
    this.curtain.close();
    this.sound.powerOn();
    this.sound.playPlaylist('回家放松');
    this.sound.setVolume(40);
  }

  // 场景模式:离家
  activateLeaveMode(): void {
    console.log('=== 启动离家模式 ===');
    this.light.turnOff();
    this.ac.stop();
    this.curtain.close();
    this.sound.powerOff();
  }

  // 场景模式:影院
  activateCinemaMode(): void {
    console.log('=== 启动影院模式 ===');
    this.light.dim(10);
    this.curtain.close();
    this.sound.powerOn();
    this.sound.setVolume(80);
  }

  // 仍然允许直接访问子系统(外观不是强制的屏障)
  getLightSystem(): LightSystem { return this.light; }
  getSoundSystem(): SoundSystem { return this.sound; }
}

// 使用:一行搞定
const home = new SmartHomeFacade();
home.activateHomeMode();

外观模式的要点是:它不隐藏子系统,而是提供一条便捷通道。 用户既可以走外观提供的快速入口,也可以在需要时直接访问子系统做更细粒度的控制。

🤔 想一想 很多后端框架的”脚手架命令”(如 nest generatecreate-react-app)是否也是一种外观模式的体现?它们简化了什么子系统的操作?


二、桥接模式:分离两个独立变化的维度

2.1 从消息推送系统说起

假设你要开发一个消息推送系统。消息有不同的紧急程度(普通、重要、紧急),也有不同的发送渠道(短信、邮件、App 推送)。这两个维度是独立变化的——增加一种紧急程度不应该影响发送渠道的代码,反之亦然。

如果用继承来实现,你会得到一个类爆炸的噩梦:

MessageSender
├── NormalSMS
├── NormalEmail
├── NormalAppPush
├── ImportantSMS
├── ImportantEmail
├── ImportantAppPush
├── UrgentSMS
├── UrgentEmail
└── UrgentAppPush

3 种紧急程度 x 3 种渠道 = 9 个类。如果再加一种紧急程度和一种渠道,变成 4 x 4 = 16 个类。这种指数级增长是不可接受的。

桥接模式的核心思想是:将抽象部分与实现部分分离,使它们可以独立变化。 它用组合代替继承来处理多维度变化。

2.2 桥接模式实现

// ===== 实现维度:发送渠道 =====
interface MessageChannel {
  send(recipient: string, content: string): Promise<void>;
  getChannelName(): string;
}

class SMSChannel implements MessageChannel {
  async send(recipient: string, content: string): Promise<void> {
    console.log(`[短信] 发送至 ${recipient}: ${content}`);
  }
  getChannelName(): string { return '短信'; }
}

class EmailChannel implements MessageChannel {
  async send(recipient: string, content: string): Promise<void> {
    console.log(`[邮件] 发送至 ${recipient}: ${content}`);
  }
  getChannelName(): string { return '邮件'; }
}

class AppPushChannel implements MessageChannel {
  async send(recipient: string, content: string): Promise<void> {
    console.log(`[App推送] 发送至 ${recipient}: ${content}`);
  }
  getChannelName(): string { return 'App推送'; }
}

// ===== 抽象维度:消息紧急程度 =====
abstract class Message {
  // "桥":持有一个渠道的引用
  constructor(protected channel: MessageChannel) {}

  abstract send(recipient: string, content: string): Promise<void>;
}

class NormalMessage extends Message {
  async send(recipient: string, content: string): Promise<void> {
    const formatted = `[普通] ${content}`;
    await this.channel.send(recipient, formatted);
  }
}

class ImportantMessage extends Message {
  async send(recipient: string, content: string): Promise<void> {
    const formatted = `[重要] ⚠ ${content} ⚠`;
    await this.channel.send(recipient, formatted);
    // 重要消息额外记录日志
    console.log(`重要消息已发送至 ${recipient},通过 ${this.channel.getChannelName()}`);
  }
}

class UrgentMessage extends Message {
  // 兜底渠道通过构造函数注入,避免硬编码具体实现
  constructor(channel: MessageChannel, private backupChannel?: MessageChannel) {
    super(channel);
  }

  async send(recipient: string, content: string): Promise<void> {
    const formatted = `[紧急] ${content} —— 请立即处理!`;
    await this.channel.send(recipient, formatted);
    // 紧急消息:通过当前渠道发送后,额外通过兜底渠道发送
    if (this.backupChannel && this.channel.getChannelName() !== this.backupChannel.getChannelName()) {
      await this.backupChannel.send(recipient, `紧急提醒: ${content}`);
    }
  }
}

使用时,两个维度自由组合:

// 通过邮件发送普通消息
const normalEmail = new NormalMessage(new EmailChannel());
await normalEmail.send('alice@example.com', '本周周报已生成');

// 通过 App 推送发送紧急消息,短信作为兜底渠道
const urgentPush = new UrgentMessage(new AppPushChannel(), new SMSChannel());
await urgentPush.send('bob', '服务器 CPU 使用率超过 95%');

// 新增渠道?只加一个 Channel 类
// 新增紧急程度?只加一个 Message 子类
// 彼此完全不影响

三、组合模式:统一处理”个体”和”整体”

3.1 从组织架构权限系统说起

假设你正在开发一个企业权限管理系统。公司的组织架构是一棵树:公司下面有部门,部门下面有小组,小组下面有员工。当你要计算”某个节点及其所有下级的总人数”或”给某个节点及其所有下级批量授权”时,你不希望”部门”和”员工”的处理逻辑完全不同。

组合模式的核心思想是:将对象组织成树形结构,使得客户端对单个对象和组合对象的使用具有一致性。

3.2 组合模式实现

// 组件接口:不管是"叶子"还是"容器",都实现同一套接口
interface OrgNode {
  getName(): string;
  getHeadCount(): number;
  grantPermission(permission: string): void;
  print(indent?: number): void;
}

// 叶子节点:员工
class Employee implements OrgNode {
  private permissions: Set<string> = new Set();

  constructor(
    private name: string,
    private title: string
  ) {}

  getName(): string { return this.name; }

  getHeadCount(): number { return 1; }

  grantPermission(permission: string): void {
    this.permissions.add(permission);
    console.log(`${this.name} 获得权限: ${permission}`);
  }

  print(indent = 0): void {
    const prefix = ' '.repeat(indent);
    console.log(`${prefix}- ${this.name} (${this.title})`);
  }
}

// 容器节点:部门/小组
class Department implements OrgNode {
  private children: OrgNode[] = [];

  constructor(private name: string) {}

  getName(): string { return this.name; }

  add(node: OrgNode): void {
    this.children.push(node);
  }

  remove(node: OrgNode): void {
    const index = this.children.indexOf(node);
    if (index > -1) this.children.splice(index, 1);
  }

  // 递归计算总人数——对客户端来说,调用方式和叶子节点完全一样
  getHeadCount(): number {
    return this.children.reduce((sum, child) => sum + child.getHeadCount(), 0);
  }

  // 递归批量授权
  grantPermission(permission: string): void {
    console.log(`[${this.name}] 批量授权: ${permission}`);
    this.children.forEach(child => child.grantPermission(permission));
  }

  print(indent = 0): void {
    const prefix = ' '.repeat(indent);
    console.log(`${prefix}+ ${this.name} (${this.getHeadCount()}人)`);
    this.children.forEach(child => child.print(indent + 2));
  }
}

构建并使用:

// 构建组织树
const company = new Department('星辰科技');

const engineering = new Department('工程部');
const frontend = new Department('前端组');
frontend.add(new Employee('Alice', '高级工程师'));
frontend.add(new Employee('Bob', '中级工程师'));
frontend.add(new Employee('Charlie', '初级工程师'));

const backend = new Department('后端组');
backend.add(new Employee('Dave', '架构师'));
backend.add(new Employee('Eve', '高级工程师'));

engineering.add(frontend);
engineering.add(backend);

const product = new Department('产品部');
product.add(new Employee('Frank', '产品经理'));
product.add(new Employee('Grace', '产品经理'));

company.add(engineering);
company.add(product);

// 统一操作——不管是公司、部门还是员工,调用方式一模一样
console.log(`公司总人数: ${company.getHeadCount()}`); // 7
console.log(`工程部人数: ${engineering.getHeadCount()}`); // 5

// 批量授权
engineering.grantPermission('jira:access');

// 打印组织树
company.print();
// + 星辰科技 (7人)
//   + 工程部 (5人)
//     + 前端组 (3人)
//       - Alice (高级工程师)
//       - Bob (中级工程师)
//       - Charlie (初级工程师)
//     + 后端组 (2人)
//       - Dave (架构师)
//       - Eve (高级工程师)
//   + 产品部 (2人)
//     - Frank (产品经理)
//     - Grace (产品经理)

组合模式的威力在于统一性company.getHeadCount()alice.getHeadCount() 的调用方式完全相同,客户端不需要区分”容器”和”叶子”。


四、享元模式:海量相似对象的内存优化

4.1 从地图标记点渲染说起

假设你在开发一个地图应用,需要在地图上渲染数万个兴趣点(POI)。每个 POI 有类型(餐厅、酒店、加油站等)、名称、坐标。其中,类型对应的图标、颜色、尺寸是所有同类 POI 共享的——一万个餐厅用的都是同一个刀叉图标。如果每个 POI 对象都保存一份完整的图标数据,内存开销巨大。

享元模式的核心思想是:运用共享技术有效地支持大量细粒度的对象。 它把对象的状态分成两部分:

  • 内部状态(Intrinsic):可共享的、不随上下文变化的——如图标、颜色。
  • 外部状态(Extrinsic):不可共享的、随上下文变化的——如名称、坐标。

4.2 享元模式实现

// 享元对象:存储可共享的内部状态
class POIStyle {
  constructor(
    readonly type: string,
    readonly icon: string,     // 图标资源路径(假设很大)
    readonly color: string,
    readonly size: number
  ) {
    // 模拟图标资源加载
    console.log(`加载图标资源: ${icon} (${type})`);
  }

  render(x: number, y: number, name: string): void {
    // 外部状态通过参数传入,不存储在享元对象中
    console.log(`在 (${x}, ${y}) 渲染 [${this.type}] ${name} | 图标: ${this.icon}, 颜色: ${this.color}`);
  }
}

// 享元工厂:管理共享实例
class POIStyleFactory {
  private styles = new Map<string, POIStyle>();

  getStyle(type: string): POIStyle {
    if (!this.styles.has(type)) {
      // 根据类型创建样式配置
      const configs: Record<string, { icon: string; color: string; size: number }> = {
        restaurant: { icon: '/icons/fork-knife.svg', color: '#FF6B35', size: 24 },
        hotel:      { icon: '/icons/bed.svg',        color: '#4A90D9', size: 28 },
        gasStation: { icon: '/icons/fuel.svg',       color: '#2ECC71', size: 22 },
        parking:    { icon: '/icons/parking.svg',    color: '#9B59B6', size: 20 },
        hospital:   { icon: '/icons/cross.svg',      color: '#E74C3C', size: 26 },
      };

      const config = configs[type];
      if (!config) throw new Error(`未知 POI 类型: ${type}`);

      this.styles.set(type, new POIStyle(type, config.icon, config.color, config.size));
    }

    return this.styles.get(type)!;
  }

  getStyleCount(): number {
    return this.styles.size;
  }
}

// POI 数据点(只存储外部状态)
interface POIData {
  type: string;
  name: string;
  x: number;
  y: number;
}

// 地图渲染器
class MapRenderer {
  private styleFactory = new POIStyleFactory();

  renderPOIs(poiList: POIData[]): void {
    console.log(`准备渲染 ${poiList.length} 个兴趣点...`);

    for (const poi of poiList) {
      // 从工厂获取共享的样式对象
      const style = this.styleFactory.getStyle(poi.type);
      // 外部状态作为参数传入
      style.render(poi.x, poi.y, poi.name);
    }

    console.log(`实际创建的样式对象数: ${this.styleFactory.getStyleCount()}`);
  }
}

使用:

const renderer = new MapRenderer();
const poiData: POIData[] = [
  { type: 'restaurant', name: '老王火锅', x: 116.40, y: 39.91 },
  { type: 'restaurant', name: '沙县小吃', x: 116.41, y: 39.92 },
  { type: 'restaurant', name: '兰州拉面', x: 116.42, y: 39.93 },
  { type: 'hotel',      name: '如家酒店', x: 116.43, y: 39.90 },
  { type: 'hotel',      name: '汉庭酒店', x: 116.44, y: 39.91 },
  { type: 'gasStation', name: '中石化加油站', x: 116.45, y: 39.92 },
  // ...假设有 50000 个 POI
];

renderer.renderPOIs(poiData);
// 加载图标资源: /icons/fork-knife.svg (restaurant)  ← 只加载一次
// 加载图标资源: /icons/bed.svg (hotel)               ← 只加载一次
// 加载图标资源: /icons/fuel.svg (gasStation)          ← 只加载一次
// 在 (116.40, 39.91) 渲染 [restaurant] 老王火锅 | ...
// ...
// 实际创建的样式对象数: 3  ← 5万个 POI 只用了3个样式对象

50000 个 POI 只需要 5 个(或更少)样式对象,内存节省了好几个数量级。

🤔 想一想 JavaScript 中字符串的”驻留”(interning)机制——相同内容的字符串字面量指向同一块内存——是否可以看作一种语言层面的享元模式?

另外,React 中 React.memo 避免重复渲染、CSS 中的 class 复用,是否也包含享元思想?


五、四种模式的对比与选择

模式核心问题关键词适用场景
外观子系统太复杂简化、封装第三方 SDK 封装、复杂流程编排
桥接两个维度独立变化分离、组合跨平台 + 跨主题、消息类型 + 渠道
组合树形结构统一处理递归、一致性文件系统、组织架构、UI 组件树
享元大量相似对象占内存共享、内外部状态地图标记、弹幕子弹、文本字符渲染

选择建议:

  • 觉得”调用方要知道太多细节” → 考虑外观
  • 发现”两个维度的排列组合导致类爆炸” → 考虑桥接
  • 数据结构天然是”树形”的 → 考虑组合
  • 内存分析显示”大量相似对象” → 考虑享元

📝 结尾自测

  1. 外观模式与适配器模式都提供了一层封装,它们的核心区别是什么?
  2. 桥接模式的”桥”具体桥接的是什么和什么?用继承行不行?为什么?
  3. 组合模式中,“叶子节点”和”容器节点”需要实现同一个接口,这样做的好处是什么?
  4. 享元模式中的”内部状态”和”外部状态”如何区分?举一个例子说明。
  5. 如果你要设计一个”权限系统”,其中角色(admin/editor/viewer)和资源类型(文档/表格/图片)两个维度独立变化,你会选择哪种结构型模式?

购买课程解锁全部内容

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

¥29.90