[譯] 更可靠的 React 組件:單一職責原則

原文摘自:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/#6testableandtestedhtml

當只有惟一的緣由能改變一個組件時,該組件就是「單一職責」的react

單一職責原則(SRP - single responsibility principle)是編寫 React 組件時的基礎原則。ios

所謂職責可能指的是渲染一個列表、顯示一個時間選擇器、發起一次 HTTP 請求、描繪一幅圖表,或是懶加載一個圖片等等。組件應該只選擇一個職責去實現。當修改組件所實現的惟一職責時(如對所渲染列表中的項目數量作出限制時),組件就會所以改變。axios

爲什麼「只有一個改變的緣由」如此重要呢?由於這樣組件的修改就被隔離開來,變得可控了。api

單一職責限制了組件的體積,也使其聚焦於一件事。這有利於編碼,也方便了以後的修改、重用和測試。數組

舉幾個例子看看。promise

例子1:一個請求遠端數據並作出處理的組件,其惟一的改變緣由就是請求邏輯發送變化了,包括:bash

  • 服務器 URL 被修改了
  • 響應數據的格式被修改了
  • 換了一種 HTTP 請求庫
  • 其餘只關係到請求邏輯的改動

例子2:一個映射了由若干行組件造成的數組的表格組件,引發其改變的惟一緣由是映射邏輯的改變:服務器

  • 有一個限制最多渲染行數的需求,好比 25 行
  • 沒有行可渲染的時候,須要給出文字提示
  • 其餘只關係到數組和組件之間映射的改變

你的組件是否有多個職責呢?若是答案是確定的話,就應將其分割成若干單一職責的組件。session

在項目發佈以前,早期階段編寫的代碼單元會頻繁的修改。這些組件要可以被輕易的隔離並修改 -- 這正是 SRP 的題中之意。

1. 多個職責的陷阱

一個組件有多個職責的狀況常常被忽視,乍看起來,這並沒有不妥且容易理解:

  • 擼個袖子就寫起了代碼:不用區分去各類職責,也不用規劃相應的結構
  • 造成了一個大雜燴的組件
  • 不用爲相互分隔的組件間的通訊建立 props 和回調函數

這種天真爛漫的結構在編碼之處很是簡單。當應用不斷增加並變得愈來愈複雜,須要對組件修改的時候,麻煩就會出現。

有不少理由去改變一個同時擔負了多個職責的組件;那麼主要的問題就會浮現:由於一個緣由去改變組件,極可能會誤傷其餘的職責。

The pitfall of multiple responsibilities

這樣的設計是脆弱的。無心間帶來的反作用極難預知和控制。

舉個例子,<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 屬性代理和渲染劫持技術每每能幫助組件實現單一職責。

擴展閱讀:


(end)


----------------------------------------

轉載請註明出處

長按二維碼或搜索 fewelife 關注咱們哦

相關文章
相關標籤/搜索