目錄
一、前言
提到React,大家最容易聯想到的就是組件,這也是React能夠幫助我們簡單容易地開發複雜用戶界面的有效利器。可能大家初次嘗試React進行組件化開發時,都只是把一些使用多次的組件提取成單獨的組件以達到複用的目的,減少冗餘。這麼做沒錯,這一點確實是組件化帶來的最直接的好處,但React組件化並不只是簡單地把公共提取複用,還有很多組件設計模式能對代碼的複用性、擴展性帶來質的提升,今天我們就來看下有哪些組件設計模式。
二、組件設計
首先我們先想一個問題:怎樣進行組件設計?我剛學React時,也對這個問題躊躇了很久,看了很多資料,但看完後還是感覺一知半解的,直到一次無意間在知乎上看到下面這麼一篇問答。
問題:
新手的對於前端組件化開發的一些疑問?(大致就是問如何合理地劃分、設計組件)
回覆:
我們說到組件化,一般會用components這個詞,多個components組合形成一個page,不同的page用router調度。
components應該是和業務無關的,它只負責渲染給入的數據。比如按鈕是一個組件,可能有一個參數決定了它的尺寸,一個參數決定了它是否可以點擊,但是點擊這個按鈕之後會發生什麼,就不是按鈕這個組件需要知道的事情了。
所以我們的組件都是業務無關,然後把所有的數據放在page中,去調度組件的使用麼?這顯然又有哪裏不太對。問題出在我們在這裏面少了一層結構,components要組成module,然後module和一些簡單components一起形成page。components和modules,組件和模塊,或者叫做木偶組件和智能組件。
比如常見的TODO list demo中的addNewTodo這件事,可以由input決定,可以有TodoList決定,甚至可以由整個根組件(page)決定。input應該是一個木偶組件,就像是公司最底層的員工,只能聽命於領導埋頭做事,並沒有決策的權利。所以把方法安排在input上是不合適的。如果給到根組件呢?這就像讓CEO去負責一個員工的入職,可能在小公司(簡單頁面)裏也是可以的,但如果公司特別大,入職這種事情肯定授權給HR來負責了。所以TodoList就是這個HR,它可以全權負責新增和刪除,既不越權也不屈尊。
組件開發設計和人員的組織架構設計非常像,要分爲多少個層級,每個人負責哪些事務,如何把權利和責任落實到合適的層級,這是兩者的共同點。
在這位答主的評論中,他提到了components、module等關鍵詞,以及按照功能職責、模塊組合的方式去設計組件,並用CEO和HR的工作擬人化地舉了這麼一個形象的例子,看完有沒有對組件設計有種醍醐灌頂的感覺?其實這種設計方式就和社區中流行的Container&Component模式特別相似,而且設計模式還不止這一種,具體的請繼續往下看。
三、常見的組件設計模式
1.容器組件和展示組件
場景
假設有這麼一個業務場景,需要實時顯示當前的時間,常規的寫法可能是下面這樣的:
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {
time: props.time
};
}
render() {
const time = this._formatTime(this.state.time);
return (
<Text>
{ time.hours } : { time.minutes } : { time.seconds }
</Text>
);
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_formatTime = (time) => {
var [ hours, minutes, seconds ] = [
time.getHours(),
time.getMinutes(),
time.getSeconds()
].map(num => num < 10 ? '0' + num : num);
return { hours, minutes, seconds };
}
_updateTime = () => {
this.setState({
time: new Date(this.state.time.getTime() + 1000)
});
}
};
在組件的構造函數中,初始化了組件的狀態,這裏只保存了當前時間。通過使用 setInterval ,每秒更新一次狀態,然後組件會重新渲染。爲了看起來像個真正的時鐘,還使用了兩個輔助函數: _formatTime 和 _updateTime 。_formatTime 用來提取時分秒並確保它們是兩位數的形式。_updateTime 用來將 time 對象設置爲當前時間加一秒。
問題
這個組件中做了好幾件事,它似乎承擔了太多的職責。
-
它通過自身來修改狀態。在組件中更改時間可能不是一個好主意,因爲只有 Clock 組件知道當前時間。如果系統中的其他部分也需要此數據,那麼將很難進行共享。
-
_formatTime 實際上做了兩件事,它從時間對象中提取出所需信息,並確保這些值永遠以兩位數字的形式進行展示。這沒什麼問題,但如果提取操作不是函數的一部分那就更好了,因爲函數綁定了 time對象的類型。即此函數既要知道數據結構,同時又要對數據進行可視化處理。
解決方案
對於容器型組件和展示型組件的概念,大家或多或少都聽過,下面我們詳細地介紹下容器型組件和展示型組件。
在 React.js Conf 2015 ,有一個 Making your app fast with high-performance components 的主題介紹了容器組件,它的基本原則是:
一個container組件僅僅做數據提取,然後渲染它對應的corresponding子組件
“Corresponding”意味着分享同一個名稱的組件,例如:
StockWidgetContainer => StockWidget
TagCloudContainer => TagCloud
MerchandiseListContainer => MerchandiseList
容器組件專門負責和 store 通信,把數據通過 props 傳遞給普通的展示組件,展示組件如果想發起數據的更新,也是通過容器組件從 props 傳遞來的回調函數來告訴 store。
由於展示組件不再直接和 store 耦合,而是通過 props 接口來定義自己所需的數據和方法,使得展示組件的可複用性會更高。
兩者的區別
|
展示組件 |
容器組件 |
---|---|---|
作用 |
描述如何展現(骨架、樣式) |
描述如何運行(數據獲取、狀態更新) |
直接使用 store |
否 |
是 |
數據來源 |
props |
監聽 store state |
數據修改 |
從 props 調用回調函數 |
向 store 派發 actions |
來自 Redux 文檔 https://user-gold-cdn.xitu.io/2018/5/2/1631f590aa5512b7
劃分出容器組件和展示組件的優點:
-
展示和容器更好的分離,更好的理解應用程序和UI
-
重用性高,展示組件可以用於多個不同的state數據源
-
展示組件就是你的調色板,可以把他們放到單獨的頁面,在不影響應用程序的情況下,配合設計師很方便地調整UI
-
迫使你分離出更細,職責更單一的組件,達到更高的可用性
下面將應用容器組件和展示組件的模式來對上面的例子進行組件提取:
容器型組件 ClockContainer 的代碼:
import Clock from './Clock.jsx'; // <-- 展示型組件
export default class ClockContainer extends React.Component {
constructor(props) {
super(props);
this.state = { time: props.time };
this._update = this._updateTime.bind(this);
}
render() {
return <Clock { ...this._extract(this.state.time) }/>;
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_extract(time) {
return {
hours: time.getHours(),
minutes: time.getMinutes(),
seconds: time.getSeconds()
};
}
_updateTime() {
this.setState({
time: new Date(this.state.time.getTime() + 1000)
});
}
};
它接收 time (date 對象) 屬性,使用 setInterval 循環並瞭解數據 (getHours、getMinutes 和 getSeconds) 的詳情。最後渲染展示型組件並傳入時分秒三個數字。這裏沒有任何展示相關的內容。只有業務邏輯。
展示型組件 Clock 的代碼:
export default function Clock(props) {
var [ hours, minutes, seconds ] = [
props.hours,
props.minutes,
props.seconds
].map(num => num < 10 ? '0' + num : num);
return <Text>{ hours } : { minutes } : { seconds }</Text>;
};
這麼做的好處
-
提高組件的可複用性
不改變時間或不使用 JavaScript Date 對象的應用中,都可以使用 Clock 函數/組件。原因是它相當純粹,不需要對所需數據的詳情有任何瞭解。
-
邏輯和UI隔離
容器型組件封裝了邏輯,它們可以搭配不同的展示型組件使用,因爲它們不參與任何展示相關的工作。我們上面所採用的方法是一個很好的示例,我們可以很容易地從數字時鐘切換到模擬時鐘,唯一的變化就是替換 render 方法中的 <Clock> 組件。
-
更加容易測試
測試也將變得更容易,因爲組件承擔的職責更少,容器型組件不關心 UI ,展示型組件只是純粹地負責展示。
複用容器組件
需要提一點的是,容器型組件也能複用。比如下面這個小說列表的示例,兩種分類的小說,數據(接口)差異不大,主要是展現形式(列表和網格)的不同,這種情況下可以複用容器組件:
export interface NovelGridProps {
loading: boolean
novels: Array<any>
}
export const NovelGrid = ({ loading, novels }: NovelGridProps) => (
<GridView
data={novels}
itemsPerRow={3}
renderItem={item => (
<View>
<Image
source={{ uri: item.url }}
style={{ width: 50, height: 100 }}
/>
<Text>{item.name}</Text>
</View>
)}
/>
)
export interface NovelListProps {
loading: boolean
novels: Array<any>
}
export const NovelList = ({ novels, loading }: NovelListProps) => (
<FlatList
data={novels}
renderItem={({ item }) => (
<View>
<Image
source={{ uri: item.url }}
style={{ width: 50, height: 100 }}
/>
<Text>{item.name}</Text>
</View>
)}
/>
)
export interface Props {
type: string,
render: (state: State, props: Props) => ReactNode
}
export interface State {
loading: boolean
novels: Array<any>
}
export class NovelListContainer extends PureComponent<Props, State> {
state = {
loading: false,
novels: []
}
componentDidMount() {
this.fetchNovels()
}
fetchNovels = () => {
this.setState({
loading: true
})
API.fetchNovelsByType(this.props.type)
.then(novels => {
this.setState({
novels,
loading: false
})
})
.catch(error => {
this.setState({
loading: false
})
})
}
render() {
return this.props.render(this.state, this.props)
}
}
export class Test extends PureComponent<Props, State> {
render() {
return (
<Tabs>
<TabItem title='熱門小說'>
<NovelListContainer
type='hot_novel'
render={({ novels, loading }, { type }) => (
<NovelGrid
loading={loading}
novels={novels}
/>
)}
/>
</TabItem>
<TabItem title='猜你喜歡'>
<NovelListContainer
type='suggest_novel'
render={({ novels, loading }, { type }) => (
<NovelGrid
loading={loading}
novels={novels}
/>
)}
/>
</TabItem>
</Tabs>
)
}
}
小結
沒有什麼規則是一成不變的,是否適用還是需要看業務場景是否需要,沒必要給每個場景都強行套上這套模式,它只是爲你在代碼設計時提供了一種選擇。
在一些非常小的組件裏混用容器和展示是可以的,當業務變複雜後,如何進行容器組件和展示組件的拆分就很明顯了,就像你知道什麼時候該提取一個函數一樣!
2.模版化組件
場景
假設有這麼一種業務場景,需要有一個 Comment(評論) 組件,這個組件存在多種行爲或事件,同時組件所展現的信息根據用戶的身份不同而有所變化:
-
用戶是否是此 comment 的作者;
-
此 comment 是否被正確保存;
-
各種權限不同
-
等等......
都會引起這個組件的不同展示行爲。
問題
想象上面這種場景,是不是就感覺到了繁雜的邏輯,各種 if else邏輯判斷,也就是多種configurations的情況。就算使用上面提到的容器組件和展示組件的劃分模式,也解決不了這種根本問題。
解決方案
我們先想想,爲什麼一個組件會變的臃腫而複雜呢?
-
渲染元素較多且嵌套
-
組件內部變化較多,或者存在多種 configurations 的情況。
這種情況下,我們便可以將組件改造爲模版:父組件類似一個模版,只專注於各種 configurations。
比如上面提到的這種場景,與其把所有的邏輯混淆在一起,也許更好的做法是利用 React 可以傳遞 React component 的特性,我們將 React component 進行組件間傳遞,這樣就更加像一個強大的模版:
class CommentTemplate extends React.Component {
static propTypes = {
// Declare slots as type node
metadata: PropTypes.node,
actions: PropTypes.node,
};
render() {
return (
<div>
<CommentHeading>
<Avatar user={...}/>
// Slot for metadata
<span>{this.props.metadata}</span>
</CommentHeading>
<CommentBody/>
<CommentFooter>
<Timestamp time={...}/>
// Slot for actions
<span>{this.props.actions}</span>
</CommentFooter>
</div>
...
此時,我們真正的 Comment 組件組織爲:
class Comment extends React.Component {
render() {
const metadata = this.props.publishTime ? <PublishTime time={this.props.publishTime} /> : <span>Saving...</span>;
const actions = [];
if (this.props.isSignedIn) {
actions.push(<LikeAction />);
actions.push(<ReplyAction />);
}
if (this.props.isAuthor) {
actions.push(<DeleteAction />);
}
return <CommentTemplate metadata={metadata} actions={actions} />;
}
metadata 和 actions 其實就是在特定情況下需要渲染的 React element。
比如:
-
如果 this.props.publishTime 存在,metadata 就是 <PublishTime time={this.props.publishTime} />;
-
反之則爲 <Text>Saving...</Text>。
-
如果用戶已經登陸,則需要渲染(即actions值爲) <LikeAction /> 和 <ReplyAction />;
-
如果是評論者本人,需要渲染的內容就要加入 <DeleteAction />
小結
模板化組件對於一些UI樣式高度相似、渲染元素較多但存在多種“configurations“的業務場景是特別適用的,靈活運用模板化組件,能給你帶來邏輯簡化,易擴展的好處。
3.高階組件
在實際開發當中,組件經常會被其他需求所污染。
場景
想象這樣一個場景:我們想統計頁面中所有鏈接的點擊信息。在鏈接點擊時,發送統計請求,同時這條請求需要包含此頁面 document 的 id 值。
常見的做法是在 Document 組件的生命週期函數 componentDidMount 和 componentWillUnmount 增加代碼邏輯:
class Document extends React.Component {
componentDidMount() {
ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
}
componentWillUnmount() {
ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
}
onClick = (e) => {
if (e.target.tagName === 'A') { // Naive check for <a> elements
sendAnalytics('link clicked', {
documentId: this.props.documentId // Specific information to be sent
});
}
};
render() {
// ...
問題
這麼做的幾個問題在於:
-
相關組件 Document 除了自身的主要邏輯:顯示主頁面之外,多了其他統計邏輯;
-
如果 Document 組件的生命週期函數中,還存在其他邏輯,那麼這個組件就會變的更加含糊不合理;
-
統計邏輯代碼無法複用;
-
組件重構、維護都會變的更加困難。
解決辦法
爲了解決這個問題,我們提出了高階組件這個概念: higher-order components (HOCs)。不去晦澀地解釋這個名詞,我們來直接看看使用高階組件如何來重構上面的代碼:
function withLinkAnalytics(mapPropsToData, WrappedComponent) {
return class LinkAnalyticsWrapper extends React.Component {
componentDidMount() {
ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
}
componentWillUnmount() {
ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
}
onClick = (e) => {
if (e.target.tagName === 'A') { // Naive check for <a> elements
const data = mapPropsToData ? mapPropsToData(this.props) : {};
sendAnalytics('link clicked', data);
}
}
render() {
// Simply render the WrappedComponent with all props
return <WrappedComponent {...this.props} />;
}
}
}
需要注意的是,withLinkAnalytics 函數並不會去改變 WrappedComponent 組件本身,更不會去改變 WrappedComponent 組件的行爲。而是返回了一個被包裹的新組件。實際用法爲:
class Document extends React.Component {
render() {
// ...
}
}
export default withLinkAnalytics((props) => ({
documentId: props.documentId
}), Document);
這樣一來,Document 組件仍然只需關心自己該關心的部分,而 withLinkAnalytics 賦予了複用統計邏輯的能力。
高階組件的存在,完美展示了 React 天生的複合(compositional)能力,在 React 社區當中,react-redux,styled-components,react-intl 等都普遍採用了這個方式。值得一提的是,recompose 類庫又利用高階組件,併發揚光大,做到了“腦洞大開”的事情。
小結
高階組件是對React代碼進行更高層次重構的好方法,如果你想精簡你的state和生命週期方法,那麼高階組件可以幫助你提取出可重用的函數。一般來說高階組件能完成的用組件嵌套+繼承也可以,用嵌套+繼承的方式理解起來其實更容易一點,特別是去重構一個複雜的組件時,通過這種方式往往更快,拆分起來更容易。至於到底用哪個最佳還要具體看業務場景。
四、總結
最後再說下幾種組件設計模式的特點:
-
容器組件和展示組件:將邏輯和UI進行隔離,降低耦合,便於複用,但如果強加這種模式,可能會增加額外的工作量
-
模板化組件:更加適合渲染元素較多但存在多種“configurations“的業務場景,簡化邏輯判斷,使邏輯清晰
-
高階組件:給所有子組件注入邏輯,達到功能增強的目的,通常來講,能使用父組件達到的效果,儘量不要用高階組件,因爲高階組件是一種更 hack 的方法,但同時也有更高的靈活性。
相關資料: