Appearance
4.4 组合模式 (Composite Pattern)
欢迎来到结构型模式的最后一站!我们将学习一个旨在处理“部分-整体”层次结构的模式——组合模式。它能让你像对待单个对象一样,统一地处理对象组合。
核心定义:将对象组合成树形结构,以表示“部分-整体”的层次关系。组合模式使得客户端对单个对象(叶子)和组合对象(容器)的使用具有一致性。
一、生活中的场景:文件与文件夹
这个模式最完美的现实世界映射就是电脑的文件系统。
- 文件 (Leaf / 叶子节点):如
photo.jpg,document.txt。它们是基本单位,内部不能再包含其他东西。 - 文件夹 (Composite / 组合节点):如
My Documents,Downloads。它们是容器,既可以包含文件,也可以包含文件夹,形成无限嵌套的树形结构。
现在,思考一个操作:计算大小。
- 对于一个文件,计算大小很简单,就是它自身的体积。
- 对于一个文件夹,计算它的大小,你需要遍历它内部所有的文件和子文件夹,把它们的大小全部加起来。
组合模式的精髓就在于,它能让你用完全相同的方式来对待“文件”和“文件夹”。你可以对任何一个节点(不管是文件还是文件夹)调用 calculateSize() 方法,而无需关心它的具体类型。
二、没有组合模式的代码:繁琐的递归与判断
让我们用代码模拟一个需要区别对待文件和文件夹的场景。
javascript
// 两个完全独立的类
class File {
constructor(name, size) {
this.name = name;
this.size = size;
}
}
class Folder {
constructor(name) {
this.name = name;
this.children = [];
}
add(child) {
this.children.push(child);
}
}
// --- 客户端代码 ---
// 我们需要一个函数来计算一个文件夹的总大小
function calculateTotalSize(node) {
let total = 0;
// 必须进行类型判断!
if (node instanceof Folder) {
for (const child of node.children) {
// 对每个子节点,都需要递归调用,并再次进行类型判断
total += calculateTotalSize(child);
}
} else if (node instanceof File) {
total += node.size;
}
return total;
}
// 创建一个文件结构
const root = new Folder("Root");
const musicFolder = new Folder("Music");
const videoFolder = new Folder("Videos");
const song1 = new File("song1.mp3", 10);
const song2 = new File("song2.mp3", 15);
const movie1 = new File("movie1.mp4", 100);
root.add(musicFolder);
root.add(videoFolder);
musicFolder.add(song1);
musicFolder.add(song2);
videoFolder.add(movie1);
// 使用起来非常不便
console.log(`Music 文件夹大小: ${calculateTotalSize(musicFolder)}`); // 25
console.log(`Root 文件夹大小: ${calculateTotalSize(root)}`); // 125这段代码的问题在于,客户端的 calculateTotalSize 函数充满了 instanceof 判断,它被迫要知道 File 和 Folder 这两个具体类的实现差异。这使得代码非常不“透明”,且难以扩展。
三、使用组合模式重构
核心思想是:为叶子节点 (File) 和组合节点 (Folder) 定义一个共同的接口。
第 1 步:定义组件 (Component) 接口
虽然 JavaScript 没有正式的接口,但我们可以约定,所有节点都必须有 getSize() 方法。
第 2 步:实现叶子 (Leaf)
javascript
class File {
constructor(name, size) {
this.name = name;
this.size = size;
}
// 实现共同接口
getSize() {
return this.size;
}
// 为了保持接口统一(透明性),叶子节点也需要提供 add/remove 方法,
// 但通常是空实现或抛出错误,因为文件不能有子节点。
add() {
throw new Error("文件不支持添加子节点");
}
}第 3 步:实现组合 (Composite)
javascript
class Folder {
constructor(name) {
this.name = name;
this.children = [];
}
add(child) {
this.children.push(child);
}
// 实现共同接口
getSize() {
let total = 0;
// **关键**:它不自己计算,而是“委托”给它的每一个子节点
for (const child of this.children) {
total += child.getSize(); // 无论 child 是文件还是文件夹,都有 getSize 方法!
}
return total;
}
}第 4 步:重构客户端代码
现在,客户端代码变得极其简单和“透明”。
javascript
// 创建文件结构的过程和之前一样...
const root = new Folder("Root");
// ...
// 客户端现在可以统一对待所有节点了!
console.log(`Music 文件夹大小: ${musicFolder.getSize()}`); // 25
console.log(`Root 文件夹大小: ${root.getSize()}`); // 125
console.log(`movie1 文件大小: ${movie1.getSize()}`); // 100客户端不再需要 calculateTotalSize 这个外部函数,也不再需要任何 instanceof 判断。它只需要知道,任何一个节点,无论是文件还是文件夹,都可以调用 getSize()。
四、核心概念:透明性 vs. 安全性
组合模式在实现时有一个经典的设计权衡:
- 透明组合模式 (我们上面实现的):将
add,remove等管理子节点的方法也定义在共同接口中。- 优点:对客户端是完全透明的,客户端可以无差别地对待所有组件。
- 缺点:对叶子节点“不安全”,因为它被迫实现了它本不该有的方法(如文件的
add)。
- 安全组合模式:只在组合节点 (
Folder) 中定义add,remove等方法,共同接口中只有getSize这种共有的方法。- 优点:对叶子节点是“安全”的,它不会有多余的方法。
- 缺点:破坏了透明性,客户端在需要添加子节点时,必须先判断
instanceof Folder。
在绝大多数情况下,透明组合模式更受欢迎,因为它带来的客户端代码的简化和一致性,其价值远大于对叶子节点造成的一点点“不安全”。
五、优缺点与适用场景
优点
- 统一接口,简化客户端:客户端代码可以统一处理所有对象,无需区分它是叶子还是组合。
- 易于扩展:可以很方便地增加新的叶子或组合类,只要它们实现了共同接口,原有代码无需改动。
- 天然支持递归:非常适合用来表示和处理具有递归结构的树形数据。
缺点
- 设计更抽象:可能会让设计变得过于通用,使得限制组合中组件的类型变得困难。
- 透明模式下的“不安全”:如上所述,叶子节点可能会继承到它不支持的方法。
适用场景
- 任何你需要表示“部分-整体”的树形结构时。
- UI 界面:一个窗口 (Composite) 包含面板 (Composite),面板又包含按钮、文本框 (Leafs)。
- 组织架构:一个部门 (Composite) 包含子部门 (Composite) 和员工 (Leaf)。
- 代码解析:抽象语法树 (AST) 就是一个典型的组合模式应用。
结构型模式小结
恭喜你!我们已经探索完了所有核心的结构型模式。它们的核心都是关于如何组织和关联对象,以创造出更灵活、更强大的系统结构。
- 适配器模式:解决接口不兼容的问题。
- 代理模式:解决访问控制和增强职责的问题。
- 外观模式:解决简化复杂子系统接口的问题。
- 装饰器模式:解决动态添加功能的问题。
- 组合模式:解决统一处理树形结构中部分与整体的问题。