使用 React 高阶组件

Flying
2019-01-05 / 0 评论 / 202 阅读 / 正在检测是否收录...

高阶组件是 React 中一个很重要且较复杂的概念,主要用来实现组件逻辑的抽象和复用,在很多第三方库(如 Redux)中都被使用到。即使开发一般的业务项目,如果能合理地使用高阶组件,也能显著提高项目的代码质量。本章将详细介绍高阶组件的概念及应用。

component-react.svg

基本概念

在 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 <div>{this.state.data}</div>;
  }
}

代码很简单,但当其他组件也需要从 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 (
        <WrappedComponent
          data={this.state.data}
          {...this.props}
        />
      );
    }
  };
}

class MyComponent extends Component {
  render() {
    return <div>{this.props.data}</div>;
  }
}

const MyComponentWithPersistentData = withPersistentData(MyComponent);

withPersistentData 就是一个高阶组件,它返回一个新的组件,在新组件的 componentWillMount 中统一处理从 LocalStorage 中获取数据的逻辑,然后将获取到的数据通过 props 传递给被包装的组件 WrappedComponent,这样在 WrappedComponent 中就可以直接使用 this.props.data 获取需要展示的数据。当有其他的组件也需要这段逻辑时,继续使用 withPersistentData 这个高阶组件包装这些组件。

通过这个例子可以看出高阶组件的主要功能是封装并分离组件的通用逻辑,让通用逻辑在组件间更好地被复用。高阶组件的这种实现方式本质上是装饰者设计模式。

使用场景

高阶组件的使用场景主要有以下 4 种:

  • 操作 props
  • 通过 ref 访问组件实例
  • 组件状态提升

每一种使用场景通过一个例子来说明。

操作 props

在被包装组件接收 props 前,高阶组件可以先拦截到 props,对 props 执行增加、删除或修改的操作,然后将处理后的 props 再传递给被包装组件。前面的例子就属于这种情况,高阶组件为 WrappedComponent 增加了一个 data 属性。这里不再额外举例。

通过 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 (
        <WrappedComponent
          ref={(instance) => {
            this.wrappedInstance = instance;
          }}
          {...this.props}
        />
      );
    }
  };
}

当 WrappedComponent 被渲染时,执行 ref 的回调函数,高阶组件通过 this.wrappedInstance 保存 WrappedComponent 实例的引用,在 someMethod 中,通过 this.wrappedInstance 调用 WrappedComponent 中的方法。这种用法在实际项目中很少会被用到,但当高阶组件封装的复用逻辑需要被包装组件的方法或属性的协同支持时,这种用法就有了用武之地。

组件状态提升

无状态组件更容易被复用。高阶组件可以通过将被包装组件的状态及相应的状态处理方法提升到高阶组件自身内部实现被包装组件的无状态化。一个典型的场景是,利用高阶组件将原本受控组件需要自己维护的状态统一提升到高阶组件中。

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 (
        <WrappedComponent
          {...this.props}
          {...newProps}
        />
      );
    }
  };
}

这个例子把受控组件 value 属性用到的状态和处理 value 变化的回调函数都提升到高阶组件中,当我们再使用受控组件时,就可以这样使用:

class SimpleControlledComponent extends React.Component {
  render() {
    // 此时的 SimpleControlledComponent 为无状态组件,状态由高阶组件维护;
    return (
      <input
        name="simple"
        {...this.props.controlledProps}
      />
    );
  }
}
const ComponentWithControlledState = withControlledState(
  SimpleControlledComponent
);

参数传递

高阶组件的参数并非只能是一个组件,它还可以接收其他参数。例如,前面的示例是从 LocalStorage 中获取 key 为 data 的数据,当需要获取的数据的 key 不确定时,withPersistentData 这个高阶组件就不满足需求了。我们可以让它接收一个额外的参数来决定从 LocalStorage 中获取哪个数据:

import React, { Component } from 'react';

const withPersistentData = (key) => (WrappedComponent) => {
  return class extends Component {
    componentWillMount() {
      let data = localStorage.getItem(key);
      this.setState({ data });
    }
    render() {
      // 通过{...this.props} 把传递给当前组件的属性继续传递给被包装
      的组件;
      return (
        <WrappedComponent
          data={this.state.data}
          {...this.props}
        />
      );
    }
  };
};
class MyComponent extends Component {
  render() {
    return <div>{this.props.data}</div>;
  }
}
// 获取 key='data'的数据
const MyComponent1WithPersistentData = withPersistentData('data')(MyComponent);
// 获取 key=’name’的数据
const MyComponent2WithPersistentData = withPersistentData('name')(MyComponent);

HOC(…params)的返回值是一个高阶组件,高阶组件需要的参数是先传递给 HOC 函数的。用这种形式改写 withPersistentData 如下(注意:这种形式的高阶组件使用箭头函数定义更为简洁)。

实际上,这种形式的高阶组件大量出现在第三方库中,例如 reactredux 中的 connect 函数就是一个典型的例子。connect 的简化定义如下:

connect(mapStateToProps, mapDispatchToProps)(WrappedComponent)

这个函数会将一个 React 组件连接到 Redux 的 store 上,在连接的过程中,connect 通过函数参数 mapStateToProps 从全局 store 中取出当前组件需要的 state,并把 state 转化成当前组件的 props;同时通过函数参数
mapDispatchToProps 把当前组件用到的 Redux 的 action creators 以 props 的方式传递给当前组件。connect 并不会修改传递进去的组件的定义,而是会返回一个新的组件。

connect 的参数 mapStateToProps、mapDispatchToProps 是函数类型,说明高阶组件的参数也可以是函数类型。

例如,把组件 ComponentA 连接到 Redux 上的写法类似于:

const ConnectedComponentA = connect(mapStateToProps,mapDispatchToProps) (ComponentA);

我们可以把它拆分来看:

// connect 是一个函数,返回值 enhance 也是一个函数
const enhance = connect(mapStateToProps, mapDispatchToProps);
// enhance 是一个高阶组件
const ConnectedComponentA = enhance(ComponentA);

这种形式的高阶组件非常容易组合起来使用,因为当多个函数的输出和它的输入类型相同时,这些函数很容易组合到一起使用。例如,有 f、g、h 三个高阶组件,都只接收一个组件作为参数,于是我们可以很方便地嵌套使用它们:f( g( h(WrappedComponent) ) )。这里有一个例外,即最内层的高阶组件 h 可以有多个参数,但其他高阶组件必须只能接收一个参数,只有这样才能保证内层的函数返回值和外层的函数参数数量一致(都只有 1 个)。

例如,将 connect 和另一个打印日志的高阶组件 withLog()(注意,withLog()的执行结果才是真正的高阶组件)联合使用:

// connect 的参数是可选参数,这里省略了 mapDispatchToProps 参数
const ConnectedComponentA = connect(mapStateToProps)
(withLog() (ComponentA));

我们还可以定义一个工具函数 compose(...funcs):

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }
  if (funcs.length === 1) {
    return funcs[0];
  }
  return funcs.reduce(
    (a, b) =>
      (...args) =>
        a(b(args))
  );
}

调用 compose(f, g, h)等价于 (...args) => f(g(h(...args)))。用 compose 函数可以把高阶组件嵌套的写法压平:

const enhance = compose(connect(mapStateToProps), withLog());
const ConnectedComponentA = enhance(ComponentA);

像 Redux 等很多第三方库都提供了 compose 的实现,compose 结合高阶组件使用可以显著提高代码的可读性和逻辑的清晰度。

继承方式实现高阶组件

前面介绍的高阶组件的实现方式都是由高阶组件处理通用逻辑,然后将相关属性传递给被包装组件,我们称这种实现方式为属性代理。除了属性代理外,还可以通过继承方式实现高阶组件:通过继承被包装组件实现逻辑的复用。继承方式实现的高阶组件常用于渲染劫持。例如,
当用户处于登录状态时,允许组件渲染;否则渲染一个空组件。示例代码如下:

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 调用父类组件方法而导致逻辑丢失。因此,在使用高阶组件时,应尽量通过代理方式实现高阶组件。

注意事项

使用高阶组件需要注意以下事项。

  • 为了在开发和调试阶段更好地区别包装了不同组件的高阶组件,需要对高阶组件的显示名称做自定义处理。常用的处理方式是,把被包装组件的显示名称也包到高阶组件的显示名称中。以 withPersistentData 为例:
function withPersistentData(WrappedComponent) {
  return class extends Component {
    // 结合被包装组件的名称,自定义高阶组件的名称
    static displayName = `HOC(${getDisplayName(WrappedComponent)})`;
    render() {
      //...
    }
  };
}
function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
  • 不要在组件的 render 方法中使用高阶组件,尽量也不要在组件的其他生命周期方法中使用高阶组件。因为调用高阶组件,每次都会返回一个新的组件,于是每次 render,前一次高阶组件创建的组件都会被卸载(unmount),然后重新挂载(mount)本次创建的新组件,既影响效率,又丢失了组件及其子组件的状态。例如:
render() {
  // 每次 render,enhance 都会创建一个新的组件,尽管被包装的组件没有变
  const EnhancedComponent = enhance(MyComponent);
  // 因为是新的组件,所以会经历旧组件的卸载和新组件的重新挂载
  return <EnhancedComponent />;
}

所以,高阶组件最适合使用的地方是在组件定义的外部,这样就不会受到组件生命周期的影响。

  • 如果需要使用被包装组件的静态方法,那么必须手动复制这些静态方法。因为高阶组件返回的新组件不包含被包装组件的静态方法。例如:
// WrappedComponent 组件定义了一个静态方法 staticMethod
WrappedComponent.staticMethod = function () {
  //...
};
function withHOC(WrappedComponent) {
  class Enhance extends React.Component {
    //...
  }
  // 手动复制静态方法到 Enhance 上
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}
  • Refs 不会被传递给被包装组件。尽管在定义高阶组件时,我们会把所有的属性都传递给被包装组件,但是 ref 并不会传递给被包装组件。如果在高阶组件的返回组件中定义了 ref,那么它指向的是这个返回的新组件,而不是内部被包装的组件。如果希望获取被包装组件的引用,那么可以自定义一个属性,属性的值是一个函数,传递给被包装组件的 ref 。下面的例子就是用 inputRef 这个属性名代替常规的 ref 命名:
function FocusInput({ inputRef, ...rest }) {
  // 使用高阶组件传递的 inputRef 作为 ref 的值
  return (
    <input
      ref={inputRef}
      {...rest}
    />
  );
}
//enhance 是一个高阶组件
const EnhanceInput = enhance(FocusInput);
// 在一个组件的 render 方法中,自定义属性 inputRef 代替 ref,
// 保证 inputRef 可以传递给被包装组件
return (
  <EnhanceInput
    inputRef={(input) => {
      this.input = input;
    }}
  />
);
// 组件内,让 FocusInput 自动获取焦点
this.input.focus();
  • 与父组件的区别。高阶组件在一些方面和父组件很相似。例如,我们完全可以把高阶组件中的逻辑放到一个父组件中去执行,执行完成的结果再传递给子组件,但是高阶组件强调的是逻辑的抽象。高阶组件是一个函数,函数关注的是逻辑;父组件是一个组件,组件主要关注的是 UI/DOM。如果逻辑是与 DOM 直接相关的,那么这部分逻辑适合放到父组件中实现;如果逻辑是与 DOM 不直接相关的,那么这部分逻辑适合使用高阶组件抽象,如数据校验、请求发送等。

总结

高阶组件主要用于封装组件的通用逻辑,常用在操作组件 props、通过 ref 访问组件实例、组件状态提升等场景中。高阶组件可以接收被包装组件以外的其他参数,多个高阶组件还可以组合使用。高阶组件一般通过代理方式实现,少量场景中也会使用继承方式实现。灵活使用高阶组件可以显著提高代码质量。

3

评论 (0)

取消