[译] 更可靠的 React 组件:提纯

1,431 阅读9分钟

原文摘自: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)

孤立的非纯代码有明确的副作用,或对全局状态的依赖。在隔离状态下,非纯代码对系统中其余部分的不可预测性影响会降低很多。

来看一些提纯的例子。

案例学习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)


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

转载请注明出处


长按二维码或搜索 fewelife 关注我们哦