Skip to content

组合模式

1. 核心概念与价值

组合模式的定义是:将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

维度描述形象比喻
核心意图模糊“简单对象(叶子)”和“复杂对象(容器)”的边界。文件系统。你右键“删除”,无论是删一个文件还是删一个装有千万文件的文件夹,操作都是一样的。
主要优点1. 忽略层级差异,简化客户端调用逻辑;
2. 易于增加新类型的节点(符合开闭原则);
3. 方便构建复杂的树形结构。
俄罗斯套娃。最里面的小娃娃和外面的大套娃,在“套”这个属性上是一致的。
主要缺点很难在编译时限制组合中的组件类型(例如限制文件夹里只能放图片)。在 JS 这种弱类型语言中尤为明显。

2. 模式结构:叶子与容器

组合模式通常包含两个主要角色:

  1. 叶子对象 (Leaf):最底层的节点,没有子节点,执行具体的操作。
  2. 组合对象 (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 属性。这样每个节点就拥有了双向的引用,不仅能向下分发,也能向上追溯。