一、基本概念
在JavaScript中,高阶函数是以函数为参数,并且返回值也是函数的函数。类似地,高阶组件(简称HOC)接收React组件作为参数,并且返回一个新的React组件。高阶组件本质上也是一个函数,并不是一个组件。高阶组件的函数形式如下:
const EnhancedComponent =higherOrderComponent(WrappedComponent);
我们先通过一个简单的例子看一下高阶组件是如何进行逻辑复用的。现在有一个组件MyComponent,需要从LocalStorage中获取数据,然后渲染到界面。一般情况下,我们可以这样实现:
import React, { Component } from 'react'
class MyComponent extends Component {
componentWillMount() {
let data = localStorage.getItem('data');
this.setState({data});
}
render() {
return {this.state.data}
}
}
代码很简单,但当其他组件也需要从LocalStorage中获取同样的数据展示出来时,每个组件都需要重写一次componentWillMount中的代码,这显然是很冗余的。下面让我们来看看使用高阶组件改写这部分代码。
import React, { Component } from 'react'
function withPersistentData(WrappedComponent) {
return class extends Component {
componentWillMount() {
let data = localStorage.getItem('data');
this.setState({data});
}
render() {
// 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件
return
}
}
}
class MyComponent extends Component {
render() {
return {this.props.data}
}
}
const MyComponentWithPersistentData =withPersistentData(MyComponent)
withPersistentData就是一个高阶组件,它返回一个新的组件,在新组件的componentWillMount中统一处理从LocalStorage中获取数据的逻辑,然后将获取到的数据通过props传递给被包装的组件WrappedComponent,这样在WrappedComponent中就可以直接使用this.props.data获取需要展示的数据。当有其他的组件也需要这段逻辑时,继续使用withPersistentData这个高阶组件包装这些组件。
通过这个例子可以看出高阶组件的主要功能是封装并分离组件的通用逻辑,让通用逻辑在组件间更好地被复用。高阶组件的这种实现方式本质上是装饰者设计模式。
二、使用场景
高阶组件的使用场景主要有以下4种:
(1)操纵props
(2)通过ref访问组件实例
(3)组件状态提升
(4)用其他元素包装组件
每一种使用场景通过一个例子来说明。
1.操纵props
在被包装组件接收props前,高阶组件可以先拦截到props,对props执行增加、删除或修改的操作,然后将处理后的props再传递给被包装组件。上面的例子就属于这种情况,高阶组件为WrappedComponent
增加了一个data属性。
2.通过ref访问组件实例
高阶组件通过ref获取被包装组件实例的引用,然后高阶组件就具备了直接操作被包装组件的属性或方法的能力。
function withRef(wrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.someMethod = this.someMethod.bind(this);
}
someMethod() {
this.wrappedInstance.someMethodInWrappedComponent();
}
render() {
//为被包装组件添加ref属性,从而获取该组件实例并赋值给 this.wrappedInstance
return
{this.wrappedInstance = instance}} {...this.props} />
}
}
}
当WrappedComponent被渲染时,执行ref的回调函数,高阶组件通过this.wrappedInstance保存WrappedComponent实例的引用,在someMethod中,通过this.wrappedInstance调用WrappedComponent中的方法。这种用法在实际项目中很少会被用到,但当高阶组件封装的复用逻辑需要被包装组件的方法或属性的协同支持时,这种用法就有了用武之地。
3.组件状态提升
无状态组件更容易被复用。高阶组件可以通过将被包装组件的状态及相应的状态处理方法提升到高阶组件自身内部实现被包装组件的无状态化。一个典型的场景是,利用高阶组件将原本受控组件需要自己维护的状态统一提升到高阶组件中。
function withControlledState(WrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = {value: ''};
this.handleValueChange =this.handleValueChange.bind(this);
}
handleValueChange(event) {
this.setState({value: event.target.value});
}
render() {
// newProps 保存受控组件需要使用的属性和事件处理函数
const newProps = {controlledProps: {value: this.state.value,onChange: this.handleValueChange}
};
return
}
}
}
这个例子把受控组件value属性用到的状态和处理value变化的回调函数都提升到高阶组件中,当我们再使用受控组件时,就可以这样使用:
class SimpleControlledComponent extends React.Component {
render() {
//此时的SimpleControlledComponent为无状态组件,状态由高阶组件维护
return
}
}
const ComponentWithControlledState = withControlledState(SimpleControlledComponent);
4.用其他元素包装组件
我们还可以在高阶组件渲染WrappedComponent时添加额外的元素,这种情况通常用于为WrappedComponent增加布局或修改样式。
function withRedBackground(WrappedComponent) {
return class extends React.Component {
render() {
return (
)
}
}
}
三、参数传递
高阶组件的参数并非只能是一个组件,它还可以接收其他参数。例如,从LocalStorage中获取key为data的数据,当需要获取的数据的key不确定时,withPersistentData这个高阶组件就不满足需求了。我们可以让它接收一个额外的参数来决定从LocalStorage中获取哪个数据:
import React, { Component } from 'react'
function withPersistentData(WrappedComponent, key) {
return class extends Component {
componentWillMount() {
let data = localStorage.getItem(key);
this.setState({data});
}
render() {
// 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件
return
}
}
}
class MyComponent extends Component {
render() {
return {this.props.data}
}
}
// 获取key=’data’的数据
const MyComponent1WithPersistentData =withPersistentData(MyComponent, 'data');
// 获取key=’name’的数据
const MyComponent2WithPersistentData =withPersistentData(MyComponent, 'name');
新版本的withPersistentData满足获取不同key值的需求。但实际情况中,我们很少使用这种方式传递参数,而是采用更加灵活、更具通用性的函数形式:
HOC(...params)(WrappedComponent)
HOC(…params)的返回值是一个高阶组件,高阶组件需要的参数是先传递给HOC函数的。用这种形式改写withPersistentData如下(注意:这种形式的高阶组件使用箭头函数定义更为简洁):
import React, { Component } from 'react'
function withPersistentData = (key) => (WrappedComponent) =>
{
return class extends Component {
componentWillMount() {
let data = localStorage.getItem(key);
this.setState({data});
}
render() {
// 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件
return
}
}
}
class MyComponent extends Component {
render() {
return {this.props.data}
}
}
// 获取key=’data’的数据
const MyComponent1WithPersistentData =withPersistentData('data') (MyComponent);
// 获取key=’name’的数据
const MyComponent2WithPersistentData =withPersistentData('name') (MyComponent);
实际上,这种形式的高阶组件大量出现在第三方库中,例如react-redux中的connect函数就是一个典型的例子。connect的简化定义如下:
connect(mapStateToProps, mapDispatchToProps)(WrappedComponent)
这个函数会将一个React组件连接到Redux 的 store上,在连接的过程中,connect通过函数参数mapStateToProps从全局store中取出当前组件需要的state,并把state转化成当前组件的props;同时通过函数参数mapDispatchToProps把当前组件用到的Redux的action creators以props的方式传递给当前组件。connect并不会修改传递进去的组件的定义,而是会返回一个新的组件。例如,把组件ComponentA连接到Redux上的写法类似于:
const ConnectedComponentA = connect(mapStateToProps,mapDispatchToProps) (ComponentA);
四、继承方式实现高阶组件
前面介绍的高阶组件的实现方式都是由高阶组件处理通用逻辑,然后将相关属性传递给被包装组件,我们称这种实现方式为属性代理。除了属性代理外,还可以通过继承方式实现高阶组件:通过继承被包装组件实现逻辑的复用。继承方式实现的高阶组件常用于渲染劫持。例如,当用户处于登录状态时,允许组件渲染;否则渲染一个空组件。示例代码如下:
function withAuth(WrappedComponent) {
return class extends WrappedComponent {
render() {
if (this.props.loggedIn) {
return super.render();
} else {
return null;
}
}
}
}
根据WrappedComponent的this.props.loggedIn判断用户是否已经登录,如果登录,就通过super.render()调用WrappedComponent的render方法正常渲染组件,否则返回一个null。继承方式实现的高阶组件对被包装组件具有侵入性,当组合多个高阶组件使用时,很容易因为子类组件忘记通过super调用父类组件方法而导致逻辑丢失。因此,在使用高阶组件时,应尽量通过代理方式实现高阶组件。
五、注意事项
使用高阶组件需要注意以下事项。
1.为了在开发和调试阶段更好地区别包装了不同组件的高阶组件,需要对高阶组件的显示名称做自定义处理。常用的处理方式是,把被包装组件的显示名称也包到高阶组件的显示名称中。以withPersistentData为例:
function withPersistentData(WrappedComponent) {
return class extends Component {
//结合被包装组件的名称,自定义高阶组件的名称
static displayName =`HOC(${getDisplayName(WrappedComponent)})`;
render() {
//...
}
}
}
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName ||
WrappedComponent.name || 'Component';
}
2.不要在组件的render方法中使用高阶组件,尽量也不要在组件的其他生命周期方法中使用高阶组件。因为调用高阶组件,每次都会返回一个新的组件,于是每次render,前一次高阶组件创建的组件都会被卸载(unmount),然后重新挂载(mount)本次创建的新组件,既影响效率,又丢失了组件及其子组件的状态。例如:
render() {
// 每次render,enhance都会创建一个新的组件,尽管被包装的组件没有变
const EnhancedComponent = enhance(MyComponent);
// 因为是新的组件,所以会经历旧组件的卸载和新组件的重新挂载
return ;
}
所以,高阶组件最适合使用的地方是在组件定义的外部,这样就不会受到组件生命周期的影响。
3.如果需要使用被包装组件的静态方法,那么必须手动复制这些静态方法。因为高阶组件返回的新组件不包含被包装组件的静态方法。例如:
// WrappedComponent组件定义了一个静态方法staticMethod
WrappedComponent.staticMethod = function() {
//...
}
function withHOC(WrappedComponent) {
class Enhance extends React.Component {
//...
}
// 手动复制静态方法到Enhance上
Enhance.staticMethod = WrappedComponent.staticMethod;
return Enhance;
}
4.Refs不会被传递给被包装组件。尽管在定义高阶组件时,我们会把所有的属性都传递给被包装组件,但是ref并不会传递给被包装组件。如果在高阶组件的返回组件中定义了ref,那么它指向的是这个返回
的新组件,而不是内部被包装的组件。如果希望获取被包装组件的引用,那么可以自定义一个属性,属性的值是一个函数,传递给被包装组件的ref 。下面的例子就是用inputRef这个属性名代替常规的ref命名:
function FocusInput({ inputRef, ...rest }) {
// 使用高阶组件传递的inputRef作为ref的值
return ;
}
//enhance 是一个高阶组件
const EnhanceInput = enhance(FocusInput);
// 在一个组件的render方法中,自定义属性inputRef代替ref,
// 保证inputRef可以传递给被包装组件
return ( {this.input = input}}>)
// 组件内,让FocusInput自动获取焦点
this.input.focus();
5.与父组件的区别。高阶组件在一些方面和父组件很相似。例如,我们完全可以把高阶组件中的逻辑放到一个父组件中去执行,执行完成的结果再传递给子组件,但是高阶组件强调的是逻辑的抽象。高阶组件是一个函数,函数关注的是逻辑;父组件是一个组件,组件主要关注的是UI/DOM。如果逻辑是与DOM直接相关的,那么这部分逻辑适合放到父组件中实现;如果逻辑是与DOM不直接相关的,那么这部分逻辑适合使用高阶组件抽象,如数据校验、请求发送等。