[译]React高级话题之高阶组件

前言

本文为意译,翻译过程中掺杂本人的理解,如有误导,请放弃继续阅读。

原文地址:Higher-Order Components

正文

高阶组件(后文中均以HOCs来指代)是React生态里面的一种用来复用组件逻辑的高级技术。HOCs本身并不是React API的一部分,而是一种从React的可组合性中产生的模式。

具体来说,HOCs其实就是一个函数。只不过这个函数跟编程语言中普通的函数不同的是,它接受一个React组件作为输入,返回了一个新的React组件。

const EnhancedComponent = higherOrderComponent(WrapperComponent)
复制代码

我们从转化的角度可以这么说:“如果说,React组件是将props转化为UI,那么HOCs就是将一个旧的组件转化为一个新的组件(一般情况下,是作了增强)”。HOCs在第三方类库中很常见,比如:Redux的connect方法,Relay的createFragmentContainer

在这个文档里面,我么将会讨论为什么HOCs这么有用和我们该怎样写自己的高阶组件。

使用HOCs来完成关注点分离

注意:我们之前一直在推荐使用mixins来完成关注点分离。但是后面我们发现了mixins所带来的问题远大于它存在所带来的价值,我们就放弃使用它了。查阅这里,看看我们为什么放弃了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} />;
  }
}
复制代码

严格上来说,CommentList和BlogPost是不完全相同的。它们分别在DataSource上调用不同的方法,渲染了不同的UI。但是,除了这些点之外,它们大部分是相同的:

  • 在挂载之后,都往DataSource里面注册了一个change listener。
  • 在change listener里面,当数据源发生改变时都调用了setState。
  • 在卸载之前,都要移除change listener。

你可以想象,在一个大型的项目中,这种模式的代码(订阅一个DataSource,然后在数据发生变化的时候,调用setState来更新UI)会到处出现。我们需要将这种逻辑抽取出来,定义在单独的地方,然后跨组件去共用它。而,这恰恰是HOCs要做的事情。

我们可以写一个函数用于创建像CommentList和BlogPost那样订阅了DataSource的组件。这个函数将会接收子组件作为它的一个参数。然后这个子组件会接收订阅的数据作为它的prop。我们姑且称这个函数为withSubscription。

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属性将会得到从订阅源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} />;
    }
  };
}
复制代码

注意,HOCs并没有篡改我们传递进入的组件,也没有继承它然后复制它的行为。HOCs只是单纯地将我们传递进入的组件包裹在一个容器组件(container component)。一个高阶组件是纯函数,不能包含任何的副作用。

就这么多。wrapped component接受从container component传递下来的所有props,与此同时,还多了一个用于渲染最终UI的,新的prop:data。HOC它不关注你怎么使用数据,为什么要这样使用。而wrapped component也不关心数据是从哪里来的。

因为withSubscription只是一个普通函数,你可以定义任意多的参数。举个例子,你想让data 属性变得更加的可配置,以便将HOC和wrapped component作进一步的解耦。又或者你可以增加一个参数来定义shouldComponentUpdate的实现。这些都是可以做到的,因为HOC只是一个纯函数而已,它对组件的定义拥有百分百的话语权。

正如React组件一样,高阶组件withSubscription跟wrapped component的唯一关联点只有props。这样的清晰的关注点分离,使得wrapped component与其他HOC的结合易如反掌。前提是,另外一个HOC也提供相同的props给wrapped component。就拿上面的例子来说,如果你切换data-fetching类库(DataSource),这将会是很简单的。

戒律

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

在HOC的内部,要抵制修改原始组件的prototype的这种诱惑(毕竟这种诱惑是触手可及的)。

function logProps(InputComponent) {
  InputComponent.prototype.componentWillReceiveProps = function(nextProps) {
    console.log('Current props: ', this.props);
    console.log('Next props: ', nextProps);
  };
  // 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应用到另外一个HOC,恰好这个HOC在原型链上也做了同样的修改。那么,你第一个HOC的功能就被覆盖掉了。
  • 上述例子中的写法不能应用于function component。因为function component没有生命周期函数。
  • 造成抽象封装上的漏洞。一旦你这么做了,那么使用者为了避免冲突,他必须知道你到底在上一个HOC对wrapped component 做了什么样的修改,以免他也作出同样的修改而导致冲突。

相对于修改,HOCs应该使用组合来实现。也就是说,把传递进来的组件包裹到container component当中。

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log('Current props: ', this.props);
      console.log('Next props: ', nextProps);
    }
    render() {
      // Wraps the input component in a container, without mutating it. Good!
      return <WrappedComponent {...this.props} />;
    }
  }
}
复制代码

上面这个组合版的HOC跟修改版的拥有相同的功能。相比修改版的HOC,它很好地避免了一些潜在的冲突。同时,它也能很好地跟function component和class component组合使用。最后,它也能很方便地跟其他HOC组合使用,或者甚至跟它自己。

container component是对高层级关注点与低层级关注点进行职责分离策略的一部分。在这个策略里面,container component负责管理数据订阅和保存state,并且将所有的数据衍生为props传递给它的子组件,然后子组件负责渲染UI。HOCs把container模式作为了它实现的一部分。你可以理解为HOC是参数化的container component定义。

2.不要在React组件的render方法中使用HOCs

React的diff算法(也称之为reconciliation)是根据component的唯一标识(component identity )来决定这个组件是否应该从已经存在的子组件树中更新还是彻底弃用它,挂载一个新的组件。如果一个component的render函数的返回值全等于(===)另外一个组件render函数的返回值,那么React就认为他们是同一个组件,然后递归地更新这个组件的子组件树。否则的话,就完全卸载之前的那个组件。

通常来说,你不需要考虑这些东西。但是,在使用HOCs的时候,你需要做这样的考虑。因为你不能在一个组件的render方法里面使用HOC。如下:

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 />;
}
复制代码

像上面这样的做法会导致性能上的问题。什么问题呢?那就是重新挂载一个组件会导致无法利用现有的组件state和子组件树。 而我们想要的恰恰相反。我们想要的增强后的组件的标识在多次render调用过程中都是一致的。要想达成这种效果,我们需要在组件定义的外部去调用HOC来仅仅创建一次这个增强组件。

在极少数的情况下,你可能想动态地使用HOC,你可以在组件的非render生命周期函数或者constructor里面这么做。

约定俗成

1. 将(HOC)非相关的props传递给Wrapped component

HOCs本质就是给组件增加新的特性。他们不应该去大幅度地修改它与wrapped component的契约之所在-props。我们期待从HOC返回的新的组件与wrapped component拥有相同的接口(指的也是props)。

HOCs应该将它不关注的props原样地传递下去(给增强后的新组件)。大部分的HOCs都会包含一个render方法,这个render方法看起来是这样的:

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}
    />
  );
}
复制代码

我们这么做的目的是让HOCs能做到尽可能的可扩展和可复用。

2. 可组合性最大化

并不是所有的HOCs看起来都是一样的。有一些HOC仅仅接收一个参数-wrapped component。

const NavbarWithRouter = withRouter(Navbar);
复制代码

一般来说,HOCs会接收其余的一些参数。比如说Relay的createContainer方法,它的第二个参数就是一个配置型的参数,用于指明组件的数据依赖。

const CommentWithRelay = Relay.createContainer(Comment, config);
复制代码

HOCs最常见的函数签名是这样的:

// 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就是一个返回高阶组件的高阶函数。(注意,原文这里用的是higher-orderfunction 和 higher-order component)!

这种写法可能看起来是让人疑惑或者是多余的,实际上,它是有用的。从函数式编程的角度来讲,那种参数类型和返回类型一致的单参数函数是很容易组合的。而connect之所以要这么实现,也是基于这种考虑。也就是说,相比这种签名的函数(arg1,component)=> component,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的这种类型写法的函数可以被用作ES7提案特性之一的装饰器(decorators)。

像compose这种工具函数很多第三方的类库都会提供,比如说lodash(loadash.flowRight),ReduxRamda

3. 给HOC追加displayName属性

那个被HOCs创建的container component在React Developer Tools中长得跟普通的组件是一样的。为了更方便调试,我们要选择一个display name 给它。

最常见的做法是给container component的静态属性displayName直接赋值。假如你的高阶组件叫withSubscription,wrapped component叫CommentList,那么container component的displayName的值就是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';
}
复制代码

注意点

1. 记得要把Wrapped component的静态方法复制到增强后的组件中去

有时候,在React component上定义一个静态方法还是挺有用的。比如说,Relay container就暴露了一个叫getFragment静态方法来方便与GraphQL fragments的组合。

当你把一个组件传递进HOC,也仅仅意味着你把它包裹在container component当中而已。因为增强后的组件并没有“继承”原组件的所有静态方法。

// 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
复制代码

为了解决这个问题,你可以在HOC的内部先将原组件的静态方法一一复制了,再返回出去。

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';
复制代码

2. 记得将ref属性传递下去

虽然说,将所有的prop都原样传递下去是实现HOC的一个惯例,但是这种传递对ref这个属性不起作用。那是因为,严格意义上说,ref并不是一个真正的prop,key也不是。它俩都是专用于React的内部实现的。如果你在一个由HOC创建并返回的组件(也就是说增强后的组件)上增加了ref属性,那么这个ref属性指向的将会是HOC内部container component最外层那个组件的实例,而不是我们期待的wrapped component。

针对这个问题的解决方案是使用React.forwardRef这个API(在React的16.3版本引入的)。关于React.forwardRef,你可以查阅更多

猜你喜欢

转载自juejin.im/post/5bfcedd851882527796ab939