react组合模式

119 阅读10分钟

组合模式

组合模式是一种结构型设计模式, 你可以使用它将对象组合成树状结构, 并且能像使用独立对象一样使用它们。

组合模式结构

341359589-c6c514de-17c7-426c-b3eb-90f0bfec5f7c.png

  1. 组件 (Component) 接口描述了树中简单项目和复杂项目所共有的操作。

  2. 叶节点 (Leaf) 是树的基本结构, 它不包含子项目。 一般情况下, 叶节点最终会完成大部分的实际工作, 因为它们无法将工作指派给其他部分。

  3. 容器 (Container)——又名 “组合 (Composite)”——是包含叶节点或其他容器等子项目的单位。 容器不知道其子项目所属的具体类, 它只通过通用的组件接口与其子项目交互。 容器接收到请求后会将工作分配给自己的子项目, 处理中间结果, 然后将最终结果返回给客户端。

  4. 客户端 (Client) 通过组件接口与所有项目交互。 因此, 客户端能以相同方式与树状结构中的简单或复杂项目交互。

伪代码

在本例中, 我们将借助组合模式帮助你在图形编辑器中实现一系列的几何图形。

342286452-6ac76312-9dbc-4709-bced-ae9144e6c180.png 组合图形Compound­Graphic是一个容器, 它可以由多个包括容器在内的子图形构成。 组合图形与简单图形拥有相同的方法。 但是, 组合图形自身并不完成具体工作, 而是将请求递归地传递给自己的子项目, 然后 “汇总” 结果。 通过所有图形类所共有的接口, 客户端代码可以与所有图形互动。 因此, 客户端不知道与其交互的是简单图形还是组合图形。 客户端可以与非常复杂的对象结构进行交互, 而无需与组成该结构的实体类紧密耦合。

// 组件接口会声明组合中简单和复杂对象的通用操作。
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()

组件模式优点

  1. 你可以利用多态和递归机制更方便地使用复杂树结构。
  2. 开闭原则。 无需更改现有代码, 你就可以在应用中添加新元素, 使其成为对象树的一部分。

组件模式缺点 对于功能差异较大的类, 提供公共接口或许会有困难。 在特定情况下, 你需要过度一般化组件接口, 使其变得令人难以理解。

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,拓展性很强,实现的功能强大。

总结流程如下:

342300792-5ff408a5-8de1-42dc-9c06-003e78532a31.png