> 文章列表 > 设计模式之组合模式

设计模式之组合模式

设计模式之组合模式

参考资料

  • 曾探《JavaScript设计模式与开发实践》;
  • JavaScript设计模式之组合模式
  • JavaScript 设计模式(十一):组合模式

定义

组合模式就是用小的子对象来构建成更大的对象,而这些小的子对象本身也许是更小的“孙对象”构成。

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

使用场景:

  • 文件目录;
  • DOM 文档树;
  • Tree组件;

模式特点

  1. 表示 “部分-整体” 的层次结构,生成 “树叶型” 结构;

  2. 一致操作性,树叶对象对外接口保存一致(操作与数据结构一致);

  3. 自上而下的的请求流向,从树对象传递给叶对象;

  4. 调用顶层对象,会自行遍历其下的叶对象执行。

在这里插入图片描述

代码实现

我们假设定义了若干个对象,需要去调用对应的execute函数来执行对应的命令,举一个例子:

var closeDoorCommand = {execute: function(){console.log( '关门' );}
}var openPCCommand = {execute: function(){console.log( '开电脑' );}
}var openQQCommand = {execute: function(){console.log( '登陆QQ' );}
}

上面我们定义了三个命令,分别为关门、开电脑、登陆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();}}}
};

接下来,我们执行如下操作,将三个子命令添加到MacroCommand中:

var macroCommand = MacroCommand()macroCommand.add(closeDoorCommand)
macroCommand.add(openPCCommand)
macroCommand.add(openQQCommand)macroCommand.execute()

观察代码,我们会发现,宏命令子命令组成了一个树形结构。macroCommand对象称为组合对象closeDoorCommand、openPCCommand、openQQCommand都是叶对象。

execute执行时,函数将会遍历commandsList中的所有子命令,并调用每个子命令的execute方法。而这种模式在开发中会带来相当大的便利性,因为,当我们新增一个命令时,我们并不需要关心他是宏命令还是子命令,只要关心他是否拥有可执行的execute函数就可以了。

我们发现,如果节点叶对象,那么将自行处理execute,如果子节点还是组合对象,那么将继续向下传递,直到树的尽头。

所以,可以总结出:组合模式的核心是通过递归的方式访问整个树形结构

文件目录

树对象和叶对象接口统一,树对象增加一个缓存数组,存储叶对象。执行树对象方法时,将请求传递给其下叶对象执行。

// 树对象 - 文件目录
class CFolder {constructor(name) {this.name = name;this.files = [];}add(file) {this.files.push(file);}scan() {for (let file of this.files) {file.scan();}}
}// 叶对象 - 文件
class CFile {constructor(name) {this.name = name;}add(file) {throw new Error('文件下面不能再添加文件');}scan() {console.log(`开始扫描文件:${this.name}`);}
}let mediaFolder = new CFolder('娱乐');
let movieFolder = new CFolder('电影');
let musicFolder = new CFolder('音乐');let file1 = new CFile('钢铁侠.mp4');
let file2 = new CFile('再谈记忆.mp3');
movieFolder.add(file1);
musicFolder.add(file2);
mediaFolder.add(movieFolder);
mediaFolder.add(musicFolder);
mediaFolder.scan();/* 输出:
开始扫描文件:钢铁侠.mp4
开始扫描文件:再谈记忆.mp3
*/

CFolderCFile 接口保持一致。执行 scan() 时,若发现是树对象,则继续遍历其下的叶对象,执行 scan()

JavaScript 不同于其它静态编程语言,实现组合模式的难点是保持树对象与叶对象之间接口保持统一,可借助 TypeScript 定制接口规范,实现类型约束。

// 定义接口规范
interface Compose {name: string,add(file: CFile): void,scan(): void
}// 树对象 - 文件目录
class CFolder implements Compose {fileList = [];name: string;constructor(name: string) {this.name = name;}add(file: CFile) {this.fileList.push(file);}scan() {for (let file of this.fileList) {file.scan();}}
}// 叶对象 - 文件
class CFile implements Compose {name: string;constructor(name: string) {this.name = name;}add(file: CFile) {throw new Error('文件下面不能再添加文件');}scan() {console.log(`开始扫描:${this.name}`)}
}let mediaFolder = new CFolder('娱乐');
let movieFolder = new CFolder('电影');
let musicFolder = new CFolder('音乐');let file1 = new CFile('钢铁侠.mp4');
let file2 = new CFile('再谈记忆.mp3');
movieFolder.add(file1);
musicFolder.add(file2);
mediaFolder.add(movieFolder);
mediaFolder.add(musicFolder);
mediaFolder.scan();/* 输出:
开始扫描文件:钢铁侠.mp4
开始扫描文件:再谈记忆.mp3
*/

透明性的安全问题:

组合模式的透明性,指的是树叶对象接口保持统一,外部调用时无需区分。但是这会带来一些问题,如上述文件目录的例子,文件(叶对象)下不可再添加文件,因此需在文件类的 add() 方法中抛出异常,以作提醒。

误区规避

  1. 组合不是继承,树叶对象并不是父子对象

    组合模式的树型结构是一种 HAS-A(聚合)的关系,而不是 IS-A 。树叶对象能够合作的关键,是它们对外保持统一接口,而不是叶对象继承树对象的属性方法,两者之间不是父子关系。

  2. 叶对象操作保持一致性

    叶对象除了与树对象接口一致外,操作也必须保持一致性。一片叶子只能生在一颗树上。调用顶层对象时,每个叶对象只能接收一次请求,一个叶对象不能从属多个树对象。

  3. 叶对象实现冒泡传递

    请求传递由树向叶传递,如果想逆转传递过程,需在叶对象中保留对树对象的引用,冒泡传递给树对象处理。

  4. 不只是简单的子集遍历

    调用对象的接口方法时,如果该对象是树对象,则会将请求传递给叶对象,由叶对象执行方法,以此类推。不同于迭代器模式,迭代器模式遍历并不会做请求传导。

源码中的组合模式-antd vue的Tree组件

根组件

为了更直观的看到组件的递归方式,我们传入如下数据结构:

const treeData = [{title: '0-0',key: '0-0',children: [{title: '0-0-0',key: '0-0-0',children: [{ title: '0-0-0-0', key: '0-0-0-0' },{ title: '0-0-0-1', key: '0-0-0-1' },{ title: '0-0-0-2', key: '0-0-0-2' },],},{title: '0-0-1',key: '0-0-1',children: [{ title: '0-0-1-0', key: '0-0-1-0' },{ title: '0-0-1-1', key: '0-0-1-1' },{ title: '0-0-1-2', key: '0-0-1-2' },],},{title: '0-0-2',key: '0-0-2',},],},{title: '0-1',key: '0-1',children: [{ title: '0-1-0-0', key: '0-1-0-0' },{ title: '0-1-0-1', key: '0-1-0-1' },{ title: '0-1-0-2', key: '0-1-0-2' },],},{title: '0-2',key: '0-2',},
];
复制代码

并设置选中key['0-0-0'],此时,可以看到Tree组件的展示效果:

在这里插入图片描述

对应在antd源码中,我们的根组件代码在vc-tree/Tree.jsx,代码如下:

render() {const { _treeNode: treeNode } = this.$data;const { prefixCls, focusable, showLine, tabIndex = 0 } = this.$props;return (<ulclass={classNames(prefixCls, {[`${prefixCls}-show-line`]: showLine,})}role="tree"unselectable="on"tabIndex={focusable ? tabIndex : null}>{mapChildren(treeNode, (node, index) => this.renderTreeNode(node, index))}</ul>);},
复制代码

在源码中可以看到根组件有一个标识role=tree, 通过mapChildren遍历渲染其子节点。

遍历子节点

先来看下mapChildren函数:

export function mapChildren(children = [], func) {const list = children.map(func);if (list.length === 1) {return list[0];}return list;
}
复制代码

非常简单,遍历treeNode,然后执行renderTreeNode渲染子节点。那么treeNode是什么呢?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p5WTWaJr-1681604092619)(assets/617f55b7132d4142bffc71746abf5370tplv-k3u1fbpfcp-zoom-in-crop-mark1512000.webp)]

查看组件的data数据,可以发现是在getDerivedState中对treeNode进行赋值的,为了简化逻辑,直接贴出treeNode赋值的函数:

// vc-tree/src/Tree.jsxgetDerivedState(props, prevState) {...if (needSync('treeData')) {treeNode = convertDataToTree(this.$createElement, props.treeData);}...
}// vc-tree/src/util.js
export function convertDataToTree(h, treeData, processor) {if (!treeData) return [];const { processProps = internalProcessProps } = processor || {};const list = Array.isArray(treeData) ? treeData : [treeData];return list.map(({ children, ...props }) => {const childrenNodes = convertDataToTree(h, children, processor);return <TreeNode {...processProps(props)}>{childrenNodes}</TreeNode>;});
}
复制代码

通过convertDataToTree函数,我们可以看到,组件中会将我们传入的treeData进行递归处理,返回树形结构的VNode,如图:

在这里插入图片描述

TreeNode渲染

查看TreeNode组件源码:

  render(h) {const {dragOver,dragOverGapTop,dragOverGapBottom,isLeaf,expanded,selected,checked,halfChecked,loading,} = this.$props;const {vcTree: { prefixCls, filterTreeNode, draggable },} = this;const disabled = this.isDisabled();return (<liclass={{[`${prefixCls}-treenode-disabled`]: disabled,[`${prefixCls}-treenode-switcher-${expanded ? 'open' : 'close'}`]: !isLeaf,[`${prefixCls}-treenode-checkbox-checked`]: checked,[`${prefixCls}-treenode-checkbox-indeterminate`]: halfChecked,[`${prefixCls}-treenode-selected`]: selected,[`${prefixCls}-treenode-loading`]: loading,'drag-over': !disabled && dragOver,'drag-over-gap-top': !disabled && dragOverGapTop,'drag-over-gap-bottom': !disabled && dragOverGapBottom,'filter-node': filterTreeNode && filterTreeNode(this),}}role="treeitem"onDragenter={draggable ? this.onDragEnter : noop}onDragover={draggable ? this.onDragOver : noop}onDragleave={draggable ? this.onDragLeave : noop}onDrop={draggable ? this.onDrop : noop}onDragend={draggable ? this.onDragEnd : noop}>{this.renderSwitcher()}{this.renderCheckbox()}{this.renderSelector(h)}{this.renderChildren()}</li>);},复制代码

可以看到几个关键点:

  • TreeNode组件由一个li标签包裹,并且role=treeitem
  • <li role="treeitem">标签中有renderSwitcher、renderCheckbox、renderSelector、renderChildren。其中renderSelector显示树形节点的图标和标题,renderChildren显示组件下的子组件,并且由<ul role="group">包裹
  • <ul role="group">遍历渲染多个TreeNode组件

对应下图可更清晰的看到其结构:

在这里插入图片描述

其中,蓝色表示TreeNode节点,橙色表示节点中的图标、title、checkbox等基本信息紫色表示子节点的根元素也就是ul,内部包含着对应的子节点。

由于遍历渲染TreeNode组件,操作子节点时参数和方法保持统一,从而提高了组件的可扩展性,这就是组合模式的一个典型应用

优缺点

优点:

  • 忽略组合对象和单个对象的差别,对外一致接口使用;
  • 解耦调用者与复杂元素之间的联系,处理方式变得简单。

缺点:

  • 树叶对象接口一致,无法区分,只有在运行时方可辨别;
  • 包裹对象创建太多,额外增加内存负担。

总结

,并且由<ul role="group">包裹

  • <ul role="group">遍历渲染多个TreeNode组件

对应下图可更清晰的看到其结构:

[外链图片转存中…(img-WrF4CM6K-1681604092620)]

其中,蓝色表示TreeNode节点,橙色表示节点中的图标、title、checkbox等基本信息紫色表示子节点的根元素也就是ul,内部包含着对应的子节点。

由于遍历渲染TreeNode组件,操作子节点时参数和方法保持统一,从而提高了组件的可扩展性,这就是组合模式的一个典型应用

优缺点

优点:

  • 忽略组合对象和单个对象的差别,对外一致接口使用;
  • 解耦调用者与复杂元素之间的联系,处理方式变得简单。

缺点:

  • 树叶对象接口一致,无法区分,只有在运行时方可辨别;
  • 包裹对象创建太多,额外增加内存负担。

总结

组合模式是处理复杂树形结构的有用设计模式。我们可以把相同的操作应用在组合对象和单个对象上。