[譯] 更可靠的 React 元件:提純

江米小棗tonylua發表於2018-06-20

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

pure、almost-pure 和 impure

一個 純元件(pure componnet) 總是針對同樣的 prop 值渲染出同樣的元素;

一個 幾乎純的元件(almost-pure compoent) 總是針對同樣的 prop 值渲染同樣的元素,並且會產生一個 副作用(side effect)

在函數語言程式設計的術語裡,一個 純函式(pure function) 總是根據某些給定的輸入返回相同的輸出。讓我們看一個簡單的純函式:

function sum(a, b) {  
  return a + b;
}
sum(5, 10); // => 15  
複製程式碼

對於給定的兩個數字,sum() 函式總是返回同樣的相加值。

一旦對相同的輸入返回不同的輸出了,一個函式就變成 非純(impure) 的了。這種情況可能發生在函式依賴了全域性狀態的時候。舉個例子:

let said = false;

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

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

即便是使用了同樣的引數 'Hello World!',兩次的呼叫返回值也是不同的。就是因為非純函式依賴了全域性狀態: 變數 said

sayOnce() 的函式體中的 said = true 語句修改了全域性狀態。這產生了副作用,這是非純的另一個特徵。

因此可以說,純函式沒有副作用,也不依賴全域性狀態。 其單一資料來源就是引數。所以純函式是可以預測並可判斷的,從而可重用並可以直接測試。

React 元件應該從純函式特性中受益。給定同樣的 prop 值,一個純元件(不要和 React.PureComponent 弄混)總是會渲染同樣的元素。來看一看:

function Message({ text }) {  
  return <div className="message">{text}</div>;
}

<Message text="Hello World!" />  
// => <div class="message">Hello World</div>
複製程式碼

可以肯定的是 <Message> 接受相同的 prop 值後會渲染出相同的元素。

有時也不總是能夠把元件做成純的。比如要像下面這樣依賴一些環境資訊:

class InputField extends Component {  
  constructor(props) {
    super(props);
    this.state = { value: '' };
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange({ target: { value } }) {
    this.setState({ value });
  }

  render() {
    return (
      <div>
         <input 
           type="text" 
           value={this.state.value} 
           onChange={this.handleChange} 
         />
         You typed: {this.state.value}
      </div>
    );
  }
}
複製程式碼

帶狀態的 <InputField> 元件並不接受任何 props,但根據使用者輸入會渲染不同的輸出。因為要通過 input 域訪問環境資訊,所以 <InputField> 只能是非純的。

非純程式碼雖然有害但不可或缺。大多數應用都需要全域性狀態、網路請求、本地儲存等等。你能做的只是將非純程式碼從純程式碼中隔離出來,這一過程又成為提純(purification)

[譯] 更可靠的 React 元件:提純

孤立的非純程式碼有明確的副作用,或對全域性狀態的依賴。在隔離狀態下,非純程式碼對系統中其餘部分的不可預測性影響會降低很多。

來看一些提純的例子。

案例學習1:從全域性變數中提純

我不喜歡全域性變數。它們破壞了封裝、造成了不可預測的行為,並使得測試困難重重。

全域性變數可以作為可變(mutable)物件使用,也可以當成不可變的只讀物件。

改變全域性變數會造成元件的不可控行為。資料被隨意注入和修改,將干擾一致性比較(reconciliation)過程,這是一個錯誤。

如果需要可變的全域性狀態,解決的辦法是引入一個可預測的系統狀態管理工具,比如 Redux。

全域性中不可變的(或只讀的)物件經常用於系統配置等。比如包含站點名稱、已登入的使用者名稱或其他配置資訊等。

下面的語句定義了一個配置物件,其中儲存了站點的名稱:

export const globalConfig = {  
  siteName: 'Animals in Zoo'
};
複製程式碼

隨後,<Header> 元件渲染出系統的頭部,其中顯示了以上定義的站點名稱:

import { globalConfig } from './config';

export default function Header({ children }) {  
  const heading = 
    globalConfig.siteName ? <h1>{globalConfig.siteName}</h1> : null;
  return (
     <div>
       {heading}
       {children}
     </div>
  );
}
複製程式碼

<Header>globalConfig.siteName 渲染到一個 <h1> 標籤中。當站點名稱沒有定義(比如賦值為 null)時,頭部就不顯示。

首先要關注的是 <Header> 是非純的。在給定相同 children 的情況下,元件會根據 globalConfig.siteName 返回不同的結果:

// globalConfig.siteName 為 'Animals in Zoo'
<Header>Some content</Header>  
// 渲染:
<div>  
  <h1>Animals in Zoo</h1>
  Some content
</div>
複製程式碼

或是:

// globalConfig.siteName 為 `null`
<Header>Some content</Header>  
// 渲染:
<div>  
  Some content
</div>  
複製程式碼

第二個問題是難以測試。要測試元件如何處理 null 站點名,你得手動修改全域性變數為 globalConfig.siteName = null

import assert from 'assert';  
import { shallow } from 'enzyme';  
import { globalConfig } from './config';  
import Header from './Header';

describe('<Header />', function() {  
  it('should render the heading', function() {
    const wrapper = shallow(
      <Header>Some content</Header>
    );
    assert(wrapper.contains(<h1>Animals in Zoo</h1>));
  });

  it('should not render the heading', function() {
    //修改全域性變數:
    globalConfig.siteName = null;
    const wrapper = shallow(
      <Header>Some content</Header>
    );
    assert(appWithHeading.find('h1').length === 0);
  });
});
複製程式碼

為了測試而修改全域性變數 globalConfig.siteName = null 既不規範又令人不安。 之所以如此是因為 <Heading> 緊依賴了全域性環境。

為了解決這種非純情況,最好是將全域性變數注入元件的作用域,讓全域性變數作為元件的一個輸入。

下面來修改 <Header>,讓其再多接收一個 prop siteName。然後用 recompose 庫提供的 defaultProps() 高階元件包裹 <Header>,以確保缺失 prop 時填充預設值:

import { defaultProps } from 'recompose';  
import { globalConfig } from './config';

export function Header({ children, siteName }) {  
  const heading = siteName ? <h1>{siteName}</h1> : null;
  return (
     <div className="header">
       {heading}
       {children}
     </div>
  );
}

export default defaultProps({  
  siteName: globalConfig.siteName
})(Header);
複製程式碼

<Header> 已經變為一個純的函式式元件,也不再直接依賴 globalConfig 變數了。純化版本是一個命名過的模組: export function Header() {...},這在測試時是很有用的。

與此同時,用 defaultProps({...}) 包裝過的元件會在 siteName 屬性缺失時將其設定為 globalConfig.siteName。正是這一步,非純元件被分離和孤立出來。

讓我們測試一下純化版本的 <Header>

import assert from 'assert';  
import { shallow } from 'enzyme';  
import { Header } from './Header';

describe('<Header />', function() {  
  it('should render the heading', function() {
    const wrapper = shallow(
      <Header siteName="Animals in Zoo">Some content</Header>
    );
    assert(wrapper.contains(<h1>Animals in Zoo</h1>));
  });

  it('should not render the heading', function() {
    const wrapper = shallow(
      <Header siteName={null}>Some content</Header>
    );
    assert(appWithHeading.find('h1').length === 0);
  });
});
複製程式碼

棒極了。純元件 <Header> 的單元測試非常簡單。測試只做了一件事:檢驗元件是否針對給定的輸入渲染出期望的輸出。不需要引入、訪問或修改全域性變數,也沒有什麼摸不準的副作用了。

設計良好的元件易於測試,純元件正是如此。

案例學習2:從網路請求中提純

重溫一下之前文章中提過的 <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
       })
     });
   }
}
複製程式碼

<WeatherFetch> 是非純的,因為對於相同的輸入,其產生了不同的輸出。元件渲染什麼取決於伺服器端的響應。

麻煩的是,HTTP 請求副作用無法被消除。從伺服器端請求資料是 <WeatherFetch> 的直接職責。

但可以讓 <WeatherFetch> 針對相同 props 值渲染相同的輸出。然後將副作用隔離到一個叫做 fetch() 的 prop 函式中。這樣的元件型別可以稱為 幾乎純(almost-pure) 的元件。

讓我們來把非純元件 <WeatherFetch> 轉變為幾乎純的元件。Redux 在將副作用實現細節從元件中抽離出的方面是一把好手。

fetch() 這個 action creator 開啟了伺服器呼叫:

export function fetch() {  
  return {
    type: 'FETCH'
  };
}
複製程式碼

一個 saga (譯註:Sage是一個可以用來處理複雜非同步邏輯的中介軟體,並且由 redux 的 action 觸發)攔截了 "FETCH" action,併發起真正的伺服器請求。當請求完成後,"FETCH_SUCCESS" action 會被分發:

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 負責更新應用的 state:

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 store 和 sagas 的初始化過程在此被省略了)

即便考慮到使用了 Redux 後需要額外的構造器,如 actions、 reducers 和 sagas,這仍然將 <FetchWeather> 轉化為了幾乎純的元件。

那麼把 <WeatherFetch> 修改為可以適用於 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 });  
複製程式碼

connect(mapStateToProps, { fetch }) HOC 包裹了 <WeatherFetch>.

當元件載入後,this.props.fetch() 這個 action creator 會被呼叫,觸發一個伺服器請求。當請求完成後,Redux 會更新系統狀態並讓 <WeatherFetch> 從 props 中獲得 temperaturewindSpeed

this.props.fetch() 作為被孤立並扁平化的非純程式碼,正是它產生了副作用。要感謝 Redux 的是,元件不會再被 axios 庫的細節、服務端 URL,或是 promise 搞得混亂。此外,對於相同的 props 值,新版本的 <WeatherFetch> 總是會渲染相同的元素。元件變為了幾乎純的。

相比於非純的版本,測試幾乎純的 <WeatherFetch> 就更簡單了:

import assert from 'assert';  
import { shallow, mount } from 'enzyme';  
import { spy } from 'sinon';  
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);
  });
});
複製程式碼

要測試的是對於給定的 props, <WeatherFetch> 渲染出了符合期望的 <WeatherInfo>,以及載入後 fetch() 會被呼叫。簡單又易行。

讓“幾乎純”的“更純”

實際上至此為止,你可能已經結束了隔離非純的過程。幾乎純的元件在可預測性和易於測試方面已經表現不俗了。

但是... 讓我們看看兔子洞到底有多深。幾乎純版本的 <WeatherFetch> 還可以被轉化為一個更理想的純元件。

讓我們把 fetch() 的呼叫抽取到 recompose 庫提供的 lifecycle() HOC 中:

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

lifecycle() HOC 接受一個指定生命週期的物件。componentDidMount() 被 HOC 處理,也就是用來呼叫 this.props.fetch()。通過這種方式,副作用被從 <WeatherFetch> 中完全消除了。

現在 <WeatherFetch> 是一個純元件了。沒有副作用,且總是對於給定的相同 temperaturewindSpeed props 值渲染相同的輸出。

純化版本的 <WeatherFetch> 在可預測性和簡單性方面無疑是很棒的。為了將非純元件逐步提純,雖然增加了引入 compose() 和 lifecycle() 等 HOC 的開銷,通常這是很划算的買賣。


(end)


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

轉載請註明出處

[譯] 更可靠的 React 元件:提純
長按二維碼或搜尋 fewelife 關注我們哦

相關文章