翻译:刘小夕
原文的篇幅非常长,不过内容太过于吸引我,还是忍不住要翻译出来。此篇文章对编写可重用和可维护的React组件非常有帮助。但因为篇幅实在太长,我对文章进行了分割,本篇文章重点阐述 纯组件和几乎纯组件 。因水平有限,文中部分翻译可能不够准确,如果你有更好的想法,欢迎在评论区指出。
更多优质文章可戳: github.com/YvetteLau/B…
———————————————我是一条分割线————————————————
纯组件和几乎纯组件
纯组件总是为相同的属性值渲染相同的元素。几乎纯的组件总是为相同的属性值呈现相同的元素,但是会产生副作用。
在函数编程属于中,对于给定的相同输入,纯函数总是返回相同的输出,并且不会对外界产生副作用。
function sum(a, b) {
return a + b;
}
sum(5, 10); // => 15
对于给定的两个数字,sum() 函数总是会返回相同的结果。
当一个函数输入相同,而输出不同时,它就不是一个纯函数。当这个函数依赖于全局的状态时,就不是一个纯函数,例如:
let said = false;
function sayOnce(message) {
if (said) {
return null;
}
said = true;
return message;
}
sayOnce('Hello World!'); // => 'Hello World!'
sayOnce('Hello World!'); // => null
sayOnce('Hello World!') 第一次调用时,返回 Hello World.
即使输入参数相同,都是 Hello World,但是第二次调用 sayOnce('Hello World!'),返回的结果是 null 。这里有一个非纯函数的特征:依赖全局状态 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,而是根据用户的输入内容渲染输出。<InputField> 必须是非纯组件,因为它需要通过 input 输入框与外界交互。
非纯组件是必要的,大多数应用程序中都需要全局状态,网络请求,本地存储等。你所能做的就是将 纯组件和非纯组件隔离,也就是说将你的组件进行提纯。

非纯代码显式的表明了它有副作用,或者是依赖全局状态。在隔离状态下,不纯代码对系统其它部分的不可预测的影响较小。
让我们详细介绍一下提纯的例子。
案例研究:从全局变量中提取纯组件
我不喜欢全局变量,因为它们打破了封装,创造了不可预测的行为,并且使测试变得困难。
全局变量可以作为可变对象或者是不可变对象使用。
可变的全局变量使得组件的行为难以控制,数据可以随意的注入和修改,影响协调过程,这显然是错误的。
如果你需要可变的全局状态,那么你可以考虑使用 Redux 来管理你的应用程序状态。
不可变的全局变量通常是应用程序的配置对象,这个对象中包含站点名称、登录用户名或者其它的配置信息。
以下代码定义一个包含站点名称的配置对象:
export const globalConfig = {
siteName: 'Animals in Zoo'
};
<Header> 组件渲染应用的头部,包括展示站点名称: Animals in Zoo
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 来展示站点名称,当 globalConfig.siteName 未定义时,不显示。
首先需要注意的是 <Header> 是非纯组件。即使传入的 children 值相同,也会因为 globalConfig.siteName 值的不同返回不同的结果。
// globalConfig.siteName is 'Animals in Zoo'
<Header>Some content</Header>
// Renders:
<div>
<h1>Animals in Zoo</h1>
Some content
</div>
或:
// globalConfig.siteName is `null`
<Header>Some content</Header>
// Renders:
<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 () {
// Modification of global variable:
globalConfig.siteName = null;
const wrapper = shallow(
<Header>Some content</Header>
);
assert(appWithHeading.find('h1').length === 0);
});
});
为了测试而修改 globalConfig.siteName = null 是不方便的。发生这种情况是因为 <Heading> 对全局变量有很强的依赖。
为了解决这个问题,可以将全局变量作为组件的输入,而非将其注入到组件的作用域中。
我们来修改一下 <Header> 组件,使其多接受一个 siteNmae 的prop, 然后使用 recompose 库中的 defaultProps 高阶组件来包装组件,defaultProps 可以保证在没有传入props时,使用默认值。
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 变量,让测试变得简单。
同时,当我们没有设置 siteName时,defaultProps 会传入 globalConfig.siteName 作为 siteName 属性值。这就是不纯代码被分离和隔离开的地方。
现在让我们测试纯版本的 <Header> 组件:
import assert from 'assert';
import { shallow } from 'enzyme';
import { Header } from './Header'; // Import the pure 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> 为相同的属性值渲染相同的内容。这样就可以将副作用隔离到 prop 的函数属性 fetch() 上。这样的一个组件类型被称为几乎纯组件。
我们来将非纯组件<WeatherFetch>改写成几乎纯组件。 Redux 可以很好的帮助我们将副作用的实现细节从组件中提取出来。因此,我们需要设置一些 Redux 的结构。
fetch() action creater 启动服务器调用:
export function fetch() {
return {
type: 'FETCH'
};
}
redux-saga 拦截了 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 负责更新应用的状态。
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;
}
}
ps: 为了简单起见,省略了 Redux store 和 sagas 的初始化。
尽管使用 Redux 需要额外的结构,例如: actions ,reducers 和 sagas,但是它有助于使得 <WeatherFetch> 成为几乎纯组件。
我们来修改一下 <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>.
当组件挂载时,action creator this.props.fetch() 被调用,触发服务端请求,当请求完成时, Redux 更新应用的 state,使得 <WeatherFetch> 从 props 中接收 temperature 和 windSpeed。
this.props.fetch 是为了隔离产生副作用的非纯代码。因为 Redux 的存在,组件内部不再需要使用 axois 库,请求 URL 或者是处理 promise。此外,新版本的 <WeatherFetch>会为相同的props值渲染相同的元素。这个组件变成了几乎纯组件。
与非纯版本相比,测试几乎纯版本 <WeatherFetch> 更加容易:
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);
});
});
你需要检查,给定的 prop 值,<WeatherFetch>的渲染结果是否与预期一致,并在挂载时调用 fetch()。简单且明了。
将几乎纯组件转换成纯组件
实际上,在这一步,你不在需要分离不纯的代码,几乎纯组件具有良好的可预测性,并且易于测试。
但是...我们一起来看看兔子洞究竟有多深。几乎纯版本的 <WeatherFetch> 组件可以被转换成一个理想的纯组件。
我们来将 fetch 回调提取到 recompose 库的 lifecycle() 高阶组件中。
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() 高阶组件接受一个有生命周期方法的对象。 调用 this.props.fecth() 方法的 componentDidMount() 由高阶组件处理,将副作用从 <WeatherFetch> 中提取出来。
现在,<WeatherFetch> 是一个纯组件,它不再有副作用,并且当输入的属性值 temperature 和 windSpeed 相同时,输出总是相同。
虽然纯版本的 <WeatherFetch> 在可预测性和捡东西方面很好,但是它需要类似 compose() 、lifecycle() 等高阶组件,因此,通常,是否将几乎纯组件转换成纯组件需要我们去权衡。
最后谢谢各位小伙伴愿意花费宝贵的时间阅读本文,如果本文给了您一点帮助或者是启发,请不要吝啬你的赞和Star,您的肯定是我前进的最大动力。github.com/YvetteLau/B…
关注公众号,加入技术交流群
