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

江米小棗tonylua發表於2019-03-01

原文摘自: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 和回撥函式

這種天真爛漫的結構在編碼之處非常簡單。當應用不斷增長並變得越來越複雜,需要對元件修改的時候,麻煩就會出現。

有很多理由去改變一個同時擔負了多個職責的元件;那麼主要的問題就會浮現:因為一個原因去改變元件,很可能會誤傷其他的職責。

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)


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

轉載請註明出處

[譯] 更可靠的 React 元件:單一職責原則
長按二維碼或搜尋 fewelife 關注我們哦

相關文章