React Component Design - Compound Component
- 發佈時間
假設今天我們有一個 React component Switch
,它的 props 如下
已複製!Switch.props = { on: PropTypes.boolean, onClick: PropTypes.func, };
請設計一個 component 叫Toggle
,這個Toggle
有一個state
儲存on
的狀態,on
變化時要呼叫 props 傳下來的onToggle
callback,相信大多數人會實作類似以下的程式碼
已複製!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
裡面,並不需要透露給外面知道。
如果今天我們接到需求需要在on
state true
跟false
各顯示不同的字串,於是我們修改成
已複製!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} /> </> ) } }
這樣的方式有三個缺點:
- 顯示的順序寫死在 component 裡面,無法客製化順序或者樣式
- props 命名方式沒統一的情況下,無法讓人一眼就知道這 props 是什麼意思,要傳什麼內容進來,需要檢視
Toggle
的propTypes
才可以了解 - 如果今天在
on
是true
的時候有額外的 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 個問題:
- 順序現在可以依照
children
的排序做顯示,樣式也可以自定義 - props 維持在最小限度,只有
onToggle
需要傳入 Toggle
內的children
根據介面,On
跟Off
可以接收到on
state,Button
則可以收到on
state 跟toggle
function。所以On
跟Off
可以根據接收到的on
state 自行決定要觸發的 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,當我們想要把on
state 跟toggle
傳給 children 不管層數有幾層時,可以使用React
的context
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
, Button
在Toggle
以外的地方被使用
已複製!// 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#
- https://youtu.be/hEGg-3pIHlE
- https://plainenglish.io/blog/5-advanced-react-patterns-a6b7624267a6
- https://frontendmasters.com/courses/advanced-react-patterns/
- https://blog.techbridge.cc/2018/06/27/advanced-react-component-patterns-note/
- https://blog.techbridge.cc/2018/07/21/advanced-react-component-patterns-note-II/
- https://kentcdodds.com/blog/answers-to-common-questions-about-render-props
- https://youtu.be/BcVAq3YFiuc
- https://codesandbox.io/s/github/kentcdodds/advanced-react-patterns-v2