淺談 React 組件設(shè)計(jì)
大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
前言
前端組件化一直是老生常談的話題,在前面介紹 React 的時(shí)候我們已經(jīng)提到過(guò) React 的一些優(yōu)勢(shì),今天則是帶大家了解一下組件設(shè)計(jì)原則。
jQuery 插件
在開始講 React 組件之前,我們還是要先來(lái)聊聊 jQuery。在我看來(lái),jQuery 插件就已經(jīng)具備了組件化的雛形。
在 jQuery 還大行其道的時(shí)代,我們?cè)诰W(wǎng)上可以看到一些 jQuery 插件網(wǎng)站,里面有各種豐富的插件,比如輪播圖、表單、選項(xiàng)卡等等。
組件?插件?
組件和插件的區(qū)別是什么呢?插件是集成到某個(gè)平臺(tái)上的,比如 Jenkins 插件、Chrome 插件等等,jQuery 插件也類似。平臺(tái)只提供基礎(chǔ)能力,插件則提供一些定制化的能力。而組件則是偏向于 ui 層面的,將 ui 和業(yè)務(wù)邏輯封裝起來(lái),供其他人使用。
封裝 DOM 結(jié)構(gòu)
在一些最簡(jiǎn)單無(wú)腦的 jQuery 插件中,它們一般會(huì)將 DOM 結(jié)構(gòu)直接寫死到插件中,這樣的插件拿來(lái)即用,但限制也比較大,我們無(wú)法修改插件的 DOM 結(jié)構(gòu)。
// 輪播圖插件
$("#slider").slider({
config: {
showDot: true, // 是否展示小圓點(diǎn)
showArrow: true // 是否展示左右小箭頭
}, // 一些配置
data: [] // 數(shù)據(jù)
})
還有另一種極端的插件,它們完全不把 DOM 放到插件中,但會(huì)要求使用者按照某種固定格式的結(jié)構(gòu)來(lái)組織代碼。一旦結(jié)構(gòu)不準(zhǔn)確,就可能會(huì)造成插件內(nèi)部獲取 DOM 出錯(cuò)。但這種插件的好處在于可以由使用者自定義具體的 DOM 結(jié)構(gòu)和樣式。
<div id="slider">
<ul class="list">
<li data-index="0"><img src="" /></li>
<li data-index="1"><img src="" /></li>
<li data-index="2"><img src="" /></li>
</ul>
<a href="javascript:;" class="left-arrow"><</a>
<a href="javascript:;" class="left-arrow">></a>
<div class="dot">
<span data-index="0"></span>
<span data-index="1"></span>
<span data-index="2"></span>
</div>
</div>
$("#slider").slider({
config: {} // 配置
})
當(dāng)然,你也可以選擇將 DOM 通過(guò)配置傳給插件,插件內(nèi)部去做這些渲染的工作,這樣的插件比較靈活。有沒(méi)有發(fā)現(xiàn)?這和 render props 模式非常相似。
$("#slider").slider({
config: {}, // 配置
components: {
dot: (item, index) => `<span data-index=${index}></span>`,
item: (item, index) => `<li data-index=${index}><img src=${item.src} /></li>`
}
})
React 組件設(shè)計(jì)
前面講了幾種 jQuery 插件的設(shè)計(jì)模式,其實(shí)萬(wàn)變不離其宗,不管是 jQuery 還是 React,組件設(shè)計(jì)思想都是一樣的。

個(gè)人覺得,組件設(shè)計(jì)應(yīng)該遵循以下幾個(gè)原則:
適當(dāng)?shù)慕M件粒度:一個(gè)組件盡量只做一件事。 復(fù)用相同部分:盡量復(fù)用不同組件相同的部分。 松耦合:組件不應(yīng)當(dāng)依賴另一個(gè)組件。 數(shù)據(jù)解耦:組件不應(yīng)該依賴特定結(jié)構(gòu)的數(shù)據(jù)。 結(jié)構(gòu)自由:組件不應(yīng)該封閉固定的結(jié)構(gòu)。
容器組件與展示組件
顧名思義,容器組件就是類似于“容器”的組件,它可以擁有狀態(tài),會(huì)做一些網(wǎng)絡(luò)請(qǐng)求之類的副作用處理,一般是一個(gè)業(yè)務(wù)模塊的入口,比如某個(gè)路由指向的組件。我們最常見的就是 Redux 中被 connect 包裹的組件。容器組件有這么幾個(gè)特點(diǎn):
容器組件常常是和業(yè)務(wù)相關(guān)的。 統(tǒng)一的數(shù)據(jù)管理,可以作為數(shù)據(jù)源給子組件提供數(shù)據(jù)。 統(tǒng)一的通信管理,實(shí)現(xiàn)子組件之間的通信。
展示組件就比較簡(jiǎn)單的多,在 React 中組件的設(shè)計(jì)理念是 view = f(data),展示組件只接收外部傳來(lái)的 props,一般內(nèi)部沒(méi)有狀態(tài),只有一個(gè)渲染的作用。
適當(dāng)?shù)慕M件粒度
在項(xiàng)目開發(fā)中,可能你會(huì)看到懶同事一個(gè)幾千行的文件,卻只有一個(gè)組件,render 函數(shù)里面又臭又長(zhǎng),讓人實(shí)在沒(méi)有讀下去的欲望。在寫 React 組件中,我見過(guò)最恐怖的代碼是這樣的:
function App() {
let renderHeader,
renderBody,
renderHTML
if (xxxxx) {
renderHeader = <h1>xxxxx</h1>
} else {
renderHeader = <header>xxxxx</header>
}
if (yyyyy) {
renderBody = (
<div className="main">
yyyyy
</div>
)
} else {
...
}
if (...) {
renderHTML = ...
} else {
...
}
return renderHTML
}
當(dāng)我看到這個(gè)組件的時(shí)候,我想要搞清楚他最終都渲染了什么。看到 return 的時(shí)候發(fā)現(xiàn)只返回了 renderHTML,而這個(gè) renderHTML 卻是經(jīng)過(guò)一系列的判斷得來(lái)的,相信沒(méi)人愿意去讀這樣的代碼。
拆分 render
我們可以將 render 方法進(jìn)行一系列的拆分,創(chuàng)建一系列的子 render 方法,將原來(lái)大的 render 進(jìn)行分割。
class App extends Component {
renderHeader() {}
renderBody() {}
render() {
return (
<>
{this.renderHeader()}
{this.renderBody()}
</>
)
}
}
當(dāng)然最好的方式還是拆分為更細(xì)粒度的組件,這樣不僅方便測(cè)試,也可以配合 memo/PureComponent/shouldComponentUpdate 做進(jìn)一步性能優(yōu)化。
const Header = () => {}
const Body = () => {}
const App = () => (
<>
<Header />
<Body />
</>
)
復(fù)用相同部分
對(duì)于可復(fù)用的組件部分,我們要盡量做到復(fù)用。這部分可以是狀態(tài)邏輯,也可以是 HTML 結(jié)構(gòu)。以下面這個(gè)組件為例,這樣寫看上去的確沒(méi)有大問(wèn)題。
class App extends Component {
state = {
on: props.initial
}
toggle = () => {
this.setState({
on: !this.state.on
})
}
render() {
<>
<Button type="primary" onClick={this.toggle}> {this.on ? "Close" : "Open"} Modal </Button>
<Modal visible={this.state.on} onOk={this.toggle} onCancel={this.toggle}/>
</>
}
}
但如果我們有個(gè) checkbox 的按鈕,它也會(huì)有開關(guān)兩種狀態(tài),完全可以復(fù)用上面的 this.state.on 和 this.toggle,那該怎么辦呢?

就像上一節(jié)講的一樣,我們可以利用 render props 來(lái)實(shí)現(xiàn)狀態(tài)邏輯復(fù)用。
// 狀態(tài)提取到 Toggle 組件里面
class Toggle extends Component {
constructor(props) {
this.state = {
on: props.initial
}
}
toggle = () => {
this.setState({
on: !this.state.on
})
}
render() {
return this.props.children({
on: this.state.on,
toggle: this.toggle
})
}
}
// Toggle 結(jié)合 Modal
function App() {
return (
<Toggle initial={false}>
{({ on, toggle }) => (
<>
<Button type="primary" onClick={toggle}> Open Modal </Button>
<Modal visible={on} onOk={toggle} onCancel={toggle}/>
</>
)}
</Toggle>
)
}
// Toggle 結(jié)合 CheckBox
function App() {
return (
<Toggle initial={false}>
{({ on, toggle }) => (
<CheckBox visible={on} toggle={toggle} />
)}
</Toggle>
)
}
或者我們可以用上節(jié)講過(guò)的 React Hooks 來(lái)抽離這個(gè)通用狀態(tài)和方法。
const useToggle = (initialState) => {
const [state, setState] = useState(initialState);
const toggle = () => setState(!state);
return [state, toggle]
}
除了這種狀態(tài)邏輯復(fù)用外,還有一種 HTML 結(jié)構(gòu)復(fù)用。比如有兩個(gè)頁(yè)面,他們都有頭部、輪播圖、底部按鈕,大體上的樣式和布局也一致。如果我們對(duì)每個(gè)頁(yè)面都寫一遍,難免會(huì)有一些重復(fù),像這種情況我們就可以利用高階組件來(lái)復(fù)用相同部分的 HTML 結(jié)構(gòu)。
const PageLayoutHoC = (WrappedComponent) => {
return class extends Component {
render() {
const {
title,
sliderData,
onSubmit,
submitText
...props
} = this.props
return (
<div className="main">
<Header title={title} />
<Slider dataList={sliderData} />
<WrappedComponent {...props} />
<Button onClick={onSubmit}>{submitText}</Button>
</div>
)
}
}
}
組件松耦合
松耦合一般是和緊耦合相對(duì)立的,兩者的區(qū)別在于:
組件之間彼此依賴方法和數(shù)據(jù),這種叫做緊耦合。
組件之間沒(méi)有彼此依賴,一個(gè)組件的改動(dòng)不會(huì)影響到其他組件,這種叫做松耦合。
很明顯,我們?cè)陂_發(fā)中應(yīng)當(dāng)使用松耦合的方式來(lái)設(shè)計(jì)組件,這樣不僅提供了復(fù)用性,還方便了測(cè)試。
我們來(lái)看一下簡(jiǎn)單的緊耦合反面例子:
class App extends Component {
state = { count: 0 }
increment = () => {
this.setState({
count: this.state.count + 1
})
}
decrement = () => {
this.setState({
count: this.state.count - 1
})
}
render() {
return <Counter count={this.state.count} parent={this} />
}
}
class Counter extends Component {
render() {
return (
<div className="counter">
<button onClick={this.props.parent.increment}>
Increase
</button>
<div className="count">{this.props.count}</div>
<button onClick={this.props.parent.decrement}>
Decrease
</button>
</div>
)
}
}可以看到上面的 Counter 依賴了父組件的兩個(gè)方法,一旦父組件的
increment和decrement改了名字呢?那 Counter 組件只能跟著來(lái)修改,破壞了 Counter 的獨(dú)立性,也不好拿去復(fù)用。所以正確的方式就是,組件之間的耦合數(shù)據(jù)我們應(yīng)該通過(guò) props 來(lái)傳遞,而非傳遞一個(gè)父組件的引用過(guò)來(lái)。
class App extends Component {
state = { count: 0 }
increment = () => {
this.setState({
count: this.state.count + 1
})
}
decrement = () => {
this.setState({
count: this.state.count - 1
})
}
render() {
return <Counter count={this.state.count} increment={this.increment} decrement={this.decrement}/>
}
}
class Counter extends Component {
render() {
return (
<div className="counter">
<button onClick={this.props.increment}>
Increase
</button>
<div className="count">{this.props.count}</div>
<button onClick={this.props.decrement}>
Decrease
</button>
</div>
)
}
}
避免通過(guò) ref 來(lái) setState
對(duì)于需要在組件外面通知組件更新的操作,盡量不要在外面通過(guò) ref 來(lái)調(diào)用組件的 setState,比如下面這種:
class Counter extends React.Component {
state = {
count: 0
}
render() {
return (
<div>{this.state.count}</div>
);
}
}
class App {
ref = React.createRef();
mount() {
ReactDOM.render(<Counter ref={this.ref} />, document.querySelector('#app'));
}
increment() {
this.ref.current.setState({
count: this.ref.current.state + 1
});
}
}
對(duì)于組件 Counter 來(lái)說(shuō),并不知道外面會(huì)直接通過(guò) ref 來(lái)調(diào)用 setState。如果以后發(fā)現(xiàn) count 突然就變化了,也不知道是哪里出了問(wèn)題。
對(duì)于這種情況我們可以在組件里面注冊(cè)事件,在外面發(fā)送事件來(lái)通知。這樣我們可以明確知道組件監(jiān)聽了外部的事件。
class Counter extends React.Component {
state = {
count: 0
}
componentDidMount() {
event.on('increment', this.increment);
}
componentWillUnmount() {
event.off('increment', this.increment);
}
increment = () => {
this.setState({
count: this.state.count + 1
});
}
render() {
return (
<div>{this.state.count}</div>
);
}
}
class App {
ref = React.createRef();
mount() {
ReactDOM.render(<Counter ref={this.ref} />, document.querySelector('#app'));
}
increment() {
event.trigger('increment');
}
}
如果在函數(shù)組件里面,React 提供了 useImperativeHandle 這個(gè) Hook,配合 forwardRef 可以支持傳遞函數(shù)組件內(nèi)部的方法給外部使用。
import React, { useState, useImperativeHandle, forwardRef } from 'react';
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);
useImperativeHandle(
ref,
() => ({
increment: () => {
setCount(count + 1);
}
})
);
});
class App {
ref = React.createRef();
mount() {
ReactDOM.render(<Counter ref={this.ref} />, document.querySelector('#app'));
}
increment() {
this.ref.current.increment();
}
}
數(shù)據(jù)解耦
我們的組件不應(yīng)該依賴于特定格式的數(shù)據(jù),組件中避免出現(xiàn) data.xxx 這種數(shù)據(jù)。你可以通過(guò) render props 的模式將要處理的對(duì)象傳到外面,讓使用者自行操作。舉個(gè)栗子:我設(shè)計(jì)了一個(gè) Tabs 組件,我需要?jiǎng)e人給我傳入這樣的結(jié)構(gòu):
[
{
key: 'Tab1',
content: '這是 Tab 1',
title: 'Tab1'
},
{},
{}
]
這個(gè) key 是我們用來(lái)關(guān)聯(lián)所有 Tab 和當(dāng)前選中的 Tab 關(guān)系的。比如我選中了 Tab1,當(dāng)前的 Tab1 會(huì)有高亮顯示,就通過(guò) key 來(lái)關(guān)聯(lián)。而我們的組件可能會(huì)這樣設(shè)計(jì):
<Tabs data={data} currentTab={'Tab1'} />
這樣的設(shè)計(jì)不夠靈活,一個(gè)是耦合了數(shù)據(jù)的結(jié)構(gòu),大多數(shù)時(shí)候,接口不會(huì)返回上圖中的 key 這種字段,title 也很可能沒(méi)有,這就需要我們自己做一下數(shù)據(jù)格式化。另一個(gè)是封裝了 DOM 結(jié)構(gòu),如果我們想定制化傳入的 Tab 結(jié)構(gòu)就會(huì)變得非常困難。我們不妨轉(zhuǎn)換一下思路,當(dāng)設(shè)計(jì)一個(gè)通用組件的時(shí)候,一定要只有一個(gè)組件嗎?一定要把數(shù)據(jù)傳給組件嗎?那么來(lái)一起看看業(yè)界知名的組件庫(kù) Ant Design 是如何設(shè)計(jì) Tabs 組件的。
<Tabs defaultActiveKey="1" onChange={callback}>
<TabPane tab="Tab 1" key="1">
Content of Tab Pane 1
</TabPane>
<TabPane tab="Tab 2" key="2">
Content of Tab Pane 2
</TabPane>
<TabPane tab="Tab 3" key="3">
Content of Tab Pane 3
</TabPane>
</Tabs>
Ant Design 將數(shù)據(jù)和結(jié)構(gòu)進(jìn)行了解耦,我們不再傳列表數(shù)據(jù)給 Tabs 組件,而是自行在外部渲染了所有的 TabPane,再將其作為 Children 傳給 Tabs,這樣的好處就是組件的結(jié)構(gòu)更加靈活,TabPane 里面隨便傳什么結(jié)構(gòu)都可以。
結(jié)構(gòu)自由
一個(gè)好的組件,結(jié)構(gòu)應(yīng)當(dāng)是靈活自由的,不應(yīng)該對(duì)其內(nèi)部結(jié)構(gòu)做過(guò)度封裝。我們上面講的 Tabs 組件其實(shí)就是結(jié)構(gòu)自由的一種代表。
考慮到這樣一種業(yè)務(wù)場(chǎng)景,我們頁(yè)面上有多個(gè)輸入框,但這些輸入框前面的 Icon 都是不一樣的,代表著不同的含義。我相信肯定不會(huì)有人會(huì)對(duì)每個(gè) Icon 都實(shí)現(xiàn)一個(gè) Input 組件。

你可能會(huì)想到我們可以把圖片的地址當(dāng)做 props 傳給組件,這樣不就行了嗎?但萬(wàn)一前面不是 Icon 呢?而是一個(gè)文字、一個(gè)符號(hào)呢?
那我們是不是可以把元素當(dāng)做 props 傳給組件呢?組件來(lái)負(fù)責(zé)渲染,但渲染后長(zhǎng)什么樣還是使用者來(lái)控制的。這就是 Ant Design 的實(shí)現(xiàn)思路。

在前面數(shù)據(jù)解耦中我們就講過(guò)了類似的思路,實(shí)際上數(shù)據(jù)解耦和結(jié)構(gòu)自由是相輔相成的。在設(shè)計(jì)一個(gè)組件的時(shí)候,很多人往往會(huì)陷入一種怪圈,那就是我該怎么才能封裝更多功能?怎么才能兼容不同的渲染?
這時(shí)候我們就不妨換一種思路,如果將渲染交給使用者來(lái)控制呢?渲染成什么樣都由用戶來(lái)決定,這樣的組件結(jié)構(gòu)是非常靈活自由的。
當(dāng)然,如果你把什么都交給用戶來(lái)渲染,這個(gè)組件的使用復(fù)雜度就大大提高了,所以我們也應(yīng)當(dāng)提供一些默認(rèn)的渲染,即使用戶什么都不傳也可以渲染默認(rèn)的結(jié)構(gòu)。
總結(jié)
組件設(shè)計(jì)是一項(xiàng)重要的工作,好的組件我們直接拿來(lái)復(fù)用可以大大提高效率,不好的組件只會(huì)增加我們的復(fù)雜度。
在組件設(shè)計(jì)的學(xué)習(xí)中,你需要多探索、實(shí)踐,多去參考社區(qū)知名的組件庫(kù),比如 Ant Design、Element UI、iview 等等,去思考他們?yōu)槭裁磿?huì)這樣設(shè)計(jì),有沒(méi)有更好的設(shè)計(jì)?如果是自己來(lái)設(shè)計(jì)會(huì)怎么樣?
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺得這篇內(nèi)容對(duì)你有幫助,我想請(qǐng)你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客 www.inode.club 讓我們一起成長(zhǎng) 點(diǎn)贊和在看就是最大的支持??
