編寫合格的React組件

衆知React應用是一種基於組件的架構模式, 複雜的UI能夠經過一些小的組件組合起來, 站在軟件工程的角度這樣的開發方式會提升開發效率, 程序的健壯性和可維護性。javascript

但在實際組件的編寫中咱們一般會遇到一個問題: 複雜的組件每每具備多種職責, 而且組件之間的耦合性很高, 咱們越寫越複雜的組件會產生技術負債, 恐懼每一次需求的變化, 在後期維護上花費很高的時間和精力成本。java

那麼爲了解決這個問題, 咱們須要思考如下2個問題:react

  • 複雜組件如何拆分?
  • 組件之間如何通訊會下降他們的耦合性或者說依賴?

Single responsibility 原則

A component has a single responsibility when it has one reason to change.ios

Single responsibility principle (SRP) 要求一個組件只作一件事情, 單一任務, 良好的可測試性, 是編寫複雜組件的基礎。這樣當咱們需求變化時候, 咱們也只須要修改單一的組件, 不會出現連鎖反應形成的"開發信心缺失"。git

舉個實際的例子: 獲取遠程數據組件, 先分析出該組件中可能變化的點github

  • 請求地址
  • 響應的數據格式
  • 使用不一樣的HTTP庫
  • 等等

再舉個例子: 表格組件, 拿到設計圖看到設計圖上有4行3列的數據, 直接寫死4行3列是沒有智慧的, 咱們仍是先要考慮可能變化的點:編程

  • 增長行列或者減小行列
  • 空的表格如何顯示
  • 請求到的表格數據格式發生變化

有些人會以爲是否是想太多? 不少時候人們一般會忽視SRP, 起初看來確實寫在一塊兒也沒有糟更重要的緣由是由於寫的快, 由於不須要去思考組件結構和通訊之類的事情, 可是在產品需求變化頻繁的今天, 惟有良好的組件化設計才能保障產品迭代的速度與質量。redux

實踐: 拆分一個Weather組件

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
       })
     });
   }
}
複製代碼

明顯這個組件的設計違反了SRP, 先讓咱們分析一下Weather組件中有哪些會變化的點:axios

  • 網絡請求部分可能會變, 好比服務器地址, 響應的數據格式
  • UI展現的邏輯可能會變, 有可能之後要增長其餘天氣信息

爲了擁抱以上的變化咱們能夠將Weather拆分紅2個組件: WeatherFetchWeatherInfo, 分別用來處理網絡請求和UI信息的展現。api

拆分爲的組件應該是這樣的

// Weather
class Weather extends Component {
   render() {
     return <WeatherFetch />;
   }
}

// WeatherFetch
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
       });
     });
   }
}

// WeatherInfo
function WeatherInfo({ temperature, windSpeed }) {
   return (
     <div className="weather">
       <div>Temperature: {temperature}°C</div>
       <div>Wind: {windSpeed} km/h</div>
     </div>
   );
}
複製代碼

HOC的應用

Higher order component is a function that takes one component and returns a new component.

有些時候拆分組件也不必定是萬能的, 好比想給一個組件上額外添加一些參數。 這時咱們可以使用高階組件(HOC)

HOC最經典的使用場景是 props proxy , 即包裹一個組, 爲其添加props或者修改已經存在的props, 並返回一個新組件。

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); 複製代碼

Props proxy

寫一個最基礎的表單, 一個input, 一個button

class PersistentForm extends 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 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() { localStorage.setItem('inputValue', this.state.inputValue); } } 複製代碼

咱們如今應該能本能的感受出上面的代碼哪裏有問題, 這個組件作了2件事情違反了SRP: input的點擊事件將用戶輸入的內容存儲到state, button的點擊事件將state存儲到localStorage, 如今讓咱們拆分這兩件事情。

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); } } 複製代碼

改爲這樣的話咱們須要一個父組件來提供存儲到localStorage的功能, 這時候HOC就派上用場了, 咱們經過HOC爲剛纔的組件添加存儲到localStorage的功能。

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); } } } } 複製代碼

最後把他們變爲一個組件, 搞定!

const LocalStoragePersistentForm 
  = withPersistence('key', localStorage)(PersistentForm);

const instance = <LocalStoragePersistentForm />;
複製代碼

經過HOC添加的localStora存儲功能複用起來無比的方便, 好比如今有另外一個表單須要使用localStorage存儲功能, 咱們只須要修改傳遞參數便可

const LocalStorageMyOtherForm
  = withPersistence('key', localStorage)(MyOtherForm);

const instance = <LocalStorageMyOtherForm />; 複製代碼

Render highjacking

除了 props proxy 以外, HOC還有一個經典應用場景是 render highjacking

function withModifiedChildren(WrappedComponent) {
  return class ModifiedChildren extends WrappedComponent {
    render() {
      const rootElement = super.render();
      const newChildren = [
        ...rootElement.props.children, 
        // Insert a new child:
        <div>New child</div>
      ];
      return cloneElement(
        rootElement, 
        rootElement.props, 
        newChildren
      );
    }
  }
}
const MyNewComponent = withModifiedChildren(MyComponent);
複製代碼

props proxy 不一樣的是, render highjacking 能夠在不 入侵 原組件的狀況下, 修改其UI渲染。

Encapsulated 封裝

An encapsulated component provides props to control its behavior while not exposing its internal structure.

Coupling (耦合) 是軟件工程中不得不考慮的問題之一, 如何解耦或者下降耦合也是軟件開發工程師遇到的難題。

低耦合如上圖, 當你須要修改系統的一個部分時可能只會影響一小部分其餘系統, 而下面這種高耦合是讓開發人員對軟件質量失去信心的原罪, 改一處可能瞬間爆炸。

隱藏信息

一個組件可能要操做refs, 可能有state, 可能使用了生命週期方法, 這些具體的實現細節其餘組件是不該該知道的, 即: 組件之間須要隱藏實現細節, 這也是組件拆分的標準之一。

通訊

組件拆分後, 原來直接獲取的數據, 如今就要依靠通訊來獲取, 雖然更加繁瑣, 可是在可讀性和維護性上帶來的好處遠遠大於它的複雜性的。React組件之間通訊的主要手段是:props

// 使用props通訊
<Message text="Hello world!" modal={false} />;

// 固然也能夠傳遞複雜數據
<MoviesList items={['Batman Begins', 'Blade Runner']} />
<input type="text" onChange={handleChange} />

// 固然也能夠直接傳遞組件(ReactNode)
function If({ component: Component, condition }) {
  return condition ? <Component /> : null;
}
<If condition={false} component={LazyComponent} />  

複製代碼

Composable 組合

A composable component is created from the composition of smaller specialized components.

Medium上有一篇文章叫作 組合是React的心臟 (Composition is the heart of React), 由於它發揮瞭如下3個優勢:

  • 單一責任
  • 複用性
  • 靈活性

接下來舉🌰說明

單一責任

const app = (
  <Application> <Header /> <Sidebar> <Menu /> </Sidebar> <Content> <Article /> </Content> <Footer /> </Application>
);
複製代碼

app這個組件中的每一個組件都只負責它該負責的部分, 好比Application只是一個應用的容器, <Footer />負責渲染頁面底部的信息, 頁面結構一目瞭然。

複用性

提取出不一樣組件中的相同代碼是提高維護性的最佳實踐, 好比

const instance1 = (
  <Composed1> <Piece1 /> <Common /> </Composed1>
);
const instance2 = (
  <Composed2> <Common /> <Piece2 /> </Composed2>
);
複製代碼

靈活性

組合的特性可讓編寫React代碼時候很是靈活, 當組件組合時須要經過props進行通訊, 好比 父組件能夠經過children prop 來接收子組件。

當咱們想爲移動和PC展現不一樣的UI時咱們一般會寫成如下這樣:

render(){
    return (<div> {Utils.isMobile() ? <div>Mobile detected!</div> : <div>Not a mobile device</div>} </div>) 
}
複製代碼

At first glance, it harmeless, 可是它明顯將判斷是否時移動端的邏輯與組件耦合了。這不是在拼積木, 這是在"入侵"積木!

讓咱們拆分判斷邏輯與UI試圖, 而且看看React如何使用 children prop 靈活的進行數據通訊。

function ByDevice({ children: { mobile, other } }) {
  return Utils.isMobile() ? mobile : other;
}

<ByDevice>{{
  mobile: <div>Mobile detected!</div>,
  other:  <div>Not a mobile device</div>
}}</ByDevice>
複製代碼

Reusable 複用

A reusable component is written once but used multiple times.

軟件世界常常犯的錯誤就是 reinventing the wheel (造輪子), 好比在項目中編寫了已經存在的工具或者庫, React組件也是同樣的, 咱們要考慮代碼的複用性, 儘量的下降重複的代碼和造輪子的事情發生, 是咱們代碼"寫一次, 可使用不少次"。

Reuse of a component actually means the reuse of its responsibility implementation.

在這裏能夠找到不少高質量的React組件, 避免咱們造輪子: Absolutely Awesome React Components & Libraries

經過閱讀上面這些可複用的高質量React組件的源碼咱們會收穫到更多複用的思想以及一些API的使用技巧好比:React.cloneElement等等。

Pure Component

Pure Component是從函數式編程延伸出來的概念, pure function always returns the same output for given the same input. 好比

const sum= (a, b) => a + b // sum(1, 1) // => 2

給相同的參數永遠會獲得相同的結果, 當一個函數內部使用全局變量的話那麼那個函數可能會變得不那麼"純"(impure)。

let said = false;

function sayOnce(message) {
  if (said) {
    return null;
  }
  said = true;
  return message;
}

sayOnce('Hello World!'); // => 'Hello World!'
sayOnce('Hello World!'); // => null
複製代碼

impure函數就是給定相同的參數確有可能獲得不一樣的結果, 那麼組件也是一個道理, pure component組件會讓咱們對本身的組件質量充滿信心, 可是不可能全部的組件咱們均可以寫成 pure component. 好比咱們的組件裏面有一個<Input />, 那麼咱們的組件不接受任何參數, 可是每次均可能產生不同的結果。

真實世界中太多impure的事情, 好比全局狀態, 可改變的全局狀態貽害不淺, 數據被意外改變致使意外的行爲, 若是實在要使用全局狀態, 那麼考慮使用Redux吧。除了全局狀態致使impure的東西還有不少好比網絡請求, local storage等等, 那如何讓咱們的組件儘量的變成pure component呢?

答案: purification

下面讓咱們實踐一下如何將impure中pure的部分過濾出來, 成爲一個almost pure組件, 用前面獲取天氣的那個例子, 咱們把網絡請求這種impure的東西使用redux-saga過濾出來

這是以前的代碼:

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 }) }); } } 複製代碼

改造後

// 定義action
export function fetch() {
  return {
    type: 'FETCH'
  };
}

// 定義dispatch handler
import { call, put, takeEvery } from 'redux-saga/effects';

export default function* () {
  yield takeEvery('FETCH', function* () {
    const response = yield call(axios.get, 'http://weather.com/api');
    const { temperature, windSpeed } = response.data.current;
    yield put({
      type: 'FETCH_SUCCESS',
      temperature,
      windSpeed
    });
  });
}

// 定義reducer 
const initialState = { temperature: 'N/A', windSpeed: 'N/A' };

export default function(state = initialState, action) {
  switch (action.type) {
    case 'FETCH_SUCCESS': 
      return {
        ...state,
        temperature: action.temperature,
        windSpeed: action.windSpeed
      };
    default:
      return state;
  }
}

// 使用redux鏈接起來
import { connect } from 'react-redux';
import { fetch } from './action';

export class WeatherFetch extends Component {
   render() {
     const { temperature, windSpeed } = this.props;
     return (
       <WeatherInfo temperature={temperature} windSpeed={windSpeed} /> ); } componentDidMount() { this.props.fetch(); } } function mapStateToProps(state) { return { temperature: state.temperate, windSpeed: state.windSpeed }; } export default connect(mapStateToProps, { fetch }); 複製代碼

將impure的組件改爲almost pure的組件可讓咱們更瞭解程序的行爲, 也將變得更易於測試

import assert from 'assert';
import { shallow, mount } from 'enzyme';
import { spy } from 'sinon';
// Import the almost-pure version WeatherFetch
import { WeatherFetch } from './WeatherFetch';
import WeatherInfo from './WeatherInfo';

describe('<WeatherFetch />', function() {
  it('should render the weather info', function() {
    function noop() {}
    const wrapper = shallow(
      <WeatherFetch temperature="30" windSpeed="10" fetch={noop} />
    );
    assert(wrapper.contains(
      <WeatherInfo temperature="30" windSpeed="10" />
    ));
  });

  it('should fetch weather when mounted', function() {
    const fetchSpy = spy();
    const wrapper = mount(
     <WeatherFetch temperature="30" windSpeed="10" fetch={fetchSpy}/>
    );
    assert(fetchSpy.calledOnce);
  });
});
複製代碼

其實上面的almost pure組件仍然有優化的空間, 咱們能夠藉助一些工具庫讓它成爲pure component

import { connect } from 'react-redux';  
import { compose, lifecycle } from 'recompose';
import { fetch } from './action';

export function WeatherFetch({ temperature, windSpeed }) {  
   return (
     <WeatherInfo temperature={temperature} windSpeed={windSpeed} /> ); } function mapStateToProps(state) { return { temperature: state.temperate, windSpeed: state.windSpeed }; } export default compose( connect(mapStateToProps, { fetch }), lifecycle({ componentDidMount() { this.props.fetch(); } }) )(WeatherFetch); 複製代碼

可測試性

A tested component is verified whether it renders the expected output for a given input. A testable component is easy to test.

如何確保組件按照咱們的指望工做, 一般咱們會改下數據或者條件之類的而後在瀏覽器中看結果, 稱之爲手動驗證。 這樣手動驗證有一些缺點:

  1. 臨時修改代碼爲了驗證容易出錯
  2. 每次修改代碼 每次驗證很低效

所以, 咱們須要須要編寫一些unit tests來幫助咱們測試組件, 可是編寫unit tests的前提是, 咱們的組件是可測試的, 一個不可測試的組件絕對是設計不良的。

A component that is untestable or hard to test is most likely badly designed.

組件變得難以測試有不少因素, 好比太多的props, 高度耦合, 全局變量等等, 下面經過一個例子讓咱們理解如何編寫可測試組件。

編寫一個Controls組件, 目的是實現一個計數器, 點擊Increase則加1, 點擊Decrease則減1, 先來一個錯誤的設計

<Control parent={ConponentName}
複製代碼

假設咱們是這樣使用的, 意圖是咱們傳入一個父組件, 點擊Control的加減操做會修改父組件的state值

import assert from 'assert';
import { shallow } from 'enzyme';

class Controls extends Component {
  render() {
    return (
      <div className="controls"> <button onClick={() => this.updateNumber(+1)}> Increase </button> <button onClick={() => this.updateNumber(-1)}> Decrease </button> </div>
    );
  }
  updateNumber(toAdd) {
    this.props.parent.setState(prevState => ({
      number: prevState.number + toAdd       
    }));
  }
}

class Temp extends Component {
  constructor(props) {
    super(props);
    this.state = { number: 0 };
  }
  render() {
    return null;
  }
}

describe('<Controls />', function() {
  it('should update parent state', function() {
    const parent = shallow(<Temp/>);
    const wrapper = shallow(<Controls parent={parent} />); assert(parent.state('number') === 0); wrapper.find('button').at(0).simulate('click'); assert(parent.state('number') === 1); wrapper.find('button').at(1).simulate('click'); assert(parent.state('number') === 0); }); }); 複製代碼

因爲咱們設計的Controls組件與父組件依賴很強, 致使咱們編寫單元測試很複雜, 這時咱們就應該思考重構這個Controls提升它的可測試性了。

import assert from 'assert';
import { shallow } from 'enzyme';
import { spy } from 'sinon';

function Controls({ onIncrease, onDecrease }) {
  return (
    <div className="controls"> <button onClick={onIncrease}>Increase</button> <button onClick={onDecrease}>Decrease</button> </div>
  );
}

describe('<Controls />', function() {
  it('should execute callback on buttons click', function() {
    const increase = sinon.spy();
    const descrease = sinon.spy();
    const wrapper = shallow(
      <Controls onIncrease={increase} onDecrease={descrease} /> ); wrapper.find('button').at(0).simulate('click'); assert(increase.calledOnce); wrapper.find('button').at(1).simulate('click'); assert(descrease.calledOnce); }); }); 複製代碼

重構後咱們的組件使用方法變爲 <Controls onIncrease={increase} onDecrease={descrease} />, 這樣的使用方式完全解耦了Controls和父組件之間的關係, 即: Controls只負責按鈕UI的渲染。

可讀性

A meaningful component is easy to understand what it does.

代碼的可讀性對於產品迭代的重要性是不可忽視的, obscured code不只會讓維護者頭疼, 甚至咱們本身也沒法理解代碼的意圖。曾經有一個有趣的統計, 編程工做是由: 75%的讀代碼(理解) + 20%的修改現有代碼 + 5%新代碼組成的。

self-explanatory code無疑是提升代碼可讀性最直接最好的方法

舉一個例子:

// <Games> renders a list of games
// "data" prop contains a list of game data
function Games({ data }) {
  // display up to 10 first games
  const data1 = data.slice(0, 10);
  // Map data1 to <Game> component
  // "list" has an array of <Game> components
  const list = data1.map(function(v) {
    // "v" has game data
    return <Game key={v.id} name={v.name} />;
  });
  return <ul>{list}</ul>;
}

<Games 
   data=[{ id: 1, name: 'Mario' }, { id: 2, name: 'Doom' }] 
/>
複製代碼

下面讓咱們重構這段代碼, 使它能夠 self-explanatoryself-documenting .

const GAMES_LIMIT = 10;

function GamesList({ items }) {
  const itemsSlice = items.slice(0, GAMES_LIMIT);
  const games = itemsSlice.map(function(gameItem) {
    return <Game key={gameItem.id} name={gameItem.name} />;
  });
  return <ul>{games}</ul>;
}

<GamesList 
  items=[{ id: 1, name: 'Mario' }, { id: 2, name: 'Doom' }]
/>
複製代碼

一個可讀性良好的React組件應該作到: 經過讀nameprops就能夠看出這段代碼的意圖。

寫在最後的

即便編寫出了自我感受良好的組件, 咱們也該在一次一次迭代中去 Do continuous improvement, 正如做家William Zinsse說過一句話

rewriting is the essence of writing. I pointed out that professional writers rewrite their sentences over and over and then rewrite what they have rewritten.

重構, 編寫高質量, 可擴展, 可維護的應用是每一個開發人員的追求。

本文參考: 7 Architectural Attributes of a Reliable React Component

既然都看到這裏了, 點個贊吧 💗

相關文章
相關標籤/搜索