原文摘自:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/react
一個 純組件(pure componnet) 老是針對一樣的 prop 值渲染出一樣的元素;ios
一個 幾乎純的組件(almost-pure compoent) 老是針對一樣的 prop 值渲染一樣的元素,而且會產生一個 反作用(side effect)編程
在函數式編程的術語裏,一個 純函數(pure function) 老是根據某些給定的輸入返回相同的輸出。讓咱們看一個簡單的純函數:redux
function sum(a, b) {
return a + b;
}
sum(5, 10); // => 15
複製代碼
對於給定的兩個數字,sum() 函數老是返回一樣的相加值。axios
一旦對相同的輸入返回不一樣的輸出了,一個函數就變成 非純(impure) 的了。這種狀況可能發生在函數依賴了全局狀態的時候。舉個例子:api
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。promise
sayOnce() 的函數體中的 said = true 語句修改了全局狀態。這產生了反作用,這是非純的另外一個特徵。bash
所以能夠說,純函數沒有反作用,也不依賴全局狀態。 其單一數據源就是參數。因此純函數是能夠預測並可判斷的,從而可重用並能夠直接測試。服務器
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)。
孤立的非純代碼有明確的反作用,或對全局狀態的依賴。在隔離狀態下,非純代碼對系統中其他部分的不可預測性影響會下降不少。
來看一些提純的例子。
我不喜歡全局變量。它們破壞了封裝、形成了不可預測的行爲,並使得測試困難重重。
全局變量能夠做爲可變(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>
的單元測試很是簡單。測試只作了一件事:檢驗組件是否針對給定的輸入渲染出指望的輸出。不須要引入、訪問或修改全局變量,也沒有什麼摸不許的反作用了。
設計良好的組件易於測試,純組件正是如此。
重溫一下以前文章中提過的 <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 中得到 temperature 和 windSpeed。
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>
是一個純組件了。沒有反作用,且老是對於給定的相同 temperature 和 windSpeed props 值渲染相同的輸出。
純化版本的 <WeatherFetch>
在可預測性和簡單性方面無疑是很棒的。爲了將非純組件逐步提純,雖然增長了引入 compose() 和 lifecycle() 等 HOC 的開銷,一般這是很划算的買賣。
轉載請註明出處
長按二維碼或搜索 fewelife 關注咱們哦