React高级指引:高阶组件

2023-10-30

上一节:Fragments

引言

在React中高阶组件(HOC)是用于复用组件逻辑的一种高阶技巧。高阶组件自身并不是React API的一部分。它是基于React组合特性而设计的一种模式。

具体来说,高阶组件就是一个接收组件作为参数并返回一个新组件的函数

const EnhancedComponent = higherOrderComponent(WrappedComponent);

组件将props转化成UI,而高阶组件则将组件转化成另一个组件。

高阶组件在第三方库中是十分常见的,比如Redux的connect,和Relay的createFragmentContainer

在本节中我们将讲述为什么高阶组件是有用的,如何来构建我们自己的高阶组件。

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

注意:
我们之前推荐使用mixins来解决横切关注点问题。但是现在我们已经了解到使用mixins会带来更多的问题。阅读更多了解为什么我们要抛弃mixins以及如何迁移已经编写好的组件。

组件是React代码复用的基本单位。但是在实践过程中中你会发现传统的组件无法直接适应某些模式。

比如,你现在有一个CommentList组件,它接收一个外部数据源来渲染评论:

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource"是某些全局数据源
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // 注册change事件监听器
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 清除监听器
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // 当数据源改变时更新state
    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添加change监听器;
  • 在监听器内部,在数据源更改时调用setState;
  • 在组件卸载时移除监听器。

你可以想象,在一个大型应用中,这种订阅DataSource和调用setState的行为是一直存在的。我们想要一个抽象方法,能够只在一个地方编写逻辑,然后把这段逻辑共享给需要的组件。这就是高阶组件擅长的地方。

我们现在来创建一个函数,这个函数能够创建CommentListBlogPost,订阅DataSource。这个函数将会接收一个子组件作为它的参数之一,这个子组件将会接收订阅数据作为props。现在让我们称这个函数为withSubscription

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

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

第一个参数是被包裹的组件。第二个参数根据我们给定的DataSource和prop返回我们需要的数据。

CommentListWithSubscriptionBlogPostWithSubscription被渲染时CommentListBlogPost将会接收从当前DataSource中计算得到的数据作为data prop:

// 这个函数接收一个组件作为参数...
function withSubscription(WrappedComponent, selectData) {
  // ...并返回另一个组件...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      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() {
      //... 用最新的数据渲染包裹的组件
      //注意我们会传递其他数据
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

注意高阶组件不会修改输入的组件,也不会用继承来复制它的行为。相反的,高阶组件将原始组件包裹在容器组件中。一个高阶组件应该是纯函数,没有任何副作用。

被包裹的组件从容器组件中获取了所有需要的props,同时也接收一个用于渲染的prop data。高阶组件不关心data是怎么被使用的,而被包裹的组件不管数据从哪来的。

这是因为withSubscription是一个正常的函数,你可以任意添加你想要的参数。比如你想要data prop的名字是可配置的。以进一步将高阶组件和被包裹的组件分离。或者你可以接受一个配置shouldComponentUpdate的参数,或者能够配置数据源的参数。由于高阶组件可以控制如何定义组件,所以这些都是可行的。

就像组件一样,withSubscription与被包裹组件的联系是完全基于props的。这种关系使得更换高阶组件十分简单,只要能够提供同样的props给被包裹组件就可以了。比如这在你更换数据获取的第三方库时非常有用。

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

不要试图在高阶组件中修改组件的原型(prototype)或用其他任何方式修改它。

function logProps(InputComponent) {
  InputComponent.prototype.componentWillReceiveProps = function(nextProps) {
    console.log('Current props: ', this.props);
    console.log('Next props: ', nextProps);
  };
  //返回原始数组,暗示它已经被修改
  return InputComponent;
}

// EnhancedComponent将会在接收到prop时在控制台打印结果
const EnhancedComponent = logProps(InputComponent);

这里有几个问题。一是输入组件无法像高阶组件增强之前使用了。更重要的是,如果你将EnhancedComponent包裹在另一个可以修改EnhancedComponent的高阶组件中,那么第一个高阶组件的功能将被覆盖!同时这个高阶组件无法应用于没有生命周期的函数组件。

修改输入组件的高阶组件是一种糟糕的抽象方式——调用者必须知道它们是如何实现的以避免与其他高阶组件发生冲突。

相比于修改,高阶组件应该使用组合,将输入组件包裹在一个容器组件中:

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} />;
    }
  }
}

上面的高阶组件的功能和修改输入组件的高阶组件功能相同,但是避免了潜在的冲突问题。它能够很好地运用于class组件和函数组件。而且由于它是一个纯函数,它可以和其他高阶组件组合使用,甚至和它自身组合使用。

也许你已经发现了高阶组件和容器组件之间的相同之处。容器组件是分离高层关注和底层关注的策略之一。容器组件使用订阅和state管理事务,并且传递props给那些需要数据的组件。高阶组件使用容器组件作为实现自身的一部分。可以将高阶组件当作是参数化的容器组件。

约定:将不想管的props传递给被包裹的组件

高阶组件给组件添加了一些特性。它们自身不能大幅度修改约定。通常我们希望从高阶组件返回的组件与输入组件有相似的交互界面。

高阶组件应该透传与自身无关的props。大部分高阶组件包含了类似于下面的render方法:

render() {
  // 过滤出与高阶组件有关的额外props并且不透传它们。
  const { extraProp, ...passThroughProps } = this.props;

  // 将props注入被包裹组件。这些props通常是
  // state值或者实例函数
  const injectedProp = someStateOrInstanceMethod;

  // 将props传递给被包裹组件
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

这个约定确保了高阶组件是灵活可复用的。

约定:最大化可组合性

并不是所有的高阶组件都看起来是一样的。有时候高阶组件只接受一个参数:被包裹组件:

const NavbarWithRouter = withRouter(Navbar);

通常高阶组件都会接收额外的参数。在下面的关于Relay的例子中,额外的参数config对象被用来声明组件的数据依赖:

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

最常见高阶组件签名如下:

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

这是什么鬼玩意??但是你把它分开,就可以更清晰地了解到它的机制。

// connect是一个函数,它返回了另一个函数
const enhance = connect(commentListSelector, commentListActions);
// 返回的函数是一个高阶组件,它返回了一个
//与Redux store相关联的组件
const ConnectedComment = enhance(CommentList);

换句话说,connect是一个高阶函数,它返回了一个高阶组件!

这种形式可能看起来让人困惑或不必要,但是它有一个非常有用的属性。就像connect函数返回的单一参数高阶组件一样,它有一个签名Component => Component。输入类型与输出类型相同的函数是非常容易组合的。

// 不要这样...
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和其他高阶组件承担装饰者的角色,装饰者是JavaScript一项实验性的提案。)

许多第三方库都提供了compose工具函数,比如lodash(lodash.flowRight),Redux,Ramda

约定:包裹显示名称一遍轻松调试

由高阶组件创建的容器组件会在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';
}

注意事项

高阶组件有一些注意事项,对于刚接触React的人来说可能不容易法相。

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

React的diff算法使用组件的身份标志来决定是否更新子组件树还是丢弃并重新挂载新的子组件树。如果render方法返回的组件与上一次渲染的组件一致(===),React将会根据diff算法在子组件树和新的子组件树进行递归更新。如果它们不是相同的,那么子组件树将会被完全卸载。

正常来说,你不需要考虑这个问题。但是这对高阶组件来说很重要,因为者意味着你不能在组件的render方法中使用高阶组件来返回组件:

render() {
  //每一次更新时都会创建一个新的EnhancedComponent
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  //这样做会导致整个子组件树在每次渲染时都被卸载然后重新挂载!
  return <EnhancedComponent />;
}

这不仅仅是性能问题——重新挂载组件将会导致它的state状态和所有的子元素的丢失。

相反,如果在组件之外调用高阶组件,那么组件只会创建一次。在这之后,组件的身份标志将会在整个渲染过程中保持一致。这才是我们想要的。

尽管很少遇到,但有时候你还是会需要动态地使用高阶组件,你可以在组件的生命周期方法或者构造函数中使用高阶组件。

务必复制静态方法

有时候在React组件中定义一个静态方法是十分有用的。比如,Relay容器暴露了一个getFragment静态方法来促进对GraphQL片段的组合。

当你将高阶组件应用于组件时,原始组件将被包裹在容器组件中。但这意味着新的组件将不持有任何原始组件的静态方法。

// 定义一个静态方法
WrappedComponent.staticMethod = function() {/*...*/}
// 现在调用一个高阶组件
const EnhancedComponent = enhance(WrappedComponent);

// 新的增强组件是没有静态方法的
typeof EnhancedComponent.staticMethod === 'undefined' // true

为了解决这个问题,你可以在返回这个容器组件之前将这些静态方法复制给它:

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  //必须要知道需要复制哪些方法
  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;
}

另一种解决方法是再另外导出这个静态方法:

// 不要这么做...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...单独地将方法导出...
export { someFunction };

// ...在消费模块中,将这两者都引入
import MyComponent, { someFunction } from './MyComponent.js';

Refs不会被透传

尽管高阶组件的规则是将所有的props都透传给被包裹组件,但对refs例外。这是因为refs不是真正的prop,就像key一样,它被React特殊对待。如果你为一个高阶组件产生的组件添加了ref,那么这个ref引用的是最外层的容器组件而不是被包裹的组件。

解决方案是使用React.forwardRef API(在React16.3中引进),在Refs转发中了解更多。

上一节:Fragments
下一节:与第三方库协同

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

React高级指引:高阶组件 的相关文章