「设计模式 JavaScript 描述」组合模式
在程序设计中,有一些和“事物是由相似的子事物构成” 类似的思想。「组合模式」就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的。
1. 回顾宏命令
我们在之前命令模式中讲解过宏命令的结构和作用。宏命令对象包含了一组具体的子命令对象,不管是宏命令对象,还是子命令对象,都有一个 execute
方法负责执行命令。现在回顾一下这段安装在万能遥控器上的宏命令代码:
const closeDoorCommand = { execute: function () { console.log('关门'); } }; const openPcCommand = { execute: function () { console.log('开电脑'); } }; const openQQCommand = { execute: function () { console.log('登录 QQ'); } }; const MacroCommand = function () { return { commandsList: [], add: function (command) { this.commandsList.push(command); }, execute: function () { for (let i = 0; i < this.commandsList.length; i++) { this.commandsList[i].execute(); } } } }; const macroCommand = MacroCommand(); macroCommand.add(closeDoorCommand); macroCommand.add(openPcCommand); macroCommand.add(openQQCommand); macroCommand.execute();
通过观察这段代码,我们很容易发现,宏命令中包含了一组子命令,它们组成了一个树形结构,这里是一棵结构非常简单的树,如下图所示。
其中,marcoCommand
被称为「组合对象」,closeDoorCommand
、openPcCommand
、openQQCommand
都是「叶对象」。在 macroCommand
的 execute
方法里,并不执行真正的操作,而是遍历它所包含的叶对象,把真正的 execute
请求委托给这些叶对象。
macroCommand
表现得像一个命令,但它实际上只是一组真正命令的“代理”。并非真正的代理,虽然结构上相似,但 macroCommand
只负责传递请求给叶对象,它的目的不在于控制对叶对象的访问。
2. 组合模式的用途
组合模式将对象组合成树形结构,以表示“部分—整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使 用具有一致性,下面分别说明。
- 表示树形结构。通过回顾上面的例子,我们很容易找到组合模式的一个优点:提供了一 种遍历树形结构的方案,通过调用组合对象的
execute
方法,程序会递归调用组合对象下面的叶对象的execute
方法,所以我们的万能遥控器只需要一次操作,便能依次完成关门、打开电脑、登录 QQ 这几件事情。组合模式可以非常方便地描述对象部分—整体层次结构。 - 利用对象多态性统一对待组合对象和单个对象。利用对象的多态性表现,可以使客户端忽略组合对象和单个对象的不同。在组合模式中,客户将统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。
这在实际开发中会给客户带来相当大的便利性,当我们往万能遥控器里面添加一个命令的时候,并不关心这个命令是宏命令还是普通子命令。这点对于我们不重要,我们只需要确定它是一 个命令,并且这个命令拥有可执行的 execute
方法,那么这个命令就可以被添加进万能遥控器。
当宏命令和普通子命令接收到执行 execute
方法的请求时,宏命令和普通子命令都会做它们各自认为正确的事情。这些差异是隐藏在客户背后的,在客户看来,这种透明性可以让我们非常自由地扩展这个万能遥控器。
3. 请求在树中传递的过程
在组合模式中,请求在树中传递的过程总是遵循一种逻辑。
以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象(宏命令),组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。
总而言之,如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。叶对象下面不会再有其他子节点,一个叶对象就是树的这条枝叶的尽头,组合对象下面可能还会有子节点,如下图所示。
请求从上到下沿着树进行传递,直到树的尽头。作为客户,只需要关心树最顶层的组合对象,客户只需要请求这个组合对象,请求便会沿着树往下传递,依次到达所有的叶对象。
在刚刚的例子中,由于宏命令和子命令组成的树太过简单,我们还不能清楚地看到组合模式带来的好处,如果只是简单地遍历一组子节点,迭代器便能解决所有的问题。接下来我们将创造一个更强大的宏命令,这个宏命令中又包含了另外一些宏命令和普通子命令,看起来是一棵相对较复杂的树。
4. 更强大的宏命令
目前的万能遥控器,包含了关门、开电脑、登录 QQ 这 3 个命令。现在我们需要一个“超级万能遥控器”,可以控制家里所有的电器,这个遥控器拥有以下功能:
- 打开空调
- 打开电视和音响
- 关门、开电脑、登录 QQ
首先在节点中放置一个按钮 button
来表示这个超级万能遥控器,超级万能遥控器上安装了一个宏命令,当执行这个宏命令时,会依次遍历执行它所包含的子命令,代码如下:
<body> <button id="button">按我</button> </body> <script> const MacroCommand = function () { return { commandsList: [], add: function (command) { this.commandsList.push(command); }, execute: function () { for (var i = 0, command; command = this.commandsList[i++];) { command.execute(); } } } }; const openAcCommand = { execute: function () { console.log('打开空调'); } }; /**家里的电视和音响是连接在一起的,所以可以用一个宏命令来组合打开电视和打开音响的命令**/ const openTvCommand = { execute: function () { console.log('打开电视'); } }; const openSoundCommand = { execute: function () { console.log('打开音响'); } }; const macroCommand1 = MacroCommand(); macroCommand1.add(openTvCommand); macroCommand1.add(openSoundCommand); /*********关门、打开电脑和打登录 QQ 的命令****************/ const closeDoorCommand = { execute: function () { console.log('关门'); } }; const openPcCommand = { execute: function () { console.log('开电脑'); } }; const openQQCommand = { execute: function () { console.log('登录 QQ'); } }; const macroCommand2 = MacroCommand(); macroCommand2.add(closeDoorCommand); macroCommand2.add(openPcCommand); macroCommand2.add(openQQCommand); /*********现在把所有的命令组合成一个“超级命令”**********/ const macroCommand = MacroCommand(); macroCommand.add(openAcCommand); macroCommand.add(macroCommand1); macroCommand.add(macroCommand2); /*********最后给遥控器绑定“超级命令”**********/ const setCommand = (function (command) { document.getElementById('button').onclick = function () { command.execute(); } })(macroCommand); </script>
当按下遥控器的按钮时,所有命令都将被依次执行,执行结果如下图所示。
从这个例子中可以看到,基本对象可以被组合成更复杂的组合对象,组合对象又可以被组合,这样不断递归下去,这棵树的结构可以支持任意多的复杂度。在树最终被构造完成之后,让整颗树最终运转起来的步骤非常简单,只需要调用最上层对象的 execute
方法。每当对最上层的对象进行一次请求时,实际上是在对整个树进行深度优先的搜索,而创建组合对象的程序员并不关心这些内在的细节,往这棵树里面添加一些新的节点对象是非常容易的事情。
5. 透明性带来的安全问题
组合模式的透明性使得发起请求的客户不用去顾忌树中组合对象和叶对象的区别,但它们在本质上有是区别的。
组合对象可以拥有子节点,叶对象下面就没有子节点, 所以我们也许会发生一些误操作,比如试图往叶对象中添加子节点。解决方案通常是给叶对象也增加 add
方法,并且在调用这个方法时,抛出一个异常来及时提醒客户,代码如下:
const MacroCommand = function () { return { commandsList: [], add: function (command) { this.commandsList.push(command); }, execute: function () { for (let i = 0; i < this.commandsList.length; i++) { this.commandsList[i].execute(); } } } }; const openTvCommand = { execute: function () { console.log('打开电视'); }, add: function () { throw new Error('叶对象不能添加子节点'); } }; const macroCommand = MacroCommand(); macroCommand.add(openTvCommand); openTvCommand.add(macroCommand) // Uncaught Error: 叶对象不能添加子节点
6. 何时使用组合模式
组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下这两种情况。
- 表示对象的部分—整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分—整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放—封闭原则。
- 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆 if、else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。
7. 小结
本文我们了解了组合模式在 JavaScript 开发中的应用。组合模式可以让我们使用树形方式创建对象的结构。我们可以把相同的操作应用在组合对象和单个对象上。在大多数情况下,我们都可以忽略掉组合对象和单个对象之间的差别,从而用一致的方式来处理它们。
然而,组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。