乐趣区

React高阶组件

高阶组件 (HOC) 是react为组件复用提供的一套更为先进的技术。HOC 并非 react 所提供的 api。他是 react 组件化思想的自然呈现。

具体的说,一个高阶组件是一个接受 component 并返回一个新的 component 的函数.


const EnhancedComponent = higherOrderComponent(WrappedComponent);

然而一个 componentprops转化为 UI, 高阶组件则是将一个 compnent 转化为另一个component

HOC 在第三方库中很常见,比如 Redux 中的 connect 以及 Relay 中的 createFragmentContainer


Use HOCs For Cross-Cutting Concerns

我们之前推荐使用 mixins 来处理 Cross-Cutting Concerns。现在我们已经意识到 mixins 带来了更多的问题。这里可以查阅到为什么我们移除了 mixins 以及你如何改造已使用的component

componentReact 中最主要的组件复用单元。However, you’ll find that some patterns aren’t a straightforward fit for traditional components.

比如, 你现在持有一个订阅了外部数据源, 用于渲染一项评论列表的 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.
  • 卸载后,移除事件监听。

你可以想象到, 在一个大型应用中, 这些相同的数据订阅以及 setState 将会不断的重复。我们希望抽象出这部分逻辑,来使得我们可以在一处定义之后可以在多处组件中分享这套逻辑。这个就是高阶组件所擅长的地方。

我们可以通过一个函数来创建这个 component,比如之前的CommentListBlogPost, 都向 DataSource 进行订阅。The function will accept as one of its arguments a child component that receives the subscribed data as a prop. Let’s call the function withSubscription:

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

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

传入的第一个参数就是被包裹的 component. 第二个参数接受我们所需要的数据, 这里是DataSource 以及props

CommentListWithSubscription 以及 BlogPostWithSubscription 需要进行渲染时,CommentListBlogPost 会将从 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} />;
    }
  };
}

注意: 我们并没有修改输入的 component, 或者使用继承来复制他的逻辑。高阶组件通过一个容器组件来 包裹 原始组件。高阶组件是一个没有任何副作用的纯函数。

这就是关键点!这个被包裹的原始组件接受容器的所有 props, 与新的props,data 以及任何它所需要的渲染的数据。高阶组件并不关心它的数据被怎样使用,而原始组件也不关心数据从哪里获取到。

由于 withSubscription 是一个普通函数, 你可以添加你所需要的任意参数。比如, 你希望 data 的读取的数据名可以配置,来进一步将被包裹的组件独立出来, 或者你也可以接受一个参数来配置shouldComponentUpdate, 又或者一个配置数据源的参数。以上所有操作都是可行的, 因为高阶组件对于被包裹的原始组件拥有完整的控制权限。

withSubscription和被包裹的组件之间是完整的 props-based。这使得他很容易将一个高阶组件替换到另一个, 只要可以提供同样的props 即可。这对于修改数据来源的库是非常有用的。

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

使用高阶组件来代替修改 componentprototype 的方法(或是其他修改原型的办法)

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);

这会产生一些问题. 其一是传入的组件无法被复用。更为关键的是,如果你使用另一个类似方式来修改组件, 它也会修改 componentWillReceiveProps 方法。那么前一次修改的逻辑就会被 == 覆盖 ==!这种方式对于函数组件也不会起作用,因为它们不存在生命周期函数。

修改原组件的方式来实现的 HOC 存在很多漏洞 – 使用者必须清楚的了解其实现方式才能够避免与其他类似 HOC 的冲突。

所以我们应该通过一个容器组件来包裹传入组件,使用组合的方式来实现HOC

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 拥有与继承版本同样的功能,同时避免了潜在的冲突。它在 class 以及 function component都能够良好的运作。而且由于这是一个纯函数,它能够与其他的 HOC 或者是它自己一起正常运作。

你可能已经听说过与 HOC 相似的模式 – 容器组件 (container components). 容器组件是分离高阶和低阶问题的策略中的一部分。容器管理着订阅,状态以及传递props 到渲染 UI 的组件中。HOC使用容器作为实现逻辑的一部分。你可以认为 HOC 是参数化的容器组件定义。

约定:传递非相关 Props 到被包裹的组件

HOC给组件添加了特性。他们并非彻底地改变其逻辑。我们期望从 HOC 中返回的组件依然是拥有相似接口的组件。

HOC应该将与自身无关的 props 传递下去。大多数 HOC 都包含一个类似下面的 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}
    />
  );
}

这个约定保证了 HOC 是尽可能灵活和可复用的。

约定:最大的可组合性

并非所有的 HOC 看上去都一样。有些时候他们仅仅接受一个单独的参数 – 被包裹的组件:

const NavbarWithRouter = withRouter(Navbar);

通常情况下,HOC接受额外的参数. 在这个来自 Relay 的例子中, 一个配置对象被用于指定一个组件的数据依赖。

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

最常见的 HOC 则是以下的样子:

// 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 模式的单参数HOC. 这样输出与输入类型一致的函数很容易组合在一起。

// 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 和其他加强形式的 HOC 被用作装饰器。这是一项实验性的 js 提案)

这个 compose 是提供给许多第三方库的工具函数,包括lodash(lodash.flowRight), Redux, 以及 Ramda.

约定:包裹应显示名称来便于调试

这个容器被 HOC 创建的容器组件和其他的组件一样,都可以在 React Developer Tools 展示出来。为了方便调试, 选择一个名称来表明其是一个 HOC 的返回组件。

而最常用的技术是包裹被包裹组件的显示名称。因此如果你的高阶组件的名称是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, 对于使用高阶组件,我们有以下不太常用的提醒

不要使用 HOC 来替换 render 方法

React的差异算法使用组件标识来决定需要更新当前的子树或是丢弃现有的重新创建一个全新的。如果这组件通过 render 返回的结果与之前的完全一致 (===),React 会通过差异算法来递归的更新其子树。如果他们并不相同, 之前的子树则会完整的卸载掉。

通常情况下, 您不需要考虑这个方面。但这对于 HOC 非常重要, 因为这意味着你无法应用 HOCrender方法到被包裹的组件中。

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 以及他的子节点都会丢失。
我们可以使用 HOC 在组件的外层应用使得最终创建的组件仅仅会创建一次。因此,它的标识将会在整个渲染过程中保持一致。这个通常就是你所想要的。

在极少数您需要动态的应用 HOC 时, 您也可以在组件的生命周期函数或者构造函数中完成。

必须复制组件的静态方法

有时候在组件中定义一个静态函数是非常有用的。比如,Relay对外暴露了 getFragment 的静态方法来促进 GraphQL 片段的组成。

当你应用了一个 HOC 到一个组件上, 尽管原始的组件被包裹到了一个容器组件中。这意味着新的组件并不包含原始组件上任何的静态方法。

// 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 来自动的拷贝所有的 non-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并不会被传递

尽管习惯上高阶组件会将所有的 props 传递给被包裹的组件, 但这个对引用并不起作用。这是因为 ref 并非是一个真实的 prop– 比如key, 它会由React 特别处理。如果你添加了一个 ref 到一个 HOC 返回的组件上, 这个 ref 实际持有的是最外层的容器组件,而非被包裹的组件。

这个的解决方法是使用React.forwardRefAPI(在 React 16.3 中引入). 关于React.forwardRef

退出移动版