您现在的位置是:首页 >其他 >设计模式之组合模式网站首页其他
设计模式之组合模式
参考资料
- 曾探《JavaScript设计模式与开发实践》;
- JavaScript设计模式之组合模式
- JavaScript 设计模式(十一):组合模式
定义
组合模式就是用小的子对象来构建成更大的对象,而这些小的子对象本身也许是更小的“孙对象”构成。、
又叫 “部分整体” 模式,将对象组合成树形结构,以表示 “部分-整体” 的层次结构。通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。
使用场景:
- 文件目录;
- DOM 文档树;
- Tree组件;
模式特点
-
表示 “部分-整体” 的层次结构,生成 “树叶型” 结构;
-
一致操作性,树叶对象对外接口保存一致(操作与数据结构一致);
-
自上而下的的请求流向,从树对象传递给叶对象;
-
调用顶层对象,会自行遍历其下的叶对象执行。
代码实现
我们假设定义了若干个对象,需要去调用对应的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
*/
CFolder
与 CFile
接口保持一致。执行 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()
方法中抛出异常,以作提醒。
误区规避
-
组合不是继承,树叶对象并不是父子对象
组合模式的树型结构是一种 HAS-A(聚合)的关系,而不是 IS-A 。树叶对象能够合作的关键,是它们对外保持统一接口,而不是叶对象继承树对象的属性方法,两者之间不是父子关系。
-
叶对象操作保持一致性
叶对象除了与树对象接口一致外,操作也必须保持一致性。一片叶子只能生在一颗树上。调用顶层对象时,每个叶对象只能接收一次请求,一个叶对象不能从属多个树对象。
-
叶对象实现冒泡传递
请求传递由树向叶传递,如果想逆转传递过程,需在叶对象中保留对树对象的引用,冒泡传递给树对象处理。
-
不只是简单的子集遍历
调用对象的接口方法时,如果该对象是树对象,则会将请求传递给叶对象,由叶对象执行方法,以此类推。不同于迭代器模式,迭代器模式遍历并不会做请求传导。
源码中的组合模式-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 (
<ul
class={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.jsx
getDerivedState(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 (
<li
class={{
[`${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
组件,操作子节点时参数和方法保持统一,从而提高了组件的可扩展性,这就是组合模式的一个典型应用。
优缺点
优点:
- 忽略组合对象和单个对象的差别,对外一致接口使用;
- 解耦调用者与复杂元素之间的联系,处理方式变得简单。
缺点:
- 树叶对象接口一致,无法区分,只有在运行时方可辨别;
- 包裹对象创建太多,额外增加内存负担。
总结
组合模式是处理复杂树形结构的有用设计模式。我们可以把相同的操作应用在组合对象和单个对象上。