什么是 React 的高阶组件?

999 阅读10分钟

高阶组件(higher-order component, HOC)是 React 中一个复用组件逻辑的高级技巧。高阶组件本身不是 React API 的一部分。它是从 React 组合生态中产生的一种模式。 具体来说,高阶组件是接受一个组件并生成一个新的组件的函数

const EnhancedComponent = higherOrderComponent(WrappedComponent);

与组件把属性渲染成 UI 不同的是,高阶组件通过组件生成一个新的组件。 高阶组件在第三方组件库中非常常见,例如 Redux's connect 和 Relay's createFragmentContainer 在这篇文档中,我们将讨论高阶组件的意义以及如何自定义高阶组件

用高阶组件解决横切关注点问题

注意 我们以前推荐用混入(mixins)的方式去处理横切关注点问题。可逐渐地意识到混入导致的麻烦已经超过了带来的好处。了解更多关于为什么我们放弃混入和如何转换现有组件。

组件是 React 中最主要的代码复用单元。然而,你会发现有些方法不再适合直接用于传统组件。 例如,有一个订阅外部数据源用于渲染列表CommentList组件。

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" is some global data source
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}

随后,又实现一个有类似逻辑的订阅博客文章的组件。

class BlogPost extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      blogPost: DataSource.getBlogPost(props.id)
    };
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id)
    });
  }

  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}

CommentListBlogPost不是相同的——它们执行DataSource的不同方法以及渲染不同的输出。但是它们大部分实现都是一样的:

  • 组件挂载时,添加一个DataSource的变化监听器
  • 在监听器中,当数据源改变时,执行setState
  • 组件卸载时,移除变化监听器

可以想象一下在一个非常大的应用中,订阅DataSource然后执行setState这种相似的模式一次又一次的出现。我们想要允许在一处定义逻辑然后在多个组件间共享的抽象。这就是高阶组件擅长的。 我们可以实现一个创建组件(例如CommentListBlogPost这种订阅DataSource的)的函数。这个函数将接受一个子组件作为它的其中一个参数,该子组件接受订阅的数据作为属性。函数命名为withSubscription

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);

第一个参数是被包装的组件。第二个参数取回我们感兴趣的数据,给定DataSource和当前属性。 当CommentListWithSubscriptionBlogPostWithSubscription渲染时,CommentListBlogPost将被传递data属性,其中包含从DataSource取回的最新数据。

// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

注意高阶组件不会改变原组件,也不是通过继承去复制它的功能。相反,高阶组件是由容器组件包装原组件的方式组合而成的。高阶组件是没有副作用的纯函数。 而且,被包装的组件接受容器组件的所有属性,同时也接受新的用于渲染输出的data属性。高阶组件不关心数据如何使用,被包装的组件不关心数据来自于哪里。 因为withSubscription是纯函数,你可以添加任何想要的参数。例如,你可能为了进一步实现和被包装组件解耦而想要让data属性名称可配置。或者你想要接受一个参数配置shouldComponentUpdate或者配置数据源。以上这些都是有可能的,因为高阶组件对生成的新组件定义拥有完全控制的能力。 就像组件,withSubscription和被包装组件直接的联系是完全基于属性的。只要为被包装组件提供相同的参数,那么更换高阶组件将变得非常容易。例如数据拉取这样的库,这将变得非常有用。

不要改变原始组件,而应该使用组合

不要试图在高阶组件中修改组件原型(或者以其他方式改变它)

function logProps(InputComponent) {
  InputComponent.prototype.componentDidUpdate = function(prevProps) {
    console.log('Current props: ', this.props);
    console.log('Previous props: ', prevProps);
  };
  // The fact that we're returning the original input is a hint that it has
  // been mutated.
  return InputComponent;
}

// EnhancedComponent will log whenever props are received
const EnhancedComponent = logProps(InputComponent);

这种情况就会带来一些问题。第一点是原组件不能在独立于增强组件重复使用。更关键的是,如果将另一个高阶组件应用到EnhancedComponent, 它也将改变componentDidUpdate, 第一个高阶组件的功能将被覆盖。这样的高阶组件也不能应用于函数组件,函数组件没有生命周期方法。 改变高阶组件是有漏洞的抽象,为了避免与其他高阶组件冲突,用户必须知道它是如何实现的。 高阶组件应该使用容器组件包装原组件的方式组合,而不是改变原组件。

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      // Wraps the input component in a container, without mutating it. Good!
      return <WrappedComponent {...this.props} />;
    }
  }
}

这个高阶组件与改变组件的版本具有相同的功能,同时又避免了改变组件的潜在风险。并且对函数组件和类组件都生效。因为它是纯函数,所以还能与其他高阶组件组合,甚至还能和它自己组合。 你可能已经注意到高阶组件和容器组件组合模式之间的相似性。容器组件是顶层和低层关注点分离责任策略的一部分。容器组件管理订阅、状态和传递属性到组件渲染UI。高阶组件就是对容器使用的一种实现。可以把高阶组件视作为一种带参数的容器组件定义。

约定:将不相关的属性传递给被包装的组件

高阶组件给组件添加功能。不应该彻底改变原有的协议。通过高阶组件生成的新组件也应该和被包装的组件拥有相似的接口。 高阶组件应该透传不相关的属性。大部分的高阶组件都应该包含一个像这样的渲染方法:

render() {
  // Filter out extra props that are specific to this HOC and shouldn't be
  // passed through
  const { extraProp, ...passThroughProps } = this.props;

  // Inject props into the wrapped component. These are usually state values or
  // instance methods.
  const injectedProp = someStateOrInstanceMethod;

  // Pass props to wrapped component
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

这个约定有助于尽可能地保证高阶组件的可扩展性和可复用性。

约定:最大化可组合性

并非所有的高阶组件看起来都一样。有时只需要接受一个参数,被包装组件。

const NavbarWithRouter = withRouter(Navbar);

通常,高阶组件也可以接受额外的参数。在这个来自于 Relay 的示例中,config参数用于指定组件的数据依赖:

const CommentWithRelay = Relay.createContainer(Comment, config);

最常见的高阶组件特点是这样:

// React Redux's `connect`
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);

这是什么意思?把它拆分开更容易理解是如何运行的。

// connect is a function that returns another function
const enhance = connect(commentListSelector, commentListActions);
// The returned function is a HOC, which returns a component that is connected
// to the Redux store
const ConnectedComment = enhance(CommentList);

换句话说,connect是一个返回高阶组件的高阶函数。 这种形式可能看起来更令人费解或者没有必要。但它拥有更有用的特性。像connect这种返回的单参数高阶组件具有显著的特点Component => Component. 输入类型和输出类型一样的函数组合起来更简单。

// Instead of doing this...
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// ... you can use a function composition utility
// compose(f, g, h) is the same as (...args) => f(g(h(...args)))
const enhance = compose(
  // These are both single-argument HOCs
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

诸如connect这种生成单参数高阶组件的增强型函数更容易被用作注解(decorators, 一种实验性的 JavaScript 协议) 很多第三方库提供compose工具函数,包括lodash(lodash.flowRight), ReduxRamda.

约定:包装显示名称方便调试

通过高阶组件创建的容器组件在 React Developer Tools 中的展示形式和其他组件一样。为了方便调试,选择合适的显示名称用来表达它是由高阶组件生成的。 最常见的技巧就是对被包装组件的显示名称进行简单包装。高阶组件如果叫做withSubscription. 被包装组件的显示名称是CommentList, 就使用这个展示名称WithSubscription(CommentList):

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

注意事项

高阶组件带来了一些注意事项,如果你是新手,这些注意事项不会立即显现出来。

不要在render方法内部使用高阶组件

React 的 diff 算法(称作协调)使用组件标识去决定是应该更新已存在的子树,还是卸载并挂载新的子树。如果从render返回的组件和上一次返回的组件相同(===),React 将递归地通过 diff 算法更新子树。如果不相同,之前的子树将被完全卸载。 正常情况下,不需要思考这个问题。但高阶组件就会有影响,这意味着无法在组件的render方法内将高阶组件应用于组件。

render() {
  // A new version of EnhancedComponent is created on every render
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // That causes the entire subtree to unmount/remount each time!
  return <EnhancedComponent />;
}

这不仅仅是性能的问题,重新挂载组件将导致组件的状态和其所有的子元素都会被丢弃。 相反,在组件定义的外部应用高阶组件的方式可以只创建一次新组件。因此其标识将在渲染中保持一致。无论如何这正是我们想要的。 在特殊场景情况下,需要动态地使用高阶组件,也可以在组件的生命周期方法或者构造函数中使用。

务必复制静态方法

有时在 React 组件中定义静态方法是非常有用的。Relay 容器暴露了一个静态方法getFragment,以方便GraphQL 片段的组合。 但当使用高阶组件时,原组件已经被容器组件包装。这就意味着新组件没有原组件的静态方法。

// Define a static method
WrappedComponent.staticMethod = function() {/*...*/}
// Now apply a HOC
const EnhancedComponent = enhance(WrappedComponent);

// The enhanced component has no static method
typeof EnhancedComponent.staticMethod === 'undefined' // true

为了解决这个问题,在返回之前必须把这些方法拷贝到容器组件。

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // Must know exactly which method(s) to copy :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

因此,这就需要明确地知道哪些方法需要被复制。可以使用 hoist-non-react-statics 自动复制不属于 React 自带的静态方法。

import hoistNonReactStatic from 'hoist-non-react-statics';
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

另一种有效的解决方案是单独从原组件导出静态方法。

// Instead of...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...export the method separately...
export { someFunction };

// ...and in the consuming module, import both
import MyComponent, { someFunction } from './MyComponent.js';

Refs 不会被传递

虽然高阶组件把所有的属性传递给被包装的组件非常方便,但是没法传递 Refs. 这是因为ref不是属性,就像key一样,是由 React 指定管理的。如果向由高阶组件生成的组件添加 ref, 那么这个 ref 指的是最外层容器组件实例,而不是被包装的组件。 这个问题的解决方案是使用React.forwardRef API(在 React 16.3 中有介绍)。了解更多请阅读forwardRef部分

本文译自 React 文档 《Higher-Order Components


欢迎关注我的微信公众号:乘风破浪的Coder