Photo by Hello I’m Nik on Unsplash
初學 React 時,只會用一種方式嵌套多個 Component:從最大的 Component,包住較小的 Component,像是 N 層俄羅斯娃娃一般。
<App>
<Header />
<Body>
<Nav>
<Ul>
<Items />
</Ul>
</Nav>
<Main>
<Title />
<Content />
</Main>
</Body>
<Footer />
</App>
工作上收到設計稿時,我習慣先和組員討論,哪些元件有可能會被重複使用? 在團隊都不清楚哪些元件比較容易被重複使用,可能有哪些變化的情況下,我習慣先將元件拆得比較小,從最小的Component開始做起(例如Button/資訊卡),再接著做比較大的容器(例如Modal/標籤頁),請同事自己將元件拼裝成他們需要的頁面。 在有技術債包袱的專案中,我無法要求團隊重構骨架,只能先刻好元件,並提供適當的接口,以”先研究不傷身體,再講求效果“的精神替換元件。
由於最近上頭同意通盤整理元件,在後台系統的頁面有 87%元件是相似的情況下,同事組裝元件的方式也大同小異,他們希望我為主畫面設計一個 Template,減少拼裝元件的時間。
設計一個Template,在初心者眼中會是這樣子↓
<PageOne
title="title"
subTitle="subTitle"
lists=[]
contentTitle="contentTitle"
prop={}
anotherProp={}
// ...
>
{children}
</PageOne>
把需要的資料都用props傳進Component就好了惹~
但這樣會有幾個顯而易見的問題:
- 無法從外觀看出 Template 的用途,及其中的內容
- props 過多,一旦調整其中一兩個 props,所有使用到的頁面也要跟著調整
- 只容許一個 children 存在,除了產生”哪些是 props,誰才是 children”的大哉問之外,重構的彈性也很低。
為了能做出更具彈性的Component,我在egghead發現了Kent C. Dodds這位大神,他提供了幾個 react 設計模式的建議。
1. Compound Components 父親與它的 static 孩子們
使用場景:父容器與子元件有緊密的關係。如<ul></ul>
與<li></li>
之間的關係。
範例說明: 這是一組
<select>
跟<option>
的下拉式選單, 符合「filterOption顯示條件」的項目才會出現在選單中,其它項目必須隱藏。 在這個範例中,只有 title 為「manager」的員工才可出現在下拉式選單中。 父容器將filterOption傳給每個子元件(static Option),由子元件判斷要 render 哪些選項。符合的話則 render<option></option>
,不符合則 return null。
<CustomSelect
label="Manager"
placeholder="Please select a manager"
defaultValue=""
filterOption={{
key: "title",
value: "manager"
}}
>
{stuffList.map((option) => (
<CustomSelect.Option element={option} key={option.name}>
{option.name}
</CustomSelect.Option>
))}
</CustomSelect>
方法 1. React 16 以下,可用 React.Children.map()將父容器的資料,傳遞給每個子元件。
以下節錄關鍵程式碼:
class CustomSelect extends React.Component {
render(){
const { label, placeholder, defaultValue, filterOption } = this.props;
return (
<select defaultValue={defaultValue}>
<option value="" disabled>
{placeholder}
</option>
{React.Children.map(this.props.children, (childElement) => React.cloneElement(childElement, { filterOption // 想傳給每個子元件的資料 }) )}
</select>
);
}
}
React.Children.map 的缺點是,它是用 for 迴圈的方式,將父容器的資料以 props 傳給第一層的子元件。只要目標元件被一層<div>
包住,資料將會注入第一層的<div>
,從而導致目標元件拿不到資料的情況。因此目標元件的修改彈性是比較低的。範例如下:
return (
<CustomSelect // 父容器
label="Manager"
placeholder="Please select a manager"
defaultValue=""
filterOption={{
key: "title",
value: "manager",
}}
>
{stuffList.map(option => (
<div> {/* 在 CustomSelect 外面再包一層 */}
{/* CustomSelect將無法拿到父容器的filterOption */}
<CustomSelect.Option element={option} key={option.name}>
{option.name}
</CustomSelect.Option>
</div> ))}
</CustomSelect>
)
方法 2. React 16.0 以上。有好用的 context API 將父容器的資料傳遞給子元件
以下節錄關鍵程式碼:
const SelectContext = createContext();
class CustomSelect extends React.Component {
render(){
const { label, placeholder, defaultValue, filterOption } = this.props;
return (
<>
<label>{`${label} : `}</label>
<select defaultValue={defaultValue}>
<option value="" disabled>
{placeholder}
</option>
{/* 想傳給每個子元件的資料 */}
<SelectContext.Provider value={{ filterOption: filterOption }}> {this.props.children}
</SelectContext.Provider> </select>
</>
);
}
}
static Option = ({ children, element }) => (
<SelectContext.Consumer> {({ filterOption }) => { const { key, value } = filterOption;
return element[key] === value ? (
<option value={children}>{children}</option>
) : null;
}} </SelectContext.Consumer>);
context API 的優點是,可將 state/props/function 等資料,放進 Provider。 包在 Provider 當中的任何 children,即便是孫子/曾曾曾孫,只需透過 Consumer,都可以取得父容器傳遞下來的資料。
優化 Context.Consumer 的渲染
Provider 所提供的 value 一旦改變,所有的 Consumer 都會重新render。 若 value 是 object,則更要注意 object 是否參考同一個refernece。
範例說明: 這次,CustomSelect 除了下拉式選單之外,還多了一個打勾回饋。 只要下拉式選單 onChange,都會setState並且顯示打勾Icon,表示選擇完成。 然而,只要元件setState,所有的
<option>
都會重新render。 在其他條件不變的情況下,如何如何避免 Consumer 進行無意義的 re-render?
<span>
<label>{`${label} : `}</label>
<select
style={{ width: 400 }}
defaultValue={defaultValue}
onChange={this.handleSelectChange}
>
<option value="" disabled>
{placeholder}
</option>
{/* 想傳給每個子元件的資料 */}
<SelectContext.Provider value={{ filterOption: filterOption }}>
{this.props.children}
</SelectContext.Provider>
</select>
{/* 打勾Icon,表示選擇完成 */}
{isSelected && <GreenCheckIcon />}</span>
若 CustomSelect 因為 setState 重新渲染,Context.Provider的value 會指向新的 reference,造成 Context.Consumer 重新渲染。
{/*每次render,value都是新的物件*/}
<SelectContext.Provider value={{ filterOption: filterOption }}> {this.props.children}
</SelectContext.Provider>
讓value指向this.state.contextValue後,即便<CustomSelect />
重新渲染,Consumer不會跟著重新渲染。
class CustomSelect extends React.Component {
state = {
contextValue: { filterOption: this.props.filterOption, }, }
render() {
return (
<SelectContext.Provider value={this.state.contextValue}> {this.props.children}
</SelectContext.Provider>
)
}
}
Context.Provider搭配Context.Consumer是個很強大的組合,只要適當地管理,能讓包在 Provider 當中的任何 children,即便是孫子/曾曾曾孫,都可以取得Provider提供的資料。
然而,若僅是打算將父容器的資料傳給「一等親」的子元件,並未打算傳給曾曾曾孫的話,使用Context真是太才小用了。 要先createContext,還要建立Provider/Consumer,更要去注意Context是否有濫用的傾向,就僅僅是一個小小的元件而言,這開發成本實在是太高了。
因此,下一章會提到更有彈性的Function as children。
本章結論
- 元件的設計需考慮到「容易維修,可重新組裝」的原則。
- 建立Context的優點是,可將 state/props/function 等資料,包裹在 Context.Provider的value屬性當中。
- 子元件若需要資料,須透過Context.Consumer,包在Context.Consumer當中。
- Provider 的任何 children,即使是曾曾曾孫,只需透過 Context.Consumer,都可以取得Context.Provider的資料。
- 在class component當中,Context.Provider的value若是物件,可將物件的reference儲存在state裡面,優化Context.Consumer的渲染。
- 由於Context可作為global state使用,須謹慎維護。若僅是「一等親」的父子元件共用資料,選擇Function as children的方式會更好維護。