- Published on
React Component Design - Compound Component
- Authors
- Name
- Alex Yu
假設今天我們有一個 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