Skip to content

06组件设计模式-高阶组件

组件复用的两种形式

  • 高阶组件
  • 函数作为子组件

  1. 高阶组件和函数子组件都是设计模式
  2. 可以实现更多场景的组件复用

高阶组件

高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件

const EnhancedComponent = higherOrderComponent(WrappedComponent);

对比组件将 props 属性转变成 UI,高阶组件则是将一个组件转换成另一个组件。

使用高阶组件(HOC)解决横切关注点

设想一下,在一个大型的应用中,这种从 DataSource 订阅数据并调用 setState 的模式将会一次又一次的发生。我们就可以抽象出一个模式,该模式允许我们在一个地方定义逻辑并且许多组件都能共享,这就是高阶组件的精华所在。

我们写一个函数,该函数能够创建类似 CommonList 和 BlogPost 从 DataSource 数据源订阅数据的组件 。该函数接受一个子组件作为其中的一个参数,并从数据源订阅数据作为 props 属性传入子组件。我们把这个函数取个名字 withSubscription:

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

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

第一个参数是包裹组件(wrapped component),第二个参数会从 DataSource 和当前 props 属性中检索应用需要的数据。

当 CommentListWithSubscription 和 BlogPostWithSubscription 渲染时, 会向 CommentList 和 BlogPost 传递一个 data 属性,该 data 属性的数据包含了从 DataSource 检索的最新数据:

jsx
// 函数接受一个组件参数
function withSubscription(WrappedComponent, selectData) {
  // 返回另一个组件
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        data: selectData(DataSource, props),
      };
    }

    componentDidMount() {
      // 订阅数据
      DataSource.addChangeListener(this.handleChange);
    }

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

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

    render() {
      // 使用最新的数据渲染组件
      // 注意此处将已有的props属性传递给原组件
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

注意,高阶组件既不会修改输入组件,也不会使用继承复制它的行为。相反,高阶组件是将原组件通过 包裹(wrapping) 在容器组件里面的方式来 组合(composes)。高阶组件就是一个没有副作用的纯函数。

就是这样!包裹组件接收容器的所有 props 属性以及一个新的 data 属性,并用 data 属性渲染输出内容。高阶组件并不关心数据是如何以及为什么被使用,而包裹组件也不关心数据来自何处。

因为 withSubscription 就是一个普通函数,你可以按需添加可多可少的参数。例如,你或许会想使 data 属性的名字是可配置的,进一步使高阶组件和包裹组件隔离开。或者你想要接收一个参数用于配置 shouldComponentUpdate 函数,或配置数据源。这些都可以的,因为高阶组件充分地控制新组件定义的方式。

和普通组件一样,withSubscription 和包裹组件之间的关联是完全基于 props 属性的。这使组件更换高阶组件变得轻松,只要他们提供相同的 props 属性给包裹组件即可。这可以用于你改变获取数据的库时,举例来说。

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

抵制诱惑,不要修改一个组件的原型(或以其它方式修改组件),在高阶组件内部。

jsx
function logProps(InputComponent) {
  InputComponent.prototype.componentWillReceiveProps = function(nextProps) {
    console.log('Current props', this.props);
    console.log('Next props', nextProps);
  };
  return InputComponent;
}

const EnhancedComponent = logProps(InputComponent);

上面的示例有一些问题。首先就是,输入组件不能够脱离增强型组件(enhanced component)被重用。更关键的一点是,如果你用另一个高阶组件来转变 EnhancedComponent ,同样的也去改变 componentWillReceiveProps 函数时,第一个高阶组件的功能就会被覆盖。这样的高阶组件对没有生命周期方法的函数式组件也是无效的。

更改型高阶组件(mutating HOCs)泄露了组件的抽象性 —— 使用者必须知道他们的实现方式,才能避免与其它高阶组件的冲突。

不应该修改原组件,高阶组件应该使用组合技术,将输入组件包裹到容器组件中:

jsx
function logProps(WrappedComponent) {
  return class extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log('Current props', this.props);
      console.log('Next props', nextProps);
    }
    render() {
      // 用容器组件组合包裹组件且不修改包裹组件,这才是正确的方式
      return <WrappedComponent {...this.props} />;
    }
  };
}

上面的组合型高阶组件却避免了发生冲突的可能。组合型高阶组件对类组件和函数式组件适用性同样好。且,因为它是一个纯函数,它和其他高阶组件,甚至它自身也是可以组合的。

高阶组件和容器组件类似。容器组件是专注于在高层次和低层次关注点之间进行责任划分的策略的一部分。容器组件会处理诸如数据订阅和状态管理等事情,并传递 props 属性给组件。组件处理渲染 UI 等事情。高阶组件使用容器组件作为实现的一部分。你也可以认为高阶组件就是参数化的容器组件定义。

约定

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

高阶组件给组件添加新特性。他们不应该大幅修改原组件的接口。预期,从高阶组件返回的组件应该与原包裹的组件具有类似的接口。

高阶组件应该传递与它要实现的功能点无关的 props 属性,大多数高阶组件都包含一个如下的 render 函数:

jsx
render() {
  // 过滤掉与高阶函数功能相关的props属性,不再传递
  const {extraProp, ...passThroughProps} = this.props
  // 向包裹组件注入props属性,一般都是高阶组件的state状态或实例方法
  const injectedProp = someStateOrInstanceMethod

  // 向包裹组件传递props属性

  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  )
}

这种约定能确保高阶组件最大程度的灵活性和可重用性。

约定:最大化使用组合

并不是所有的高阶组件看起来都是一样的,有时,它们仅仅接受一个参数,即包裹组件:

jsx
const NavbarWithRouter = withRouter(Navbar);

一般而言,高阶组件会接受额外的参数。在下面这个来自 Relay 的示例中,可配置对象用于指定组件的数据依赖关系:

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

大部分常见高阶组件的函数签名如下所示:

jsx
// React Redux's `connect`
const connectedComment = connect(
  commentSelector,
  commentActions,
)(Comment);

这是什么?如果你把它剥开,就很容易看明白到底是怎么回事了

jsx
// connect是一个返回函数的函数 - 高阶函数
const enhance = connect(
  commentListSelector,
  commentListActions,
);
// 返回的函数就是一个高阶组件,该高阶组件返回一个与Redux store关联起来的新组件
const ConnectedComment = enhance(CommentList);

这种形式有点让人迷惑,有点多余,但是它有一个有用的属性。那就是,类似 connect 函数返回的单参数的高阶组件有着这样的签名格式, Component => Component.输入和输出类型相同的函数是很容易组合在一起。

jsx
// 不要这样做...
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent));

// 你可以使用一个功能组合工具
// compose(f, g, h) 和 (...args) => f(g(h(...args))) 是一样的
const enhance = compose(
  // 这些都是单参数的高阶组件
  withRouter,
  connect(commentSelector),
);

const EnhancedComponent = enhance(WrappedComponent);

connect 函数产生的高阶组件和其他增强型高阶组件具有同样的被用作装饰器的能力。包括 lodash,Redux 和 Ramda 在内的许多第三方库都提供了类似 compose 功能的函数。

约定:包装显示名字以便于调试

高阶组件创建的容器在 React Developer Tools 中的表现和其他的普通组件是一样的。为了便于调试,可以选择一个好的名字,确保能够识别出它是由高阶组件创建的新组件还是普通的组件。

最常用的技术就是将包裹组件的名字包装在显示名字中。所以,如果你的高阶组件名字是 withSubscription,且包裹组件的名字是 CommentList,那么就是用 WithSubscription(CommentList)这样的显示名字:

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

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

注意事项

不要在 render 函数中使用高阶组件

React 使用的差异算法使用组件标识确定是否更新现有的子对象树或丢掉现有的子树并重新挂载。如果 render 函数返回的组件和之前 render 函数返回的组件是相同的,React 就递归地比较新子对象树和旧的子对象树的差异,并更新旧的子对象树。如果它们不相等,就会完全卸载掉旧的子对象树。

一般而言,你不需要考虑这些细节的东西。但是它对高阶函数的使用有影响,那就是你不能在组件的 render 函数中调用高阶函数。

jsx
render() {
  // 每一次render函数调用都会创建一个新的EnhancedComponent实例
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedCompoent = enhance(MyComponent)
  // 每一次都会使子对象完全被卸载或移除
  return <EnhancedComponent />
}

这里产生的问题不仅仅是性能问题 —— 还有,重新加载一个组件会引起原有组件的所有状态和子组件丢失。

相反,在组件定义外使用高阶组件,可以使新组件只出现一次定义。在渲染的整个过程中,保证都是同一个组件。无论在任何情况下,这都是最好的使用方式。

在很少的情况下,你可能需要动态的调用高阶组件。那么你就可以在组件的构造函数或生命周期函数中调用。

必须将静态方法做拷贝

有时,给组件定义静态方法是十分有用的。例如,Relay 的容器就开放了一个静态方法 getFragment 便于组合 GraphQL 的代码片段。

当使用高阶组件包装组件,原始组件被容器组件包裹,也就意味着新组件会丢失原始组件的所有静态方法。

jsx
// 定义静态方法
WrappedComponent.staticMethod = function() {
  /**/
};

// 使用高阶组件
const EnhancedComponent = enhance(WrappedComponent);

// 增强型组件没有静态方法
typeof EnhancedComponent.staticMethod === 'undefined'; // true

解决这个问题的方法就是,将原始组件的所有静态方法全部拷贝给新组件:

jsx
function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    /**/
  }
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

这样做,就需要你清楚的知道都有哪些静态方法需要拷贝。你可以使用 hoist-non-react-statics 来帮你自动处理,它会自动拷贝所有非 React 的静态方法:

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

另外一个可能的解决方案就是分别导出组件自身的静态方法。

jsx
// 替代……
MyComponent.someFunction = someFunction;
export default MyComponent;

// ……分别导出……
export { someFunction };

// ……在要使用的组件中导入
import MyComponent, { someFunction } from './MyComponent.js';

Refs 属性不能传递

一般来说,高阶组件可以传递所有的 props 属性给包裹的组件,但是不能传递 refs 引用。因为并不是像 key 一样,refs 是一个伪属性,React 对它进行了特殊处理。如果你向一个由高阶组件创建的组件的元素添加 ref 应用,那么 ref 指向的是最外层容器组件实例的,而不是包裹组件。

如果你碰到了这样的问题,最理想的处理方案就是搞清楚如何避免使用 ref。有时候,没有看过 React 示例的新用户在某种场景下使用 prop 属性要好过使用 ref。

现在我们提供一个名为 React.forwardRef 的 API 来解决这一问题(在 React 16.3 版本中)。在 refs 传递章节中了解详情。

共 20 个模块,1301 篇 Markdown 文档。