React Component Design - Compound Component

發佈時間

假設今天我們有一個 React component Switch,它的 props 如下

Switch.props = { on: PropTypes.boolean, onClick: PropTypes.func, };

請設計一個 component 叫Toggle,這個Toggle有一個state儲存on的狀態,on變化時要呼叫 props 傳下來的onTogglecallback,相信大多數人會實作類似以下的程式碼

import React from 'react'; import { Switch } from '../switch'; class Toggle extends React.Component { state = { on: false }; toggle = () => this.setState( ({ on }) => ({ on: !on }), () => { this.props.onToggle(this.state.on); } ); render() { const { on } = this.state; return <Switch on={on} onClick={this.toggle} />; } }

使用Toggle的方式則類似這樣

function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return <Toggle onToggle={onToggle} />; }

這樣的設計看起來沒什麼問題,on state 保留在Toggle 裡面,並不需要透露給外面知道。

如果今天我們接到需求需要在onstate truefalse各顯示不同的字串,於是我們修改成

import React from 'react' import { Switch } from '../switch' class Toggle extends React.Component { state = {on: false} toggle = () => ( // same codes ) render() { const { isOnText, isOffText } = this.props const { on } = this.state return ( <> {on ? isOnText : isOffText} <Switch on={on} onClick={this.toggle} /> </> ) } }

這樣的方式有三個缺點:

  1. 顯示的順序寫死在 component 裡面,無法客製化順序或者樣式
  2. props 命名方式沒統一的情況下,無法讓人一眼就知道這 props 是什麼意思,要傳什麼內容進來,需要檢視TogglepropTypes才可以了解
  3. 如果今天在ontrue的時候有額外的 callback 要呼叫,需要再新增一次 props 給Toggle

Compound Component#

這時候Compound Component的設計方式便派上用場,它的概念是

[讓你的 UI 元件透過  this.props.children  的方式傳入給  parent component,利用  React.Children.map()  來 render 所有傳入的  this.props.children,並且透過  React.cloneElement  將 parent 的  state  傳入每個 children 的  props,讓 parent 與 children 之間會  隱含著狀態的共享,對於元件使用者來說,他只需要傳入想要的  children component,不用知道 parent 與 children 之間如何溝通,當然也能隨意調整順序,這樣的 API 設計,對於元件使用者就非常的友善。

在這樣的原則下,不難發現,Compound component 必須要  同時結合使用 parent component 與 children component 才有意義。](https://blog.techbridge.cc/2018/06/27/advanced-react-component-patterns-note/)

實作後的結果如下

// Compound Components import React from 'react'; import { Switch } from '../switch'; class Toggle extends React.Component { static On = ({ on, children }) => (on ? children : null); static Off = ({ on, children }) => (on ? null : children); static Button = ({ on, toggle, ...props }) => ( <Switch on={on} onClick={toggle} {...props} /> ); state = { on: false }; toggle = () => this.setState( ({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on) ); render() { return React.Children.map(this.props.children, (child) => React.cloneElement(child, { on: this.state.on, toggle: this.toggle, }) ); } } function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return ( <Toggle onToggle={onToggle}> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <Toggle.Button /> </Toggle> ); }

實作後的結果解決了上面提到的 3 個問題:

  1. 順序現在可以依照children的排序做顯示,樣式也可以自定義
  2. props 維持在最小限度,只有onToggle需要傳入
  3. Toggle內的children根據介面,OnOff可以接收到on state,Button則可以收到on state 跟toggle function。所以OnOff可以根據接收到的onstate 自行決定要觸發的 callback

More Flexible Compound Component#

Check Children#

在上面的實作中,我們的 Compound Components 只有辦法接受Toggle裡面定義的On,Off,Button children,這邊可以做檢查判斷 children 是否有在Toggle的定義內,如果 children 不在Toggle的 children 的定義內,則正常 render children

// Compound Components import React from 'react'; import { Switch } from '../switch'; function componentHasChild(child) { for (const property in Toggle) { if (Toggle.hasOwnProperty(property)) { if (child.type === Toggle[property]) { return true; } } } return false; } class Toggle extends React.Component { static On = ({ on, children }) => (on ? children : null); static Off = ({ on, children }) => (on ? null : children); static Button = ({ on, toggle, ...props }) => ( <Switch on={on} onClick={toggle} {...props} /> ); state = { on: false }; toggle = () => this.setState( ({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on) ); render() { return React.Children.map(this.props.children, (child) => { if (componentHasChild(child)) { return React.cloneElement(child, { on: this.state.on, toggle: this.toggle, }); } return child; }); } } const Hi = () => <h4>Hi</h4>; function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return ( <Toggle onToggle={onToggle}> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <Toggle.Button /> <Hi /> </Toggle> ); }

Using Context#

現在我們的Toggle元件只有辦法 render 出第一層的 children,當我們想要把onstate 跟toggle傳給 children 不管層數有幾層時,可以使用Reactcontext API。

// Flexible Compound Components with context import React from 'react'; import { Switch } from '../switch'; const ToggleContext = React.createContext(); class Toggle extends React.Component { static On = ({ children }) => ( <ToggleContext.Consumer> {({ on }) => (on ? children : null)} </ToggleContext.Consumer> ); static Off = ({ children }) => ( <ToggleContext.Consumer> {({ on }) => (on ? null : children)} </ToggleContext.Consumer> ); static Button = (props) => ( <ToggleContext.Consumer> {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />} </ToggleContext.Consumer> ); state = { on: false }; toggle = () => this.setState( ({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on) ); render() { // 由於不用傳遞 props 給 children,也就不用 React.Children.map 了,直接使用 this.props.children 即可 return ( <ToggleContext.Provider value={{ on: this.state.on, toggle: this.toggle }} > {this.props.children} </ToggleContext.Provider> ); } } function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return ( <Toggle onToggle={onToggle}> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <div> <Toggle.Button /> </div> </Toggle> ); }

這邊我們可以增加Toggle的 children validation,避免On, Off, ButtonToggle以外的地方被使用

// Flexible Compound Components with context (extra credit 1) // This adds validation to the consumer import React from 'react'; import { Switch } from '../switch'; const ToggleContext = React.createContext(); function ToggleConsumer(props) { return ( <ToggleContext.Consumer {...props}> {(context) => { if (!context) { throw new Error( `Toggle compound components cannot be rendered outside the Toggle component` ); } return props.children(context); }} </ToggleContext.Consumer> ); } class Toggle extends React.Component { static On = ({ children }) => ( <ToggleConsumer>{({ on }) => (on ? children : null)}</ToggleConsumer> ); static Off = ({ children }) => ( <ToggleConsumer>{({ on }) => (on ? null : children)}</ToggleConsumer> ); static Button = (props) => ( <ToggleConsumer> {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />} </ToggleConsumer> ); state = { on: false }; toggle = () => this.setState( ({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on) ); render() { return ( <ToggleContext.Provider value={{ on: this.state.on, toggle: this.toggle }} > {this.props.children} </ToggleContext.Provider> ); } } function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return ( <Toggle onToggle={onToggle}> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <div> <Toggle.Button /> </div> </Toggle> ); }

在使用 Provider/Context 的時候,要特別注意 re-render 的問題,這邊可以改良一下把 Provider 的value改以state傳入

// Flexible Compound Components with context // This allows you to avoid unecessary rerenders import React from 'react'; import { Switch } from '../switch'; const ToggleContext = React.createContext(); function ToggleConsumer(props) { return ( <ToggleContext.Consumer {...props}> {(context) => { if (!context) { throw new Error( `Toggle compound components cannot be rendered outside the Toggle component` ); } return props.children(context); }} </ToggleContext.Consumer> ); } class Toggle extends React.Component { static On = ({ children }) => ( <ToggleConsumer>{({ on }) => (on ? children : null)}</ToggleConsumer> ); static Off = ({ children }) => ( <ToggleConsumer>{({ on }) => (on ? null : children)}</ToggleConsumer> ); static Button = (props) => ( <ToggleConsumer> {({ on, toggle }) => <Switch on={on} onClick={toggle} {...props} />} </ToggleConsumer> ); // The reason we had to move `toggle` above `state` is because // in our `state` initialization we're _using_ `this.toggle`. So // if `this.toggle` is not defined before state is initialized, then // `state.toggle` will be undefined. toggle = () => this.setState( ({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on) ); state = { on: false, toggle: this.toggle }; render() { return ( <ToggleContext.Provider value={this.state}> {this.props.children} </ToggleContext.Provider> ); } } function Usage({ onToggle = (...args) => console.log('onToggle', ...args) }) { return ( <Toggle onToggle={onToggle}> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <div> <Toggle.Button /> </div> </Toggle> ); }

結論#

最後來對Compound Component做總結,這樣的設計方式能讓 parent component 開放一定程度的介面給 children component 使用,使得 parent component 跟 children component 使用上有一定程度的語意關聯,render 的順序也可以保持彈性。但相反的,同時也綁死 children component 只能在 parent component 做使用。

如果要對 children component 更有彈性,比如檢查 children component 是否有在 parent component 的介面內,或者讓 children component 有多級層數,都需要更加複雜的檢查以及 props 傳遞的方式(Provider/Context)。

Related articles#