原文摘自: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) 总是根据某些给定的输入返回相同的输出。让我们看一个简单的纯函数:
代码语言:javascript复制function sum(a, b) {
return a b;
}
sum(5, 10); // => 15
对于给定的两个数字,sum() 函数总是返回同样的相加值。
一旦对相同的输入返回不同的输出了,一个函数就变成 非纯(impure) 的了。这种情况可能发生在函数依赖了全局状态的时候。举个例子:
代码语言:javascript复制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 值后会渲染出相同的元素。
有时也不总是能够把组件做成纯的。比如要像下面这样依赖一些环境信息:
代码语言:javascript复制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)。
孤立的非纯代码有明确的副作用,或对全局状态的依赖。在隔离状态下,非纯代码对系统中其余部分的不可预测性影响会降低很多。
来看一些提纯的例子。
案例学习1:从全局变量中提纯
我不喜欢全局变量。它们破坏了封装、造成了不可预测的行为,并使得测试困难重重。
全局变量可以作为可变(mutable)对象使用,也可以当成不可变的只读对象。
改变全局变量会造成组件的不可控行为。数据被随意注入和修改,将干扰一致性比较(reconciliation)过程,这是一个错误。
如果需要可变的全局状态,解决的办法是引入一个可预测的系统状态管理工具,比如 Redux。
全局中不可变的(或只读的)对象经常用于系统配置等。比如包含站点名称、已登录的用户名或其他配置信息等。
下面的语句定义了一个配置对象,其中保存了站点的名称:
代码语言:javascript复制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>
或是:
代码语言:javascript复制// globalConfig.siteName 为 `null`
<Header>Some content</Header>
// 渲染:
<div>
Some content
</div>
第二个问题是难以测试。要测试组件如何处理 null 站点名,你得手动修改全局变量为 globalConfig.siteName = null:
代码语言:javascript复制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 开启了服务器调用:
代码语言:javascript复制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:
代码语言:javascript复制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 的开销,通常这是很划算的买卖。