组合模式

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。 除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性

​ 在组合模式中,请求在树中传递的过程总是遵循一种逻辑。请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求作出相应的处理,如果当前处理请求的对象是组合对象(宏命令), 组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。

组合模式请求传递方式

组合模式下更为强大的宏命令

​ 目前的万能遥控器,包含了关门、开电脑、登录 QQ 这 3 个命令。现在我们需要一个“超级万能遥控器”,可以控制家里所有的电器,这个遥控器拥有以下功能

  • 打开空调
  • 打开电视和音响
  • 关门、开电脑、登录 QQ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
let MacroCommand = function () {
return {
commandsList: [],
add: function (command) {
this.commandsList.push(command);
},
execute: function () {
for (let i = 0, command; command = this.commandsList[i++];) {
command.execute();
}
}
}
};
let openAcCommand = {
execute: function () {
console.log('打开空调');
}
};
// 家里的电视和音响是连接在一起的,所以可以用一个宏命令来组合打开电视和打开音响的命令
let openTvCommand = {
execute: function () {
console.log('打开电视');
}
};
let openSoundCommand = {
execute: function () {
console.log('打开音响');
}
};
let macroCommand1 = MacroCommand();
macroCommand1.add(openTvCommand);
macroCommand1.add(openSoundCommand);
// 关门、打开电脑和打登录 QQ 的命令
let closeDoorCommand = {
execute: function () {
console.log('关门');
}
};
let openPcCommand = {
execute: function () {
console.log('开电脑');
}
};
let openQQCommand = {
execute: function () {
console.log('登录 QQ');
}
};
let macroCommand2 = MacroCommand();
macroCommand2.add(closeDoorCommand);
macroCommand2.add(openPcCommand);
macroCommand2.add(openQQCommand);

// 现在把所有的命令组合成一个“超级命令”
let macroCommand = MacroCommand();
macroCommand.add(openAcCommand);
macroCommand.add(macroCommand1);
macroCommand.add(macroCommand2);

// 最后给遥控器绑定“超级命令”
let setCommand = (function (command) {
document.getElementById('button').onclick = function () {
command.execute();
}
})(macroCommand);

不管是宏命令对象,还是子命令对象,都有一个execute方法负责执行命令
​ 这在静态类型语言中实现显的尤为便利,比如在JAVA中,Composite类和Leaf类都必须继承自一个Compenent抽象类,这个Compenent抽象即表示组合对象,又代表叶对象,它也能保证两者均有execute方法,从而做出反馈。

​ 但是在JavaScript这种动态语言中实现组合模式,由于鸭子类型,我们并不需要有一个Compenent抽象类,但是这也导致了一定的严谨性。

透明性带来的安全问题

我们可以给叶子节点也增加add方法,并在调用的时候,抛出一个异常来提醒客户。比如上面的打开电视的命令对象:

1
2
3
4
5
6
7
8
let openTvCommand = {
execute: function () {
console.log('打开电视');
},
add: function () { // 同样的添加add方法
throw new Error("叶对象不能添加子节点");
}
};

应用场景 — 扫描文件夹

​ 文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以包含其他文件夹,最终可能组合成一棵树 当使用用杀毒软件扫描该文件夹时,往往不会关心里面有多少文件和子文件夹,组合模式使得我们只需要操作最外层的文件夹进行扫描。

注意

组合模式不是父子关系

​ 组合模式是一种HAS-A(聚合)的关系,而不是IS-A;Leaf并不是Composite的子类。

对叶对象操作的一致性

​ 组合模式除了要求组合对象和叶子对象拥有相同的接口之外,还有一个必要条件,就是对一组叶对象的操作的一致性。

​ 比如公司要给全体员工发元旦的过节费1000元,这个场景可以运用组合模式,但如果公司要给今天过生日的员工发送一封生日祝福的邮件,组合模式就做不到了,除非先把今天过生日的员工挑选出来。只有用一致的方式对待列表中的每一个叶子对象的时候,才适合使用组合模式。

双向映射关系

​ 发放过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本身是一个组合模式的好例子,但某些员工可能隶属于多个组织,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该员工可能收到多份过节费。

​ 这种复合情况下我们必须给父节点和子节点建立双向映射关系,一个简单的方法是给小组和员工对象都增加集合来保存对方的引用。
但是这种相互间的引用相当复杂,而且对象之间产生了过多的耦合性,修改或者删除一个对象都变得困难,此时我们可以引入中介者模式来管理这些对象。

用职责链模式提高组合模式性能

​ 在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许表现的不够理想,我们可以借助职责链模式避免遍历整棵树。职责链模式一般需要我们手动去设置链条,但在组合模式中,父对象和子对象之间实际形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反过来从子对象往父对象传递,直到遇到可以处理该请求的对象位置。

引用父对象

在组合模式中使用职责链时,有可能需要让请求从子节点往父节点上冒泡传递

在文件系统中,当我们删除某个文件夹的时候,实际上是从这个文件夹所在的上层文件夹中删除改文件的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Folder {
constructor(name) {
this.name = name;
this.parent = null; // 增加 this.parent 属性
this.files = [];
}

add(file) {
file.parent = this; // 设置父对象
this.files.push(file);
}

scan() {
console.log('开始扫描文件夹: ' + this.name);
for (let i = 0, file, files = this.files; file = files[i++];) {
file.scan();
}
};

remove() {
if (!this.parent) { // 根节点或者树外的游离节点
return;
}
for (let files = this.parent.files, l = files.length - 1; l >= 0; l--) { // 从父目录下搜索删除当前文件或文件夹
let file = files[l];
if (file === this) {
files.splice(l, 1);
}
}
};
}

class File {
constructor(name) {
this.name = name;
this.parent = null;
}

add() {
throw new Error('不能添加在文件下面');
}

scan() {
console.log('开始扫描文件: ' + this.name);
}

remove() {
if (!this.parent) { //根节点或者树外的游离节点
return;
}

for (let files = this.parent.files, l = files.length - 1; l >= 0; l--) {
let file = files[l];
if (file === this) {
files.splice(l, 1);
}
}
};
}

何时使用组合模式

  • 表示对象的部分-整体层次结构。组合模式可以方便的构造一棵树来表示对象的部分-整体结构。特别是我们不确定该树到底存在多少层次的时候
  • 用户希望以统一的方式对待树中的所有对象

缺点

​ 组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。

参考

《javascript设计模式与开发实践》

作者

Liang

发布于

2021-02-04

更新于

2021-12-25

许可协议


评论