JavaScript 组合模式
前言
组合模式真的很难在一些业务项目上有所体现,但是当我们完完全全从零触发去构建一些东西的时候,组合模式还是很有用处的。
组合模式是一种结构型设计模式,它可以让你将对象组合成树形结构以表示部分整体的层次结构。组合使得用户对单个对象和组合对象的使用具有一致性。
在组合模式中,通常有两种类型的对象:
- 叶子对象(Leaf):不包含子对象的对象。
- 容器对象(Composite):包含叶子对象或其他容器对象。
注意重点是这个一致性,我们通过上层类型约束,要求叶子对象和容器对象都需要实现我们指定的接口,然后我们就可以放心的进行组合了。
最终我们可以发现,组合这些对象,得到的是一种树形结构,因此组合模式也被称为树枝模式。
可以看到,我们会有一个最顶层的对象,这个一般被称为root对象,也就是根对象。
我们举个例子加深理解:
比如我要求所有的对象都需要实现一个日志打印的方法log
,如果是叶子对象就只需要实现log具体的输出就行了,而如果是容器对象,它就需要遍历它的下面的子对象,调用子对象的log方法,但是它不需要关心子对象是叶子对象还是容器对象,它只需要调用log方法,剩下的就是子对象的事情了。
容器对象自己需要实现一个接收子对象的方法,比如setObj(obj)
,将子对象保存在自身。
然后随着不断的组合,形成了一个树形结构。
这种结构的好处就是,当我要通知大家log打印的时候,只需要告诉根节点就可以了,我甚至都不用关心它旗下有多少子对象,它这种结构模式保证了这种效果。
抽象类在组合模式中的作用
在上面的介绍中有提到上层类型约束,在java这种强类型语言中,实现组合模式的关键就是声明一个上层抽象类,比如声明Compenent抽象类,容器对象和叶子对象需要继承这个抽象类。
于是这个Compenent即代表着容器对象也代表叶子对象,它保证了子级对象都拥有统一的方法,从而便于统一调用。
对于客户而言,它操作的始终是Compenent对象,而不用去区分是容器还是叶子对象。
但是JavaScript是一个动态类型的语言,对象的多态性是与生俱来的,它没有所谓的抽象类或者接口,如果要实现组合模式,通常要保证组合对象和叶子对象都拥有同样的方法,这通常就需要使用鸭子类型的思想来对他们进行接口检查了,比如增加一个判断,如果存在这个方法,就是正确的对象。
透明性带来的安全问题
组合模式的透明性,使得用户不关心容器对象和叶子对象的区别,但是它们本质上是有区别的。
容器对象是可以拥有子节点的,但是叶子对象就没有子节点,这就导致我们可能会给叶子节点添加子节点,这种情况通常是叶子节点也实现一个添加子节点的方法,但是这个方法是抛出异常的,用于提醒用户。
// 叶子对象
class Leaf {
constructor(name) {
this.name = name;
}
add(component) {
throw new Error('Cannot add to a leaf');
}
remove(component) {
throw new Error('Cannot remove from a leaf');
}
display() {
console.log('Leaf: ' + this.name);
}
}
// 容器对象
class Composite {
constructor(name) {
this.name = name;
this.children = [];
}
add(component) {
this.children.push(component);
}
remove(component) {
const index = this.children.indexOf(component);
if (index > -1) {
this.children.splice(index, 1);
}
}
display() {
console.log('Composite: ' + this.name);
this.children.forEach(child => child.display());
}
}
// 使用示例
const root = new Composite('Root');
const leaf1 = new Leaf('Leaf1');
const composite1 = new Composite('Composite1');
const leaf2 = new Leaf('Leaf2');
root.add(leaf1);
root.add(composite1);
composite1.add(leaf2);
// 输出结构
root.display();
// 尝试错误的操作: 向叶子节点添加子节点
try {
leaf1.add(new Leaf('Leaf3'));
} catch (error) {
console.error(error.message);
}
组合模式例子 - 目录
// 文件夹和文件类
class FolderOrFile {
constructor(name) {
this.name = name;
this.children = [];
}
add(child) {
this.children.push(child);
}
display(indentationLevel = 0) {
let indentation = ' '.repeat(indentationLevel * 2);
console.log(`${indentation}${this.name}`);
this.children.forEach(child => {
child.display(indentationLevel + 1);
});
}
}
// 文件夹类
class Folder extends FolderOrFile {
display(indentationLevel) {
super.display(indentationLevel);
}
}
// 文件类
class File extends FolderOrFile {
display(indentationLevel) {
super.display(indentationLevel);
}
}
// 创建实例
const rootFolder = new Folder('Root');
const subFolder1 = new Folder('Subfolder 1');
const subFolder2 = new Folder('Subfolder 2');
const file1 = new File('File 1');
const file2 = new File('File 2');
subFolder1.add(file1);
subFolder2.add(file2);
rootFolder.add(subFolder1);
rootFolder.add(subFolder2);
// 显示文件系统结构
rootFolder.display();
引用父对象
容器对象保存着它下面的子对象,这是组合模式的特点,但是有时候可能需要在子对象中保持对父节点的引用,比如dom中,如果我们要删除一个div元素,就需要通过div的父级元素进行删除,在组合模式中如果想要实现这种效果,也很简单,只需要在add添加子对象的时候,给他设置parent即可。
// 文件夹和文件类
class FolderOrFile {
constructor(name, parent = null) {
this.name = name;
this.parent = parent;
this.children = [];
}
add(child) {
this.children.push(child);
child.parent = this; // 设置子节点的父级为当前节点
}
display(indentationLevel = 0) {
let indentation = ' '.repeat(indentationLevel * 2);
console.log(`${indentation}${this.name}`);
this.children.forEach(child => {
child.display(indentationLevel + 1);
});
}
}
// 文件夹类
class Folder extends FolderOrFile {
display(indentationLevel) {
super.display(indentationLevel);
}
}
// 文件类
class File extends FolderOrFile {
display(indentationLevel) {
super.display(indentationLevel);
}
}
// 创建实例
const rootFolder = new Folder('Root');
const subFolder1 = new Folder('Subfolder 1');
const subFolder2 = new Folder('Subfolder 2');
const file1 = new File('File 1');
const file2 = new File('File 2');
subFolder1.add(file1);
subFolder2.add(file2);
rootFolder.add(subFolder1);
rootFolder.add(subFolder2);
// 显示文件系统结构
rootFolder.display();
// 测试引用父对象的功能
console.log(file1.parent); // 输出 subFolder1
console.log(subFolder2.parent); // 输出 rootFolder
一些注意的地方
1. 组合模式不是父子关系。
组合模式的树形结构很容易让人误以为他们是父子关系,这是不严谨的。
实际上,在组合模式中,并不存在严格的父子关系,而是通过将对象组织成树形结构来表示部分-整体关系。在组合模式中,容器对象和叶子对象都实现相同的接口,使得客户端可以统一处理它们。容器对象可以包含叶子对象或其他容器对象,但并没有真正的父子关系,只是组合关系。所以,组合模式更多是关于对象的组合和统一处理,
2. 对叶对象操作的一致性
组合模式除了要求组合对象和叶对象拥有相同的接口之外,还有一个必要条件,就是对一组 叶对象的操作必须具有一致性。
比如公司要给全体员工发放元旦的过节费 1000 块,这个场景可以运用组合模式,但如果公 司给今天过生日的员工发送一封生日祝福的邮件,组合模式在这里就没有用武之地了,除非先把 今天过生日的员工挑选出来。只有用一致的方式对待列表中的每个叶对象的时候,才适合使用组合模式。
3. 双向映射关系
发放过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这 本身是一个组合模式的好例子,但要考虑的一种情况是,也许某些员工属于多个组织架构。比如 某位架构师既隶属于开发组,又隶属于架构组,对象之间的关系并不是严格意义上的层次结构, 在这种情况下,是不适合使用组合模式的,该架构师很可能会收到两份过节费。
这种复合情况下我们必须给父节点和子节点建立双向映射关系,一个简单的方法是给小组和员 工对象都增加集合来保存对方的引用。但是这种相互间的引用相当复杂,而且对象之间产生了过多 的耦合性,修改或者删除一个对象都变得困难,此时我们可以引入中介者模式来管理这些对象。
4. 用职责链模式提高组合模式性能
在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许 表现得不够理想。有时候我们确实可以借助一些技巧,在实际操作中避免遍历整棵树,有一种现成的方案是借助职责链模式。职责链模式一般需要我们手动去设置链条,但在组合模式中,父对 象和子对象之间实际上形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反 过来从子对象往父对象传递,直到遇到可以处理该请求的对象为止,这也是职责链模式的经典运用场景之一。
什么时候使用组合模式
组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下这两种情况:
- 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最 终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模 式中增加和删除树的节点非常方便,并且符合开放-封闭原则。
- 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别, 客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就 不用写一堆 if、else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情, 这是组合模式最重要的能力
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据