组合模式
1. 核心概念与价值
组合模式的定义是:将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
| 维度 | 描述 | 形象比喻 |
|---|---|---|
| 核心意图 | 模糊“简单对象(叶子)”和“复杂对象(容器)”的边界。 | 文件系统。你右键“删除”,无论是删一个文件还是删一个装有千万文件的文件夹,操作都是一样的。 |
| 主要优点 | 1. 忽略层级差异,简化客户端调用逻辑; 2. 易于增加新类型的节点(符合开闭原则); 3. 方便构建复杂的树形结构。 | 俄罗斯套娃。最里面的小娃娃和外面的大套娃,在“套”这个属性上是一致的。 |
| 主要缺点 | 很难在编译时限制组合中的组件类型(例如限制文件夹里只能放图片)。在 JS 这种弱类型语言中尤为明显。 |
2. 模式结构:叶子与容器
组合模式通常包含两个主要角色:
- 叶子对象 (Leaf):最底层的节点,没有子节点,执行具体的操作。
- 组合对象 (Composite/Container):包含子节点(可以是叶子,也可以是另一个容器)。它通常会遍历其子节点并调用它们的同名方法。
3. 代码实现示例:宏命令与扫描文件
让我们实现一个简单的“文件扫描”功能。
js
// 1. 容器对象:文件夹
class Folder {
constructor(name) {
this.name = name;
this.files = []; // 存储子节点
}
add(file) {
this.files.push(file);
}
scan() {
console.log(`--- 开始扫描文件夹: ${this.name} ---`);
for (let file of this.files) {
file.scan(); // 关键点:递归调用同名方法
}
}
}
// 2. 叶子对象:具体文件
class File {
constructor(name) {
this.name = name;
}
add() {
throw new Error('叶子节点无法添加子节点');
}
scan() {
console.log(`正在扫描文件: ${this.name}`);
}
}
// 3. 构建树形结构并执行
const root = new Folder('项目根目录');
const folderJs = new Folder('JavaScript文件');
const file1 = new File('index.html');
const file2 = new File('app.js');
const file3 = new File('style.css');
folderJs.add(file2);
root.add(file1);
root.add(folderJs);
root.add(file3);
// 统一调用:无论 root 内部结构多复杂,只需一个 scan()
root.scan();4. 实战场景
| 场景 | 说明 |
|---|---|
| UI 组件树 | React/Vue 的渲染过程本质上就是组合模式。父组件渲染时会触发子组件的渲染,直到叶子节点。 |
| 宏命令 (Macro Command) | 将一连串操作封装成一个“宏”。执行宏命令时,会依次触发其包含的所有子命令。 |
| 权限控制系统 | 权限可以分配给单个用户(叶子),也可以分配给部门(容器)。校验部门权限时,会自动校验线下所有人的权限。 |
| 表单校验 | 一个大表单包含多个表单项。调用 form.validate() 会自动触发内部所有 input.validate()。 |
5. 常见问题 (FAQ)
5.1 组合模式一定要使用递归吗?
- 答:绝大多数情况下是的。 组合模式的威力就在于通过递归,将操作从根节点一直传递到最末梢的叶子节点。
5.2 叶子对象和容器对象的接口必须完全一致吗?
- 答:是的,这是该模式的灵魂。 如果接口不一致,调用者就必须判断当前是文件还是文件夹,这就失去了“一致性处理”的初衷。
- 技巧:对于叶子对象不支持的操作(如
add),通常抛出异常或给出一个空实现。
- 技巧:对于叶子对象不支持的操作(如
5.3 组合模式和装饰器模式有什么区别?
- 答:
- 组合模式:是为了组织结构。它让多个对象聚合成一个整体。
- 装饰器模式:是为了增强功能。它是给单个对象包一层外壳,不涉及树形结构。
5.4 组合模式的性能开销大吗?
- 答:如果树的深度极深(上万层),递归可能会导致栈溢出 (Stack Overflow)。但在正常的 Web 开发场景(如组件树、文件树)中,这种性能损耗几乎可以忽略不计。
5.5 如何在组合模式中方便地查找父节点?
- 答:可以在
add方法被调用时,给子节点手动注入一个this.parent = this属性。这样每个节点就拥有了双向的引用,不仅能向下分发,也能向上追溯。