1. 从"文件树"说起:组合模式到底在解决什么麻烦
很多人第一次接触组合模式,脑子里冒出来的都是教科书上那套"部分-整体"的定义,看完还是不知道什么时候该用。我换个说法你就懂了:假设你要做一个文件管理器,里面有文件夹和文件两种东西。文件夹可以装文件,也可以装别的文件夹,一层套一层。现在产品经理跑过来跟你说:"我要一个功能,输入任意一个节点,算出它下面所有文件的总大小。"你怎么写?
最直觉的写法是写两个函数,一个算文件大小,一个算文件夹大小,文件夹那个函数里再判断子节点是文件还是文件夹,递归调用。能跑,但问题来了——如果以后要加第三种节点类型呢?如果还要算总文件数量、要按名字搜索、要渲染成树形UI呢?每加一个操作,你都得写一套新的递归逻辑,而且每套逻辑里都要重复判断"这是文件还是文件夹"。代码会迅速膨胀成一团乱麻。
组合模式的核心价值就一句话:让客户端用完全相同的方式对待单个对象和对象的组合。翻译成人话就是,不管你拿到的是一个文件还是一个文件夹,你调用的方法名、传的参数、拿到的返回结构都一模一样。你不需要写if (node.type === 'file')这种判断,直接node.getSize()就完事了。这个"统一接口"才是组合模式真正的杀手锏,而不是什么"树形结构"——树形结构只是它的外在表现。
在JavaScript里实现组合模式有个天然优势:JS是动态类型语言,不需要像Java那样先定义一个抽象类或者接口,再让叶子和容器去继承实现。JS可以直接靠鸭子类型(duck typing)——只要你有add、remove、getSize这些方法,我就认为你是一个合法的组合节点。这让代码量大幅减少,但也埋了一个坑:没有编译期检查,你调了一个不存在的方法,只有运行时才会报错。这个坑后面我会专门讲怎么填。
组合模式适用的场景其实比你想的广。除了文件系统,还有:UI组件树(一个div里可以放按钮,也可以放另一个div)、组织架构图(部门下面有员工,也有子部门)、菜单系统(菜单项下面有子菜单)、图形编辑器(一个组合图形由多个基本图形拼成)。判断标准很简单:如果你发现自己在写递归,而且递归里反复判断"这是单个还是多个",那基本就是组合模式该上场了。
2. 透明式还是安全式:一个必须提前做的架构选择
动手写代码之前,有个决策必须先做,而且这个决策会直接影响你后面所有代码的写法。组合模式在实现上分两大流派:透明式和安全式。这两个词听着玄乎,其实说的就是一件事——那些只有容器才需要的方法(比如add、remove),要不要也放到叶子节点上?
2.1 透明式:接口统一但叶子方法会"空转"
透明式的做法是,叶子和容器实现完全相同的接口,容器有的add、remove方法,叶子也有,只不过叶子的add方法要么抛异常,要么什么都不做。这样客户端拿到任何一个节点,都可以放心地调add,不用判断类型。
// 透明式:叶子也有 add/remove,但调用会报错 class FileNode { constructor(name, size) { this.name = name; this.size = size; } getSize() { return this.size; } add() { throw new Error('文件节点不支持 add 操作'); } remove() { throw new Error('文件节点不支持 remove 操作'); } } class FolderNode { constructor(name) { this.name = name; this.children = []; } getSize() { return this.children.reduce((sum, child) => sum + child.getSize(), 0); } add(child) { this.children.push(child); } remove(child) { const idx = this.children.indexOf(child); if (idx > -1) this.children.splice(idx, 1); } }透明式的好处是客户端代码极其干净,你写一个递归函数遍历整棵树,完全不用关心当前节点是文件还是文件夹。坏处是类型不安全——你对着一个文件调add,编译期(如果有的话)不报错,运行时才炸。在JS这种没有编译期的语言里,这个坏处会被放大。
2.2 安全式:叶子干净但客户端要判断类型
安全式的做法反过来:add、remove只定义在容器上,叶子节点根本没有这些方法。这样叶子节点很干净,不会出现"调了不该调的方法"的情况。但代价是客户端在操作之前必须判断类型。
// 安全式:叶子没有 add/remove class FileNode { constructor(name, size) { this.name = name; this.size = size; } getSize() { return this.size; } } class FolderNode { constructor(name) { this.name = name; this.children = []; } getSize() { return this.children.reduce((sum, child) => sum + child.getSize(), 0); } add(child) { this.children.push(child); } remove(child) { const idx = this.children.indexOf(child); if (idx > -1) this.children.splice(idx, 1); } } // 客户端必须判断 function buildTree(node, data) { if (node instanceof FolderNode) { data.forEach(item => { const child = item.type === 'folder' ? new FolderNode(item.name) : new FileNode(item.name, item.size); node.add(child); buildTree(child, item.children || []); }); } }2.3 我的选择建议和理由
那到底选哪个?我的经验是:看你的客户端代码里"遍历"多还是"构建"多。如果主要是遍历(比如算大小、搜索、渲染),透明式更省心,因为遍历逻辑不需要判断类型。如果主要是构建(比如从JSON数据建树),安全式更稳妥,因为构建时你本来就知道自己在操作容器还是叶子。
但还有一个更实际的因素:团队里新人的水平。透明式虽然优雅,但新人很容易对着叶子调add然后一脸懵地来问你为什么报错。安全式虽然啰嗦,但至少不会让人误用。我在实际项目里的折中方案是:用透明式,但叶子的add不抛异常,而是返回false或者打一条警告日志,这样既保持了接口统一,又不会让程序直接崩掉。
提示:如果你用的是TypeScript,透明式的类型安全问题可以被大幅缓解——给叶子节点的
add方法签名标注为never返回或者直接不实现,让编译器帮你拦住误用。纯JS项目就只能靠运行时检查和代码规范了。
3. 手写一个能跑的文件系统:完整实现与逐行拆解
光看代码片段不过瘾,我们从头搭一个完整的、能跑的文件系统组合模式实现。这个实现会包含节点定义、树的构建、以及几个典型操作(算大小、搜索、打印树形结构)。你直接复制到浏览器控制台或者Node环境就能跑。
3.1 基础节点类:叶子与容器的分工
先定义两个类。这里我采用透明式,但叶子的add返回false而不是抛异常,理由上面说过了。
class File { constructor(name, size) { this.name = name; this.size = size; // 单位 KB this.type = 'file'; } getSize() { return this.size; } // 透明式:叶子也有这些方法,但返回 false 表示操作无效 add() { console.warn(`[${this.name}] 是文件,无法添加子节点`); return false; } remove() { console.warn(`[${this.name}] 是文件,没有子节点可移除`); return false; } // 搜索:文件只判断自己是否匹配 search(keyword) { return this.name.includes(keyword) ? [this] : []; } // 打印:文件直接输出自己 print(indent = '') { console.log(`${indent}📄 ${this.name} (${this.size}KB)`); } } class Folder { constructor(name) { this.name = name; this.children = []; this.type = 'folder'; } getSize() { // 递归求和,这里体现了组合模式的精髓: // 不需要判断 child 是 File 还是 Folder,直接调 getSize return this.children.reduce((sum, child) => sum + child.getSize(), 0); } add(child) { this.children.push(child); return true; } remove(child) { const idx = this.children.indexOf(child); if (idx > -1) { this.children.splice(idx, 1); return true; } return false; } search(keyword) { // 先看自己是否匹配 const result = this.name.includes(keyword) ? [this] : []; // 再递归搜索所有子节点 this.children.forEach(child => { result.push(...child.search(keyword)); }); return result; } print(indent = '') { console.log(`${indent}📁 ${this.name}/`); this.children.forEach(child => { child.print(indent + ' '); }); } }注意getSize和search这两个方法——它们就是组合模式的灵魂所在。Folder.getSize里调child.getSize()的时候,根本不关心child是文件还是文件夹,因为两者都有getSize。search同理。这就是"统一接口"带来的直接好处:递归逻辑只写一遍,而且不需要任何类型判断。
3.2 构建一棵真实的树
有了节点类,我们来搭一棵有代表性的树。结构设计得稍微复杂一点,包含多层嵌套,这样后面测试才有说服力。
// 构建根目录 const root = new Folder('项目根目录'); // 一级子节点 const src = new Folder('src'); const docs = new Folder('docs'); const readme = new File('README.md', 5); const packageJson = new File('package.json', 2); // src 下面的内容 const components = new Folder('components'); const utils = new Folder('utils'); const indexJs = new File('index.js', 10); const appJs = new File('app.js', 15); // components 下面的内容 const buttonJs = new File('Button.js', 8); const modalJs = new File('Modal.js', 12); const nestedFolder = new Folder('nested'); const deepFile = new File('deepFile.js', 3); // utils 下面的内容 const helperJs = new File('helper.js', 6); const formatJs = new File('format.js', 4); // docs 下面的内容 const guideMd = new File('guide.md', 20); const apiMd = new File('api.md', 30); // 组装 nestedFolder.add(deepFile); components.add(buttonJs); components.add(modalJs); components.add(nestedFolder); utils.add(helperJs); utils.add(formatJs); src.add(components); src.add(utils); src.add(indexJs); src.add(appJs); docs.add(guideMd); docs.add(apiMd); root.add(src); root.add(docs); root.add(readme); root.add(packageJson);这棵树的结构是这样的:
项目根目录/ ├── src/ │ ├── components/ │ │ ├── Button.js │ │ ├── Modal.js │ │ └── nested/ │ │ └── deepFile.js │ ├── utils/ │ │ ├── helper.js │ │ └── format.js │ ├── index.js │ └── app.js ├── docs/ │ ├── guide.md │ └── api.md ├── README.md └── package.json3.3 验证三个核心操作
现在来跑一下,看看组合模式的实际效果。
// 1. 算总大小 console.log('总大小:', root.getSize(), 'KB'); // 输出: 总大小: 115 KB // 2. 搜索包含 "js" 的节点 const jsFiles = root.search('js'); console.log('匹配 "js" 的节点:', jsFiles.map(n => n.name)); // 输出: ['index.js', 'app.js', 'Button.js', 'Modal.js', 'deepFile.js', 'helper.js', 'format.js'] // 3. 打印整棵树 root.print();getSize的结果是 115KB,你可以自己加一下验证:5+2+10+15+8+12+3+6+4+20+30 = 115。正确。搜索"js"返回了7个文件,也符合预期。打印出来的树形结构层次分明。
这三个操作里,没有任何一处写了if (node.type === 'file')这样的判断。这就是组合模式最直观的收益。你想想如果用传统写法,getSize里要判断、search里要判断、print里要判断,三个地方重复三遍,以后加第四个操作还要再写一遍。组合模式把这个判断消灭在了节点类内部——每个节点自己知道该怎么处理自己。
4. 踩过的坑:组合模式在JS里的五个真实陷阱
组合模式原理不复杂,但在JS里实操的时候,有几个坑我几乎每次都能见到有人踩。这一节我把这些坑一个个拆开讲,包括怎么发现、为什么会出现、以及怎么修。
4.1 循环引用:把父节点加进了自己的子树
这是最隐蔽也最致命的坑。看下面这段代码:
const folderA = new Folder('A'); const folderB = new Folder('B'); folderA.add(folderB); folderB.add(folderA); // 灾难:B 把 A 加成了自己的子节点 // 现在调 folderA.getSize() 会怎样? // 栈溢出!因为 A 算大小要算 B,B 算大小要算 A,无限递归这个坑的可怕之处在于,构建阶段完全不报错,add方法老老实实把节点推进了数组。只有当你调用getSize、search、print这些递归方法时,才会突然栈溢出。如果这棵树是用户通过UI动态构建的,你根本不知道是哪一步操作引入了循环。
修复方案是在add的时候做检查。但"检查是否形成循环"这件事本身就需要遍历,成本不低。我的做法是维护一个父指针,add的时候沿着父指针往上找,如果找到了要添加的节点,说明会形成环,直接拒绝。
class Folder { constructor(name) { this.name = name; this.children = []; this.parent = null; // 新增父指针 } add(child) { // 检查是否会形成循环 let ancestor = this; while (ancestor) { if (ancestor === child) { console.warn('检测到循环引用,添加被拒绝'); return false; } ancestor = ancestor.parent; } child.parent = this; this.children.push(child); return true; } remove(child) { const idx = this.children.indexOf(child); if (idx > -1) { this.children.splice(idx, 1); child.parent = null; return true; } return false; } }注意:父指针虽然解决了循环检测,但引入了新的维护成本——
remove的时候必须把child.parent置空,否则会内存泄漏(被移除的节点还引用着父节点,父节点也引用着它)。这种双向引用在JS的垃圾回收机制下,如果不用WeakMap,是回收不掉的。
4.2 共享子节点:同一个节点被加到两个父节点下
组合模式默认是一棵树,但如果你不小心把同一个节点add到了两个不同的父节点下,它就变成了一张图。这时候getSize会把同一个文件算两次,print会打印两遍,remove从一个父节点移除后另一个父节点还引用着它。
const file = new File('shared.js', 10); const folderX = new Folder('X'); const folderY = new Folder('Y'); folderX.add(file); folderY.add(file); // 同一个 file 被加了两次 console.log(folderX.getSize()); // 10 console.log(folderY.getSize()); // 10 // 看起来没问题,但如果 X 和 Y 都在同一棵大树下,总大小就会多算 10这个坑的根源是add方法没有检查节点是否已经有父节点。修复很简单,在add里判断child.parent是否为空,不为空就拒绝或者先自动从原父节点移除。
add(child) { if (child.parent) { console.warn(`[${child.name}] 已有父节点,请先移除`); return false; } // ... 循环检测逻辑 child.parent = this; this.children.push(child); return true; }4.3 递归深度过大导致栈溢出
JS的调用栈深度是有限的,不同引擎不一样,V8大概在一万到一万五千层左右。如果你的树非常深(比如一个递归生成的目录结构,深度上千),getSize这种递归方法就会栈溢出。
// 构造一棵深度为 20000 的树 let current = new Folder('root'); const root = current; for (let i = 0; i < 20000; i++) { const child = new Folder('folder' + i); current.add(child); current = child; } root.getSize(); // RangeError: Maximum call stack size exceeded解决方案有两个。一是改用迭代(用显式栈代替递归调用栈),二是限制树的深度。迭代版本的getSize长这样:
getSize() { let total = 0; const stack = [this]; while (stack.length) { const node = stack.pop(); if (node.type === 'file') { total += node.size; } else { stack.push(...node.children); } } return total; }注意这个迭代版本里出现了node.type === 'file'的判断——因为迭代没法像递归那样靠多态自动分发。这是迭代方案的一个小代价,但换来的是不会栈溢出。实际项目里,如果树的深度可控(比如UI组件树一般不会超过几十层),递归完全够用;如果是处理用户上传的任意深度的数据,那就得用迭代。
4.4 方法名冲突:节点自己的属性和组合接口撞车
这个坑比较隐蔽。假设你的文件节点有一个size属性,同时你又想给文件夹加一个size方法返回子节点数量。在JS里,属性和方法都在同一个命名空间下,很容易撞车。
class File { constructor(name, size) { this.size = size; // 属性 } // 如果这里再定义一个 size() 方法,就会覆盖属性 }更常见的是name、type、children这些词,很容易和业务属性重名。我的建议是给组合接口加一个统一前缀,比如nodeGetSize、nodeAdd,虽然丑但绝对不会撞。或者用Symbol作为方法名,彻底隔离命名空间。
const GET_SIZE = Symbol('getSize'); const ADD = Symbol('add'); class File { [GET_SIZE]() { return this.size; } [ADD]() { return false; } }Symbol方案的缺点是调用方也得用Symbol,写起来啰嗦。实际项目里我一般还是用普通方法名,但会在团队规范里明确列出"保留方法名",禁止业务属性使用这些名字。
4.5 序列化与反序列化:JSON.stringify 会丢方法
组合模式的节点是对象,对象上有方法。当你把树JSON.stringify存到localStorage或者发给后端时,方法全丢了,只剩数据。反序列化回来得到的是纯对象,调getSize直接报"不是函数"。
const json = JSON.stringify(root); // json 里只有 name、size、children、type 这些数据字段,没有 getSize 等方法 const restored = JSON.parse(json); restored.getSize(); // TypeError: restored.getSize is not a function解决方案是写一对序列化和反序列化函数,反序列化时根据type字段重新构造对应的类实例。
function serialize(node) { if (node.type === 'file') { return { type: 'file', name: node.name, size: node.size }; } return { type: 'folder', name: node.name, children: node.children.map(serialize) }; } function deserialize(data) { if (data.type === 'file') { return new File(data.name, data.size); } const folder = new Folder(data.name); data.children.forEach(childData => { folder.add(deserialize(childData)); }); return folder; }这对函数看起来简单,但实际项目里经常被忽略。等到用户刷新页面发现数据加载不出来才想起来,那时候改起来就麻烦了。所以我的习惯是:只要节点类要持久化,序列化函数和节点类一起写,绝不分开。
5. 组合模式与递归、迭代的配合:性能与可读性的权衡
组合模式天然和递归绑在一起,但递归不是唯一选择。这一节聊聊什么时候该用递归、什么时候该用迭代,以及怎么在保持组合模式优点的同时优化性能。
5.1 递归的优雅与代价
递归版本的组合模式代码读起来非常舒服,因为"处理一个节点"和"处理一棵子树"的代码长得一样。这种对称性是组合模式最大的可读性优势。
// 递归版:统计所有文件数量 countFiles() { if (this.type === 'file') return 1; return this.children.reduce((sum, child) => sum + child.countFiles(), 0); }但递归有两个代价:调用栈开销和栈溢出风险。调用栈开销在树不大的时候可以忽略,但如果你要在一个循环里反复调用递归方法(比如实时搜索,每次输入都触发一次全树搜索),累积起来就很可观了。
5.2 迭代版本怎么写才不破坏组合模式
迭代版本需要显式维护一个栈或队列,代码没那么优雅,但性能更可控。关键是要把"判断节点类型"的逻辑封装好,不要让迭代逻辑里到处是if。
// 迭代版:统计所有文件数量 countFiles() { let count = 0; const stack = [this]; while (stack.length) { const node = stack.pop(); if (node.isLeaf()) { count++; } else { stack.push(...node.children); } } return count; }注意这里用了node.isLeaf()而不是node.type === 'file'。给每个节点类加一个isLeaf()方法,叶子返回true,容器返回false。这样迭代逻辑里就不需要知道具体的类型字符串,保持了组合模式的封装性。
class File { isLeaf() { return true; } } class Folder { isLeaf() { return false; } }5.3 什么时候该放弃递归
我的判断标准是三条:树的深度是否可控、方法是否会被高频调用、运行环境是否有栈限制。三条里中了两条,就该考虑迭代。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| UI组件树渲染 | 递归 | 深度一般不超过50层,可读性优先 |
| 文件系统扫描 | 迭代 | 深度不可控,可能上千层 |
| 实时搜索过滤 | 迭代 | 高频调用,递归开销累积明显 |
| 一次性数据统计 | 递归 | 调用次数少,可读性优先 |
| 移动端低内存环境 | 迭代 | 栈空间更紧张 |
还有一个折中方案:尾递归优化。但JS引擎对尾递归的支持一直不好,V8曾经支持过又移除了,所以这个方案在JS里基本不可行,别指望。
5.4 缓存:组合模式里最容易被忽略的优化
组合模式有一个天然适合缓存的特性:子树的大小、数量这些聚合值,只要子树没变,就不用重算。你可以给容器节点加一个脏标记,add或remove的时候把从当前节点到根节点的路径全部标记为脏,getSize的时候如果节点不脏就直接返回缓存值。
class Folder { constructor(name) { this.name = name; this.children = []; this.parent = null; this._sizeCache = null; // 缓存 } getSize() { if (this._sizeCache !== null) { return this._sizeCache; } const size = this.children.reduce((sum, child) => sum + child.getSize(), 0); this._sizeCache = size; return size; } add(child) { // ... 检查逻辑 this.children.push(child); child.parent = this; this._invalidateCache(); // 使缓存失效 return true; } _invalidateCache() { this._sizeCache = null; if (this.parent) { this.parent._invalidateCache(); } } }这个优化在树很大且读多写少的场景下效果显著。我做过一个测试,一万个节点的树,无缓存时每次getSize要遍历全部节点,有缓存时第一次遍历之后后续调用都是O(1)。但要注意,_invalidateCache本身是递归的,如果树很深,add操作的代价会变大。所以这个优化适合"写少读多"的场景,如果你的树频繁变动,缓存反而拖后腿。
6. 从文件系统到真实业务:组合模式的三个落地变体
组合模式不是只能用来做文件系统。这一节我分享三个我在实际项目里用组合模式解决过的真实问题,都是脱敏后的场景,但思路可以直接抄。
6.1 变体一:可配置的表单校验树
做过复杂表单的人都知道,表单校验逻辑很容易写成一团乱麻。一个表单里有基础字段(输入框、下拉框),也有分组(一个卡片里包含多个字段),还有嵌套分组(卡片里再套卡片)。每个字段有自己的校验规则,分组的校验结果是所有子字段校验结果的聚合。
用组合模式,字段是叶子,分组是容器,统一接口是validate()。叶子的validate返回自己的校验结果,容器的validate递归调用所有子节点的validate并聚合。
class Field { constructor(name, value, rules) { this.name = name; this.value = value; this.rules = rules; } validate() { const errors = []; this.rules.forEach(rule => { const result = rule(this.value); if (result) errors.push({ field: this.name, message: result }); }); return errors; } } class FieldGroup { constructor(name) { this.name = name; this.children = []; } add(child) { this.children.push(child); } validate() { return this.children.reduce((errors, child) => { return errors.concat(child.validate()); }, []); } }这个设计的好处是,表单结构怎么变,校验逻辑都不用改。加一个字段就add一个Field,加一个分组就add一个FieldGroup,validate永远只写一遍。而且你可以给FieldGroup加一个validateAsync方法做异步校验,叶子节点不需要知道异步的存在。
6.2 变体二:权限树与访问控制
后台管理系统里,权限通常是一棵树:一级菜单下面有二级菜单,二级菜单下面有按钮级别的权限点。判断一个用户有没有某个权限,需要沿着树往上查——如果用户有父节点的权限,就自动拥有所有子节点的权限。
这个场景里,叶子是权限点,容器是菜单,统一接口是hasPermission(userPermissions)。叶子的实现是检查自己的ID是否在用户的权限列表里,容器的实现是"自己有权限或者任意子节点有权限"。
class Permission { constructor(id) { this.id = id; } hasPermission(userPerms) { return userPerms.includes(this.id); } } class Menu { constructor(id) { this.id = id; this.children = []; } add(child) { this.children.push(child); } hasPermission(userPerms) { // 自己有权限,或者任意子节点有权限 if (userPerms.includes(this.id)) return true; return this.children.some(child => child.hasPermission(userPerms)); } }这个实现里有个细节值得说:Menu.hasPermission用了some而不是every。因为权限是"拥有任意一个子权限即可访问父菜单",而不是"必须拥有所有子权限"。这个语义差异在写代码的时候很容易搞混,一定要和产品确认清楚。
6.3 变体三:可撤销的操作历史树
这个变体比较特殊,是把组合模式用在"操作历史"上。一个复杂的编辑操作(比如"批量修改十个字段")可以拆成多个子操作,每个子操作又可以拆成更细的操作。撤销的时候,需要递归撤销所有子操作。
叶子的undo执行具体的撤销逻辑,容器的undo递归调用所有子节点的undo。统一接口是undo()和redo()。
class SetValueCommand { constructor(target, oldValue, newValue) { this.target = target; this.oldValue = oldValue; this.newValue = newValue; } undo() { this.target.value = this.oldValue; } redo() { this.target.value = this.newValue; } } class BatchCommand { constructor() { this.commands = []; } add(cmd) { this.commands.push(cmd); } undo() { // 注意:撤销要反序执行 for (let i = this.commands.length - 1; i >= 0; i--) { this.commands[i].undo(); } } redo() { this.commands.forEach(cmd => cmd.redo()); } }这里有个关键细节:撤销必须反序执行。因为后面的操作可能依赖前面的操作的结果,正序撤销会导致状态不一致。这个坑我在一个编辑器项目里踩过,当时调试了一下午才发现是撤销顺序的问题。
7. 组合模式与其他设计模式的边界:别用错地方
组合模式很好用,但不是万能的。这一节聊聊它和几个容易混淆的设计模式的区别,以及什么时候不该用组合模式。
7.1 组合模式 vs 装饰器模式
这两个模式在结构上长得很像,都是"一个对象持有另一个对象",都通过递归或委托来工作。区别在于意图:组合模式是为了表示"部分-整体"的层次结构,装饰器模式是为了动态添加职责。
判断标准:如果你是在构建一棵树,节点之间有父子关系,用组合模式。如果你是在给一个对象层层包裹新功能(比如给一个按钮加边框、再加阴影、再加动画),用装饰器模式。装饰器的包裹链通常是一条线,组合模式的节点关系是一棵树。
7.2 组合模式 vs 责任链模式
责任链模式也是"一个对象持有下一个对象",但它的意图是"沿着链传递请求直到被处理"。组合模式的意图是"统一对待单个和组合"。
一个简单的区分方法:责任链的节点通常只有一个后继(next),组合模式的容器有多个子节点(children数组)。责任链是线性的,组合模式是树形的。
7.3 什么时候不该用组合模式
有三种情况我建议你别用组合模式:
第一种,数据结构本来就是扁平的。如果你处理的数据没有层次关系,硬套组合模式只会增加复杂度。比如一个用户列表,每个用户就是一条记录,没有嵌套,那就老老实实用数组。
第二种,叶子和容器的行为差异太大。组合模式的前提是"叶子和容器可以用同样的方式对待"。如果你的叶子和容器需要的方法完全不同,强行统一接口只会得到一堆空实现或者抛异常的方法,那就失去了组合模式的意义。
第三种,性能极度敏感且树非常大。组合模式的递归遍历在超大树上的性能开销是实打实的。如果你的场景是每秒要遍历百万级节点的树几十次,那可能需要考虑更底层的数据结构(比如用扁平数组加索引,或者用数据库的递归查询)。
7.4 一个实用的判断清单
每次我考虑用组合模式之前,会问自己四个问题:
- 数据是否有明显的层次结构(树形)?
- 是否存在"对单个节点"和"对整棵子树"操作逻辑相同的情况?
- 未来是否可能增加新的节点类型或新的操作?
- 客户端代码是否因为频繁判断节点类型而变得臃肿?
四个问题里如果有三个回答"是",组合模式基本就是对的。如果只有一两个"是",那可能用简单的递归函数就够了,不需要引入完整的组合模式结构。
8. 写在最后:一些零散但有用的经验
组合模式这个主题,教科书上讲得都很标准,但实际用起来有很多教科书不会提的细节。最后分享几条我自己的经验,都是踩过坑之后总结出来的。
关于接口设计,我的建议是方法名尽量用动词开头,比如getSize、addChild、removeChild、findByName。这样一眼就能看出是操作而不是属性,减少和业务属性撞车的概率。另外,容器的add方法最好返回布尔值表示是否成功,而不是返回undefined,这样调用方可以判断操作结果。
关于调试,组合模式的树一旦出问题,定位起来很痛苦,因为递归调用栈很深。我的做法是给每个节点加一个path属性,记录从根节点到自己的路径(比如root/src/components/Button.js)。这样报错的时候打印path,一眼就能看出是哪个节点出的问题。path可以在add的时候自动计算:child.path = this.path + '/' + child.name。
关于测试,组合模式的测试重点应该放在边界情况上:空容器(没有子节点的文件夹)、单节点树、深度嵌套、循环引用、共享节点。这些情况在正常业务里不常出现,但一旦出现就是大问题。我一般会写一组专门的边界测试用例,每次改节点类都跑一遍。
关于和框架的结合,如果你在用React或Vue这类框架,组合模式的树和框架的组件树其实是同构的。你可以把组合模式的节点直接映射成组件,容器的render递归渲染子节点。但要注意框架的更新机制——组合模式的节点是普通对象,框架不会自动追踪它们的变化,你需要手动触发更新或者用响应式包装。
最后说一个心态上的事。组合模式刚学的时候容易过度使用,看到树形结构就想套。但实际项目里,很多所谓的"树"其实只有两层,用简单的嵌套对象就够了,没必要上完整的组合模式。设计模式是工具不是目的,能用简单方案解决的就别上模式。我见过太多项目因为过早引入设计模式而变得难以维护,这个教训值得记住。