讲解

问行政部门「你们有多少人」和问整个集团「你们有多少人」,问法一模一样——部门把自己下面的人数加起来(包括子部门)报上去就行,提问者不需要区分「一个人」和「一个部门」。组合模式就是把这个「部分-整体」的层级抽象出来:用统一的接口对待单个对象(叶子)和对象的组合(容器),客户端无需分辨自己面对的是谁。

GoF 的定义:将对象组合成树形结构以表示「部分-整体」的层次结构,使得用户对单个对象和组合对象的使用具有一致性。结构上有三个角色:Component(组件接口,声明 getSize、render 这类操作)、Leaf(叶子,直接实现)、Composite(容器,持有子组件列表,操作时递归委托给子组件再汇总)。

它的威力在「递归结构 + 统一操作」的场景里无可替代:文件系统(文件/文件夹)、菜单(菜单项/子菜单)、组织架构(员工/部门)、UI 组件树(按钮/面板)、账单(单项/套餐)。凡是「操作需要对整棵树递归执行」的地方,组合模式都能把递归藏起来——客户端只调一次根节点的 getSize()。

设计上有一个著名的取舍:Leaf 应不应该有 add/remove 方法?透明式(接口里声明,叶子抛出「不支持」异常)让客户端完全不区分叶子和容器,但叶子暴露了无意义的操作;安全式(只有容器声明 add/remove)类型上更严谨,代价是客户端想增删子节点时得先判断是不是容器。TypeScript 的接口系统让安全式更自然,本教程示例采用安全式。

示例

文件系统:文件是叶子,文件夹是容器,getSize 递归汇总,describe 递归打印缩进树:

import assert from 'node:assert/strict';

// 组件接口:叶子和容器一视同仁
interface FileNode {
  getSize(): number;
  describe(indent: string): string;
}

class FileLeaf implements FileNode {
  constructor(
    private readonly name: string,
    private readonly size: number,
  ) {}

  getSize(): number {
    return this.size;
  }

  describe(indent: string): string {
    return indent + this.name + ' (' + this.size + 'KB)';
  }
}

class Folder implements FileNode {
  private readonly children: FileNode[] = [];

  constructor(private readonly name: string) {}

  add(node: FileNode): this {
    this.children.push(node);
    return this;
  }

  getSize(): number {
    // 递归:容器的答案 = 所有子节点答案之和
    return this.children.reduce((sum, node) => sum + node.getSize(), 0);
  }

  describe(indent: string): string {
    const lines = this.children.map((c) => c.describe(indent + '  '));
    return [indent + this.name + '/'].concat(lines).join('\n');
  }
}

const docs = new Folder('docs').add(new FileLeaf('简历.pdf', 120)).add(new FileLeaf('笔记.md', 8));
const photos = new Folder('photos').add(new FileLeaf('猫.jpg', 2048));
const root = new Folder('home').add(docs).add(photos);

assert.equal(docs.getSize(), 128);
assert.equal(root.getSize(), 128 + 2048); // 递归穿透两层目录

console.log(root.describe(''));
console.log('总大小:' + root.getSize() + 'KB');

客户端 root.getSize() 这一行背后发生了四次递归调用,但调用方完全无感——它不需要写循环、不需要判断类型。Folder.add 返回 this 顺手支持了链式装配。注意 add 只存在于 Folder 上(安全式),FileLeaf 想 add 会在编译期被拦下。

常见坑

  • 叶子也实现 add/remove 但默默忽略:透明式里叶子对增删操作要么抛异常要么报错,静默成功会让调用方误以为结构改了。
  • 树里出现环:A 文件夹 add 了 B,B 又 add 了 A,递归立刻栈溢出;add 时应检查祖先链(或维护 parent 指针做循环检测)。
  • 在容器里缓存汇总值却忘了失效:getSize 缓存结果后,子树变了缓存没清——要么不缓存,要么增删时向上冒泡失效。
  • 组合模式用来做「深遍历搜索」:组合擅长「对每个节点做同一件事」;带条件的查找、剪枝遍历交给迭代器或访问者更合适。
  • 层级浅也上组合:只有一层「列表 + 元素」时,数组加接口就够,树形抽象是多余的。

小结

组合模式用统一接口抹平叶子与容器的差别,把树形递归藏进容器内部;取舍点在透明式与安全式。前端同学会发现组件树就是这个模式的日常形态。下一章:不改类、运行时给对象叠加新职责的装饰器模式。