JavaScript模式——组合模式:构建树形结构的艺术
本文介绍了组合模式(Composite Pattern)在程序设计中的应用。组合模式通过将对象组织成树形结构,统一处理单个对象和组合对象,适用于文件系统、UI组件等"部分-整体"层次结构场景。文章从宏命令示例入手,展示了组合模式的核心思想,并通过文件扫描案例演示其实现方式。文中还讨论了JavaScript中的实现特点、透明性保障、注意事项(如性能考虑、双向引用)以及删除操作的实
前言
在程序设计中,我们经常遇到“部分-整体”的层次结构,比如文件系统中的文件夹与文件、UI 组件中的容器与子组件。组合模式(Composite Pattern)正是为这类场景而生:它将对象组合成树形结构,以表示“部分-整体”的层次结构,并允许用户以一致的方式处理单个对象和组合对象。
本文将从宏命令入手,逐步深入组合模式的核心思想,并通过文件扫描的实战案例,带你领略这一模式的精妙之处。
一、组合模式初探:从宏命令说起
1.1 回顾宏命令
var closeDoorCommand = {
execute: function () {
console.log('关门');
}
};
var openPcCommand = {
execute: function () {
console.log('开电脑');
}
};
var openQQCommand = {
execute: function () {
console.log('登录QQ');
}
};
var MacroCommand = function () {
return {
commandsList: [],
add: function (command) {
this.commandsList.push(command);
},
execute: function () {
for (var i = 0, command; command = this.commandsList[i++];) {
command.execute();
}
}
};
};
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();
通过观察这段代码,我们很容易发现,宏命令中包含了一组子命令,它们组成了一个树形结构,这里是一棵结构非常简单的树。其中,macroCommand 被称为组合对象,closeDoorCommand、openPcCommand、openQQCommand 都是叶对象。在 macroCommand 的 execute 方法里,并不执行真正的操作,而是遍历它所包含的叶对象,把真正的 execute 请求委托给这些叶对象。
macroCommand 表现得像一个命令,但它实际上只是一组真正命令的“代理”。并非真正的代理,虽然结构上相似,但 macroCommand 只负责传递请求给叶对象,它的目的不在于控制对叶对象的访问。
1.2 组合模式的用途
组合模式正是这种思想的提炼:
- 表示树形结构:通过递归组合,可以轻松构建任意深度的层次结构。
- 统一对待单个对象与组合对象:客户代码无需区分叶节点和组合节点,只需调用相同的接口。
这种透明性使得系统更容易扩展和维护。
二、请求在树中传递的过程
在组合模式中,请求从树的根节点开始,沿着树枝向下传递,直到抵达叶节点。组合对象收到请求后,会将其转发给所有子节点;叶节点则直接处理请求。
这种“自上而下”的传递机制,让客户只需调用顶层对象,就能完成对整棵树的统一操作。
三、更强大的宏命令:构建复杂树结构
为了更好地理解组合模式,我们构建一个更复杂的宏命令,它嵌套了多个子宏命令,形成一棵层次分明的树。
var MacroCommand = function () {
return {
commandsList: [],
add: function (command) {
this.commandsList.push(command);
},
execute: function () {
for (var i = 0, command; command = this.commandsList[i++];) {
command.execute();
}
}
};
};
// 开空调命令(叶节点)
var openAcCommand = {
execute: function () {
console.log('打开空调');
}
};
// 打开电视和音响(组合命令)
var openTvCommand = {
execute: function () {
console.log('打开电视');
}
};
var openSoundCommand = {
execute: function () {
console.log('打开音响');
}
};
var macroCommand1 = MacroCommand();
macroCommand1.add(openTvCommand);
macroCommand1.add(openSoundCommand);
// 关门、开电脑、登录QQ(组合命令)
var closeDoorCommand = {
execute: function () {
console.log('关门');
}
};
var openPcCommand = {
execute: function () {
console.log('开电脑');
}
};
var openQQCommand = {
execute: function () {
console.log('登录QQ');
}
};
var macroCommand2 = MacroCommand();
macroCommand2.add(closeDoorCommand);
macroCommand2.add(openPcCommand);
macroCommand2.add(openQQCommand);
// 最终的超级宏命令
var superMacroCommand = MacroCommand();
superMacroCommand.add(openAcCommand);
superMacroCommand.add(macroCommand1);
superMacroCommand.add(macroCommand2);
superMacroCommand.execute();
// 输出顺序:打开空调 → 打开电视 → 打开音响 → 关门 → 开电脑 → 登录QQ
通过嵌套组合,我们构建了一棵深度为三层的命令树,而调用时只需执行根节点的 execute 方法,请求便会沿着树向下传播,所有节点各司其职。
四、抽象类与透明性:JavaScript 中的实现挑战
在 Java 等静态语言中,组合模式通常要求组合对象和叶对象继承自同一个抽象类,以保证接口一致。但 JavaScript 是动态类型语言,没有编译期的类型检查,因此我们通常使用鸭子类型来确保对象拥有相同的方法。
为了增强代码的健壮性,可以为叶对象也添加 add 方法,并在调用时抛出异常,提醒开发者误用:
var openTvCommand = {
execute: function () {
console.log('打开电视');
},
add: function () {
throw new Error('叶对象不能添加子节点');
}
};
这样,即使在运行时误操作,也能及时得到反馈。
五、实战示例:扫描文件夹
文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以 包含其他文件夹,最终可能组合成一棵树,组合模式在文件夹的应用中有以下两层好处。
我们用一个更贴近现实的例子来加深理解:模拟文件系统中的文件夹与文件。
5.1 定义 Folder 和 File 类
var Folder = function (name) {
this.name = name;
this.files = [];
};
Folder.prototype.add = function (file) {
this.files.push(file);
};
Folder.prototype.scan = function () {
console.log('开始扫描文件夹:' + this.name);
for (var i = 0, file; file = this.files[i++];) {
file.scan();
}
};
var File = function (name) {
this.name = name;
};
File.prototype.add = function () {
throw new Error('文件下面不能再添加文件');
};
File.prototype.scan = function () {
console.log('开始扫描文件:' + this.name);
};
5.2 构建文件树
var folder = new Folder('学习资料');
var folder1 = new Folder('JavaScript');
var folder2 = new Folder('jQuery');
var file1 = new File('JavaScript 设计模式与开发实践');
var file2 = new File('精通jQuery');
var file3 = new File('重构与模式');
folder1.add(file1);
folder2.add(file2);
folder.add(folder1);
folder.add(folder2);
folder.add(file3);
// 新增一些文件
var folder3 = new Folder('Nodejs');
var file4 = new File('深入浅出Node.js');
folder3.add(file4);
var file5 = new File('JavaScript 语言精髓与编程实践');
folder.add(folder3);
folder.add(file5);
folder.scan();
执行 folder.scan(),会递归地扫描所有文件夹和文件,输出类似下面的结果:
开始扫描文件夹:学习资料
开始扫描文件夹:JavaScript
开始扫描文件:JavaScript 设计模式与开发实践
开始扫描文件夹:jQuery
开始扫描文件:精通jQuery
开始扫描文件:重构与模式
开始扫描文件夹:Nodejs
开始扫描文件:深入浅出Node.js
开始扫描文件:JavaScript 语言精髓与编程实践
这个例子完美诠释了组合模式的透明性:客户(folder.scan())完全不知道当前处理的是文件夹还是文件,统一调用 scan 方法即可。
六、组合模式的注意事项
6.1 组合模式不是父子关系
组合模式体现的是 HAS-A 关系(聚合),而非 IS-A 关系。文件夹包含文件,但文件并不是文件夹的子类。它们之所以能协同工作,是因为拥有相同的接口。
6.2 对叶对象操作的一致性
组合模式要求对列表中的每个对象执行相同的操作。如果需要对叶对象进行差异化处理,组合模式可能不再适用。
6.3 双向映射关系
如果对象可以属于多个父节点(如一个文件同时存在于两个文件夹中),则需要维护双向引用,这会增加复杂度。此时可考虑使用中介者模式。
6.4 性能考虑
树结构过深或节点过多时,递归遍历可能带来性能开销。可以结合职责链模式,让请求沿树向上传递,从而避免不必要的遍历。
七、引用父对象:实现删除操作
在实际应用中,我们往往需要从树中删除节点。为此,可以为每个节点添加一个 parent 属性,并实现 remove 方法。
var Folder = function (name) {
this.name = name;
this.parent = null;
this.files = [];
};
Folder.prototype.add = function (file) {
file.parent = this;
this.files.push(file);
};
Folder.prototype.scan = function () {
console.log('开始扫描文件夹:' + this.name);
for (var i = 0, file; file = this.files[i++];) {
file.scan();
}
};
Folder.prototype.remove = function () {
if (!this.parent) return;
for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
var file = files[l];
if (file === this) {
files.splice(l, 1);
}
}
};
var File = function (name) {
this.name = name;
this.parent = null;
};
File.prototype.add = function () {
throw new Error('不能添加在文件下面');
};
File.prototype.scan = function () {
console.log('开始扫描文件:' + this.name);
};
File.prototype.remove = function () {
if (!this.parent) return;
for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
var file = files[l];
if (file === this) {
files.splice(l, 1);
}
}
};
测试删除功能:
var folder = new Folder('学习资料');
var folder1 = new Folder('JavaScript');
var file1 = new File('JavaScript 设计模式与开发实践');
folder1.add(file1);
folder.add(folder1);
folder1.remove(); // 删除 JavaScript 文件夹
folder.scan(); // 只会扫描 学习资料 文件夹,内部已无内容
八、何时使用组合模式
组合模式适用于以下场景:
- 需要表示对象的部分-整体层次结构,且不确定树有多少层。
- 希望客户代码统一处理单个对象与组合对象,避免编写大量
if-else判断。
九、小结
组合模式通过将对象组织成树形结构,让客户端可以一致地对待叶子节点与组合节点,极大地简化了代码逻辑。在 JavaScript 中,虽然我们没有抽象类和接口的强制约束,但通过鸭子类型和统一的接口约定,依然可以完美地实现组合模式。
从宏命令到文件系统,我们看到了组合模式在构建复杂层次结构中的强大能力。掌握它,你将能更优雅地应对“部分-整体”的设计挑战。
更多推荐

所有评论(0)