原文摘自:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/#6testableandtested
當只有唯一的原因能改變一個元件時,該元件就是“單一職責”的
單一職責原則(SRP - single responsibility principle)是編寫 React 元件時的基礎原則。
所謂職責可能指的是渲染一個列表、顯示一個時間選擇器、發起一次 HTTP 請求、描繪一幅圖表,或是懶載入一個圖片等等。元件應該只選擇一個職責去實現。當修改元件所實現的唯一職責時(如對所渲染列表中的專案數量做出限制時),元件就會因此改變。
為何“只有一個改變的原因”如此重要呢?因為這樣元件的修改就被隔離開來,變得可控了。
單一職責限制了元件的體積,也使其聚焦於一件事。這有利於編碼,也方便了之後的修改、重用和測試。
舉幾個例子看看。
例子1:一個請求遠端資料並做出處理的元件,其唯一的改變原因就是請求邏輯傳送變化了,包括:
- 伺服器 URL 被修改了
- 響應資料的格式被修改了
- 換了一種 HTTP 請求庫
- 其他只關係到請求邏輯的改動
例子2:一個對映了由若干行元件形成的陣列的表格元件,引起其改變的唯一原因是對映邏輯的改變:
- 有一個限制最多渲染行數的需求,比如 25 行
- 沒有行可渲染的時候,需要給出文字提示
- 其他只關係到陣列和元件之間對映的改變
你的元件是否有多個職責呢?如果答案是肯定的話,就應將其分割成若干單一職責的元件。
在專案釋出之前,早期階段編寫的程式碼單元會頻繁的修改。這些元件要能夠被輕易的隔離並修改 -- 這正是 SRP 的題中之意。
1. 多個職責的陷阱
一個元件有多個職責的情況經常被忽視,乍看起來,這並無不妥且容易理解:
- 擼個袖子就寫起了程式碼:不用區分去各種職責,也不用規劃相應的結構
- 形成了一個大雜燴的元件
- 不用為相互分隔的元件間的通訊建立 props 和回撥函式
這種天真爛漫的結構在編碼之處非常簡單。當應用不斷增長並變得越來越複雜,需要對元件修改的時候,麻煩就會出現。
有很多理由去改變一個同時擔負了多個職責的元件;那麼主要的問題就會浮現:因為一個原因去改變元件,很可能會誤傷其他的職責。
這樣的設計是脆弱的。無意間帶來的副作用極難預知和控制。
舉個例子,<ChartAndForm>
負責繪製圖表,同時還負責處理為圖表提供資料的表單。那麼 <ChartAndForm>
就有了兩個改變的原因:繪圖和表單。
當改變表單域的時候(如將 <input>
改為 <select>
),就有可能無意間破壞了圖表的渲染。此外圖表的實現也無法複用,因為它耦合了表單的細節。
要解決掉多職責的問題,需要將<ChartAndForm>
分割成 <Chart>
和 <Form>
兩個元件。分別負責單一的職責:繪製圖表或相應的處理表單。兩個元件之間的通訊通過 props 完成。
多職責問題的極端情況被稱為“反模式的上帝元件”。一個上帝元件恨不得要知道應用中的所有事情,通常你會見到這種元件被命名為<Application>
、<Manager>
、<BigContainer>
或是<Page>
,並有超過 500 行的程式碼。
對於上帝元件,應通過拆分和組合使其符合 SRP。
2. 案例學習:讓元件具有單一職責
想象有這樣一個元件,其向指定的伺服器傳送一個 HTTP 請求以查詢當前天氣。當請求成功後,同樣由該元件使用響應中的資料顯示出天氣狀況。
import axios from 'axios';
// 問題:一個元件具有多個職責
class Weather extends Component {
constructor(props) {
super(props);
this.state = { temperature: 'N/A', windSpeed: 'N/A' };
}
render() {
const { temperature, windSpeed } = this.state;
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windSpeed}km/h</div>
</div>
);
}
componentDidMount() {
axios.get('http://weather.com/api').then(function(response) {
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
})
});
}
}
複製程式碼
每當處理此類問題時,問一下自己:我是不是得把元件分割成更小的塊呢?決定元件如何根據其職責發生改變,就能為以上問題提供最好的答案。
這個天氣元件有兩個原因去改變:
- componentDidMount() 中的請求邏輯:服務端 URL 或響應格式可能會被修改
- render() 中的天氣視覺化形式:元件顯示天氣的方式可能會改變很多次
解決之道是將 <Weather>
分割成兩個元件,其中每個都有自己的唯一職責。將其分別命名為 <WeatherFetch>
和 <WeatherInfo>
。
第一個元件 <WeatherFetch>
負責獲取天氣、提取響應資料並將之存入 state。只有 fetch 邏輯會導致其改變:
import axios from 'axios';
// 解決方案:元件只負責遠端請求
class WeatherFetch extends Component {
constructor(props) {
super(props);
this.state = { temperature: 'N/A', windSpeed: 'N/A' };
}
render() {
const { temperature, windSpeed } = this.state;
return (
<WeatherInfo temperature={temperature} windSpeed={windSpeed} />
);
}
componentDidMount() {
axios.get('http://weather.com/api').then(function(response) {
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
});
});
}
}
複製程式碼
這種結果帶來了什麼好處呢?
舉例來說,你可能會喜歡用 async/await 語法取代 promise 來處理伺服器響應。這就是一種造成 fetch 邏輯改變的原因:
// 改變的原因:用 async/await 語法
class WeatherFetch extends Component {
// ..... //
async componentDidMount() {
const response = await axios.get('http://weather.com/api');
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
});
}
}
複製程式碼
因為 <WeatherFetch>
只會因為 fetch 邏輯而改變,所以對其的任何修改都不會影響其他的事情。用 async/await 就不會直接影響天氣顯示的方式。
而 <WeatherFetch>
渲染了 <WeatherInfo>
,後者只負責顯示天氣,只有視覺方面的理由會造成改變:
// 解決方案:元件職責只是顯示天氣
function WeatherInfo({ temperature, windSpeed }) {
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windSpeed} km/h</div>
</div>
);
}
複製程式碼
將 <WeatherInfo>
中的 "Wind: 0 km/h" 改為顯示 "Wind: calm":
// Reason to change: handle calm wind
function WeatherInfo({ temperature, windSpeed }) {
const windInfo = windSpeed === 0 ? 'calm' : `${windSpeed} km/h`;
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windInfo}</div>
</div>
);
}
複製程式碼
同樣,對 <WeatherInfo>
的這項改變是獨立的,不會影響到 <WeatherFetch>
。
<WeatherFetch>
和 <WeatherInfo>
各司其職。每個元件的改變對其他的元件微乎其微。這就是單一職責原則的強大之處:修改被隔離開,從而對系統中其他元件的影響是微小而可預期的。
3. 案例學習:HOC 風格的單一職責原則
將分割後的元件按照職責組合在一起並不總是能符合單一職責原則。另一種被稱作高階元件(HOC - Higher order component)的有效方式可能會更適合:
HOC 就是一個以某元件作為引數並返回一個新元件的函式
HOC 的一個常見用途是為被包裹的元件新增額外的 props 或修改既有的 props。這項技術被稱為屬性代理(props proxy):
function withNewFunctionality(WrappedComponent) {
return class NewFunctionality extends Component {
render() {
const newProp = 'Value';
const propsProxy = {
...this.props,
// Alter existing prop:
ownProp: this.props.ownProp + ' was modified',
// Add new prop:
newProp
};
return <WrappedComponent {...propsProxy} />;
}
}
}
const MyNewComponent = withNewFunctionality(MyComponent);
複製程式碼
甚至可以通過替換被包裹元件渲染的元素來形成新的 render 機制。這種 HOC 技術被稱為渲染劫持(render highjacking):
function withModifiedChildren(WrappedComponent) {
return class ModifiedChildren extends WrappedComponent {
render() {
const rootElement = super.render();
const newChildren = [
...rootElement.props.children,
<div>New child</div> //插入新 child
];
return cloneElement(
rootElement,
rootElement.props,
newChildren
);
}
}
}
const MyNewComponent = withModifiedChildren(MyComponent);
複製程式碼
如果想深入學習 HOC,可以閱讀文末推薦的文章。
下面跟隨一個例項來看看 HOC 的屬性代理技術如何幫助我們實現單一職責。
<PersistentForm>
元件由一個輸入框 input 和一個負責儲存到儲存的 button 組成。輸入框的值被讀取並儲存到本地。
<div id="root"></div>
複製程式碼
class PersistentForm extends React.Component {
constructor(props) {
super(props);
this.state = { inputValue: localStorage.getItem('inputValue') };
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { inputValue } = this.state;
return (
<div>
<input type="text" value={inputValue}
onChange={this.handleChange}/>
<button onClick={this.handleClick}>Save to storage</button>
</div>
)
}
handleChange(event) {
this.setState({
inputValue: event.target.value
});
}
handleClick() {
localStorage.setItem('inputValue', this.state.inputValue);
}
}
ReactDOM.render(<PersistentForm />, document.getElementById('root'));
複製程式碼
當 input 變化時,在 handleChange(event) 中更新了元件的 state;當 button 點選時,在 handleClick() 中將上述值存入本地儲存。
糟糕的是 <PersistentForm>
同時有兩個職責:管理表單資料並將 input 值存入本地。
<PersistentForm>
似乎不應該具有第二個職責,即不應關心如何直接操作本地儲存。那麼按此思路先將元件優化成單一職責:渲染表單域,並附帶事件處理函式。
class PersistentForm extends Component {
constructor(props) {
super(props);
this.state = { inputValue: props.initialValue };
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { inputValue } = this.state;
return (
<div className="persistent-form">
<input type="text" value={inputValue}
onChange={this.handleChange}/>
<button onClick={this.handleClick}>Save to storage</button>
</div>
);
}
handleChange(event) {
this.setState({
inputValue: event.target.value
});
}
handleClick() {
this.props.saveValue(this.state.inputValue);
}
}
複製程式碼
元件從屬性中接受 input 初始值 initialValue,並通過同樣從屬性中傳入的 saveValue(newValue) 函式儲存 input 的值;而這兩個屬性,是由叫做 withPersistence() 的屬性代理 HOC 提供的。
現在 <PersistentForm>
符合 SRP 了。表單的更改稱為了唯一導致其變化的原因。
查詢和存入本地儲存的職責被轉移到了 withPersistence() HOC 中:
function withPersistence(storageKey, storage) {
return function(WrappedComponent) {
return class PersistentComponent extends Component {
constructor(props) {
super(props);
this.state = { initialValue: storage.getItem(storageKey) };
}
render() {
return (
<WrappedComponent
initialValue={this.state.initialValue}
saveValue={this.saveValue}
{...this.props}
/>
);
}
saveValue(value) {
storage.setItem(storageKey, value);
}
}
}
}
複製程式碼
withPersistence() 是一個負責持久化的 HOC;它並不知道表單的任何細節,而是隻聚焦於一項工作:為被包裹的元件提供 initialValue 字串和 saveValue() 函式。
將 <PersistentForm>
和 withPersistence() 連線到一起就建立了一個新元件 <LocalStoragePersistentForm>
:
const LocalStoragePersistentForm
= withPersistence('key', localStorage)(PersistentForm);
const instance = <LocalStoragePersistentForm />;
複製程式碼
只要 <PersistentForm>
正確使用 initialValue 和 saveValue() 兩個屬性,則對自身的任何修改都無法破壞被 withPersistence() 持有的本地儲存相關邏輯,反之亦然。
這再次印證了 SRP 的功效:使修改彼此隔離,對系統中其餘部分造成的影響很小。
此外,程式碼的可重用性也增強了。換成其他 <MyOtherForm>
元件,也能實現持久化邏輯了:
const LocalStorageMyOtherForm
= withPersistence('key', localStorage)(MyOtherForm);
const instance = <LocalStorageMyOtherForm />;
複製程式碼
也可以輕易將儲存方式改為 sessionStorage:
const SessionStoragePersistentForm
= withPersistence('key', sessionStorage)(PersistentForm);
const instance = <SessionStoragePersistentForm />;
複製程式碼
對修改的隔離以及可重用性遍歷,在初始版本的多職責 <PersistentForm>
元件中都是不存在的。
在組合無法生效的情景下,HOC 屬性代理和渲染劫持技術往往能幫助元件實現單一職責。
擴充套件閱讀:
- 《用 SOLID 原則保駕 React 元件開發》: mp.weixin.qq.com/s/jxdMzD3sm…
- 《深入 React 高階元件》:mp.weixin.qq.com/s/dtlrOGTjo…
轉載請註明出處
長按二維碼或搜尋 fewelife 關注我們哦