结构型模式(下)—— 外观、桥接、组合、享元模式
上一章的三个模式处理的是单个对象之间的”包装”关系。这一章的四个模式则站在更宏观的视角,解决系统层面的结构问题:如何简化复杂子系统的调用、如何分离两个独立变化的维度、如何统一处理树形结构、如何高效管理海量相似对象。
📋 开篇自测:你已经知道多少?
- 你在调用第三方 SDK 时,是否封装过一层”门面”来简化调用?
- 文件系统中”文件夹包含文件和子文件夹”这种结构,用什么模式来建模最自然?
- 一款弹幕游戏中有上万颗子弹在屏幕上飞,它们的外观只有五种,你会如何优化内存?
一、外观模式:给复杂系统提供一个简单的大门
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 generate、create-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 组件树 |
| 享元 | 大量相似对象占内存 | 共享、内外部状态 | 地图标记、弹幕子弹、文本字符渲染 |
选择建议:
- 觉得”调用方要知道太多细节” → 考虑外观
- 发现”两个维度的排列组合导致类爆炸” → 考虑桥接
- 数据结构天然是”树形”的 → 考虑组合
- 内存分析显示”大量相似对象” → 考虑享元
📝 结尾自测
- 外观模式与适配器模式都提供了一层封装,它们的核心区别是什么?
- 桥接模式的”桥”具体桥接的是什么和什么?用继承行不行?为什么?
- 组合模式中,“叶子节点”和”容器节点”需要实现同一个接口,这样做的好处是什么?
- 享元模式中的”内部状态”和”外部状态”如何区分?举一个例子说明。
- 如果你要设计一个”权限系统”,其中角色(admin/editor/viewer)和资源类型(文档/表格/图片)两个维度独立变化,你会选择哪种结构型模式?
购买课程解锁全部内容
写出优雅代码:10 章掌握 23 种设计模式
¥29.90