组合模式
组合模式是一种结构型设计模式, 你可以使用它将对象组合成树状结构, 并且能像使用独立对象一样使用它们。
组合模式结构
-
组件 (Component) 接口描述了树中简单项目和复杂项目所共有的操作。
-
叶节点 (Leaf) 是树的基本结构, 它不包含子项目。 一般情况下, 叶节点最终会完成大部分的实际工作, 因为它们无法将工作指派给其他部分。
-
容器 (Container)——又名 “组合 (Composite)”——是包含叶节点或其他容器等子项目的单位。 容器不知道其子项目所属的具体类, 它只通过通用的组件接口与其子项目交互。 容器接收到请求后会将工作分配给自己的子项目, 处理中间结果, 然后将最终结果返回给客户端。
-
客户端 (Client) 通过组件接口与所有项目交互。 因此, 客户端能以相同方式与树状结构中的简单或复杂项目交互。
伪代码
在本例中, 我们将借助组合模式帮助你在图形编辑器中实现一系列的几何图形。
组合图形CompoundGraphic是一个容器, 它可以由多个包括容器在内的子图形构成。 组合图形与简单图形拥有相同的方法。 但是, 组合图形自身并不完成具体工作, 而是将请求递归地传递给自己的子项目, 然后 “汇总” 结果。
通过所有图形类所共有的接口, 客户端代码可以与所有图形互动。 因此, 客户端不知道与其交互的是简单图形还是组合图形。 客户端可以与非常复杂的对象结构进行交互, 而无需与组成该结构的实体类紧密耦合。
// 组件接口会声明组合中简单和复杂对象的通用操作。
interface Graphic is
method move(x, y)
method draw()
// 叶节点类代表组合的终端对象。叶节点对象中不能包含任何子对象。叶节点对象
// 通常会完成实际的工作,组合对象则仅会将工作委派给自己的子部件。
class Dot implements Graphic is
field x, y
constructor Dot(x, y) { …… }
method move(x, y) is
this.x += x, this.y += y
method draw() is
// 在坐标位置(X,Y)处绘制一个点。
// 所有组件类都可以扩展其他组件。
class Circle extends Dot is
field radius
constructor Circle(x, y, radius) { …… }
method draw() is
// 在坐标位置(X,Y)处绘制一个半径为 R 的圆。
// 组合类表示可能包含子项目的复杂组件。组合对象通常会将实际工作委派给子项
// 目,然后“汇总”结果。
class CompoundGraphic implements Graphic is
field children: array of Graphic
// 组合对象可在其项目列表中添加或移除其他组件(简单的或复杂的皆可)。
method add(child: Graphic) is
// 在子项目数组中添加一个子项目。
method remove(child: Graphic) is
// 从子项目数组中移除一个子项目。
method move(x, y) is
foreach (child in children) do
child.move(x, y)
// 组合会以特定的方式执行其主要逻辑。它会递归遍历所有子项目,并收集和
// 汇总其结果。由于组合的子项目也会将调用传递给自己的子项目,以此类推,
// 最后组合将会完成整个对象树的遍历工作。
method draw() is
// 1. 对于每个子部件:
// - 绘制该部件。
// - 更新边框坐标。
// 2. 根据边框坐标绘制一个虚线长方形。
// 客户端代码会通过基础接口与所有组件进行交互。这样一来,客户端代码便可同
// 时支持简单叶节点组件和复杂组件。
class ImageEditor is
field all: CompoundGraphic
method load() is
all = new CompoundGraphic()
all.add(new Dot(1, 2))
all.add(new Circle(5, 3, 10))
// ……
// 将所需组件组合为复杂的组合组件。
method groupSelected(components: array of Graphic) is
group = new CompoundGraphic()
foreach (component in components) do
group.add(component)
all.remove(component)
all.add(group)
// 所有组件都将被绘制。
all.draw()
组件模式优点
- 你可以利用多态和递归机制更方便地使用复杂树结构。
- 开闭原则。 无需更改现有代码, 你就可以在应用中添加新元素, 使其成为对象树的一部分。
组件模式缺点 对于功能差异较大的类, 提供公共接口或许会有困难。 在特定情况下, 你需要过度一般化组件接口, 使其变得令人难以理解。
react组合模式
组合模式适合一些容器组件场景,通过外层组件包裹内层组件,这种方式在 Vue 中称为 slot 插槽,外层组件可以轻松的获取内层组件的 props 状态,还可以控制内层组件的渲染,组合模式能够直观反映出 父 -> 子组件的包含关系,首先我来举个最简单的组合模式例子。
<Tabs onChange={ (type)=> console.log(type) } >
<TabItem name="react" label="react" >React</TabItem>
<TabItem name="vue" label="vue" >Vue</TabItem>
<TabItem name="angular" label="angular" >Angular</TabItem>
</Tabs>
如上 Tabs 和 TabItem 组合,构成切换 tab 功能,那么 Tabs 和 TabItem 的分工如下:
- Tabs 负责展示和控制对应的 TabItem 。绑定切换 tab 回调方法 onChange。当 tab 切换的时候,执行回调。
- TabItem 负责展示对应的 tab 项,向 Tabs 传递 props 相关信息。 我们直观上看到 Tabs 和 TabItem 并没有做某种关联,但是却无形的联系起来。
原理揭秘
实际组合模式的实现并没有想象中那么复杂,主要分为外层和内层两部分,当然可能也存在多层组合嵌套的情况,但是万变不离其宗,原理都是一样的。首先我们看一个简单的组合结构:
<Groups>
<Item name="《React进阶实践指南》" />
</Groups>
那么 Groups 能对 Item 做一些什么操作呢 ? Item 在 Groups的形态 首先如果如上组合模式的写法,会被 jsx 编译成 React element 形态,Item 可以通过 Groups 的 props.children 访问到。
function Groups (props){
console.log( props.children ) // Groups element
console.log( props.children.props ) // { name : 'React进阶实践指南》' }
return props.children
}
但是这是针对单一节点的情况,事实情况下,外层容器可能有多个子组件的情况。
<Groups>
<Item name="《React进阶实践指南》" />
<Item name="《Nodejs深度学习手册》" />
</Groups>
这种情况下,props.children 就是一个数组结构,如果想要访问每一个的 props ,那么需要通过 React.Children.forEach 遍历 props.children。
function Groups (props){
console.log( props.children ) // Groups element
React.Children.forEach(props.children,item=>{
console.log( item.props ) //依次打印 props
})
return props.children
}
隐式混入 props 这个是组合模式的精髓所在,就是可以通过 React.cloneElement 向 children 中混入其他的 props,那么子组件就可以使用容器父组件提供的特有的 props 。我们来看一下具体实现:
function Item (props){
console.log(props) // {name: "《React进阶实践指南》", author: "alien"}
return <div> 名称: {props.name} </div>
}
function Groups (props){
const newChilren = React.cloneElement(props.children,{ author:'alien' })
return newChilren
}
用 React.cloneElement 创建一个新的 element,然后混入其他的 props -> author 属性,React.cloneElement 的第二个参数,会和之前的 props 进行合并 ( merge )。 这里还是 Groups 只有单一节点的情况,有些同学会问直接在原来的 children 基础上加入新属性不就可以了吗? 像如下这样:
props.children.props.author = 'alien'
这样会报错,对于 props ,React 会进行保护,我们无法对 props 进行拓展。所以要想隐式混入 props ,只能通过 cloneElement 来实现。 控制渲染 组合模式可以通过 children 方式获取内层组件,也可以根据内层组件的状态来控制其渲染。比如如下的情况:
export default ()=>{
return <Groups>
<Item isShow name="《React进阶实践指南》" />
<Item isShow={false} name="《Nodejs深度学习手册》" />
<div>hello,world</div>
{ null }
</Groups>
}
如上这种情况组合模式,只渲染 isShow = true 的 Item 组件。那么外层组件是如何处理的呢?
实际处理这个很简单,也是通过遍历 children ,然后通过对比 props ,选择需要渲染的 children 。 接下来一起看一下如何控制:
function Item (props){
return <div> 名称: {props.name} </div>
}
/* Groups 组件 */
function Groups (props){
const newChildren = []
React.Children.forEach(props.children,(item)=>{
const { type ,props } = item || {}
if(isValidElement(item) && type === Item && props.isShow ){
newChildren.push(item)
}
})
return newChildren
}
通过 newChildren 存放满足要求的 React Element ,通过 Children.forEach 遍历 children 。 通过 isValidElement 排除非 element 节点;type指向 Item函数内存,排除非 Item 元素;获取 isShow 属性,只展示 isShow = true 的 Item,最终效果满足要求。 内外层通信 组合模式可以轻松的实现内外层通信的场景,原理就是通过外层组件,向内层组件传递回调函数 callback ,内层通过调用 callback 来实现两层组合模式的通信关系。
function Item (props){
return <div>
名称:{props.name}
<button onClick={()=> props.callback('let us learn React!')} >点击</button>
</div>
}
function Groups (props){
const handleCallback = (val) => console.log(' children 内容:',val )
return <div>
{React.cloneElement( props.children , { callback:handleCallback } )}
</div>
}
Groups 向 Item 组件中隐式传入回调函数 callback,将作为新的 props 传递。 Item 可以通过调用 callback 向 Groups传递信息。实现了内外层的通信。 复杂的组合场景 组合模式还有一种场景,在外层容器中,进行再次组合,这样组件就会一层一层的包裹,一次又一次的强化。这里举一个例子:
function Item (props){
return <div>
名称:{props.name} <br/>
作者:{props.author} <br/>
对大家说:{props.mes} <br/>
</div>
}
/* 第二层组合 -> 混入 mes 属性 */
function Wrap(props){
return React.cloneElement( props.children,{ mes:'let us learn React!' } )
}
/* 第一层组合,里面进行第二次组合,混入 author 属性 */
function Groups (props){
return <Wrap>
{React.cloneElement( props.children, { author:'alien' } )}
</Wrap>
}
export default ()=>{
return <Groups>
<Item name="《React进阶实践指南》" />
</Groups>
}
在 Groups 组件里通过 Wrap 再进行组合。经过两次组合,把 author 和 mes 混入到 props 中。
这种组合模式能够一层层强化原始组件,外层组件不用过多关心内层到底做了些什么? 只需要处理 children 就可以,同样内层 children 在接受业务层的 props 外,还能使用来自外层容器组件的状态,方法等。
实践demo
接下来,我们来简单实现刚开始的 tab,tabItem 切换功能。 tab实现
const Tab = ({ children ,onChange }) => {
const activeIndex = useRef(null)
const [,forceUpdate] = useState({})
/* 提供给 tab 使用 */
const tabList = []
/* 待渲染组件 */
let renderChildren = null
React.Children.forEach(children,(item)=>{
/* 验证是否是 <TabItem> 组件 */
if(React.isValidElement(item) && item.type.displayName === 'tabItem' ){
const { props } = item
const { name, label } = props
const tabItem = {
name,
label,
active: name === activeIndex.current,
component: item
}
if(name === activeIndex.current) renderChildren = item
tabList.push(tabItem)
}
})
/* 第一次加载,或者 prop chuldren 改变的情况 */
if(!renderChildren && tabList.length > 0){
const fisrtChildren = tabList[0]
renderChildren = fisrtChildren.component
activeIndex.current = fisrtChildren.component.props.name
fisrtChildren.active = true
}
/* 切换tab */
const changeTab=(name)=>{
activeIndex.current = name
forceUpdate({})
onChange && onChange(name)
}
return <div>
<div className="header" >
{
tabList.map((tab,index) => (
<div className="header_item" key={index} onClick={() => changeTab(tab.name)} >
<div className={'text'} >{tab.label}</div>
{tab.active && <div className="active_bored" ></div>}
</div>
))
}
</div>
<div>{renderChildren}</div>
</div>
}
我写的这个 Tab,负责了整个 Tab 切换的主要功能,包括 TabItem 的过滤,状态收集,控制对应的子组件展示。
- 首先通过 Children.forEach 找到符合条件的 TabItem。收集 TabItem的 props,形成菜单结构。
- 找到对应的 children ,渲染正确的 children 。
- 提供改变 tab 的方法 changeTab。
TabItem 的实现
const TabItem = ({ children }) => {
return <div>{children}</div>
}
TabItem.displayName = 'tabItem'
这个 demo 中的 TabItem 功能十分简单,大部分事情都交给 Tab 做了。 TabItem 做的事情是:
- 展示 children ( 我们写在 TabItem 里面的内容 )
- 绑定静态属性 displayName
总结 组合模式在日常开发中,用途还是比较广泛的,尤其是在一些比较出色的开源项目中,组合模式的总结内容如下:
- 组合模式通过外层组件获取内层组件 children ,通过 cloneElement 传入新的状态,或者控制内层组件渲染。
- 组合模式还可以和其他组件组合,或者是 render props,拓展性很强,实现的功能强大。
总结流程如下: