超级面板
文章目录
最新文章
最近更新
文章分类
标签列表
文章归档

React - 高阶组件(Higher-Order Components)

原文:Higher-Order Components

高阶组件(HOC)是 React 中用于重用组件逻辑的高级技术。 HOC 本身不是 React API的一部分。它们是从 React 的组合特性产生的一种模式。

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

const EnhancedComponent = higherOrderComponent(WrappedComponent);

就如组件将 props 转换为 UI ,高阶组件将一个组件转换为另一个组件。

HOC 在第三方 React 库中很常见,例如 Redux 的 connect 和Relay的 createContainer

在这篇文章,我们讨论为什么高阶组件很有用并且怎样写高阶组件。

使用 HOC 来解决交叉问题

我们以前建议将 mixins 作为处理交叉问题的方法。我们已经意识到,比他们的价值,mixins 创造更多的麻烦。阅读了解 为什么我们离开 mixins,以及如何转换现有组件。

组件是 React 中代码复用的重要单元。但是,你会发现某些模式并不适合传统组件。

例如,假设你有一个 CommentList 组件订阅了外部数据源渲染评论的列表:

class CommentList extends React.Component {
constructor() {
super();
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 的组件,这个参数接收一个. 这个函数接收一个子组件作为其第一个参数参数,这个子组件接收订阅的数据为 prop ,我们给这个函数命名为 withSubscription

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

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

第一个参数是被包装的组件,第二个参数通过提供 DataSource 和当前的 props 可以获得我们感兴趣的数据。

CommentListWithSubscriptionBlogPostWithSubscription 被渲染,CommentListBlogPost 将会被传入一个使用当前的 DataSource 获取到的 dataprop

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

注意,HOC 并不会修改输入组件,也不会使用继承来复制其行为。相反,HOC 通过将原始组件 包装 在容器组件中来组合, HOC 是一种具有零副作用的纯函数。

就是这样!包装的组件接收容器的所有属性 ,以及用于渲染其输出的新属性 data。 HOC不关心数据怎样使用或为什么使用,被包装的组件不关心数据来自哪里。

正因为 withSubscription 是一个普通的功能,你可以添加任意数量的参数。例如,你可能希望使 data 属性的名称可配置,以进一步隔离 HOC 与包装组件。或者你可以接受配置 shouldComponentUpdate 或配置数据源的参数。这些都是可能的,因为 HOC 完全控制包装后组件的定义。

与组件一样,withSubscription 与包装组件之间的联系完全是基于 props 的。这样可以轻松地将一个 HOC 换成不同的 HOC,只要它们为包装的组件提供相同的 props。例如,更改数据获取库,这很有用。

不要突变原始组件,使用组合代替

抵制在 HOC 内部修改组件原型(或以其他方式突变组件)的诱惑。

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

这样做有几个问题。一个是输入组件不能与增强组件分开使用。更重要的是, 如果你应用另一个 HOC 去 增强组件,而其也改变了 componentWillReceiveProps,则第一个 HOC 的功能将被覆盖。同时,这样的 HOC 也不能工作在没有生命周期方法的函数式组件上。

这类突变的 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 具有与突变版本相同的功能,同时可以避免发生冲突。它与类和函数组件同样兼容。而且因为它是一个纯函数,它可以与其他 HOC ,甚至与它自己组合。

你可能已经注意到 HOC 和称为 容器组件 的模式之间的相似。容器组件是将职责分离到高级别和低级别关注点的策略的一部分。容器管理诸如订阅和状态的东西,并将道具传递给处理诸如渲染UI之类的组件。 HOC 使用容器作为其实现的一部分。你可以将 HOC 作为参数化容器组件定义。

约定:跳过不相关的 props ,直接将其传递给被包裹的组件

HOC 向组件添加功能。他们不应该大幅改变其接收的对象,并期望从 HOC 返回的组件与被包装的组件具有相似的界面。

HOC 应跳过与其无关的 props,大多数 HOC 包含一个看起来如下的渲染方法:

render() {
// Filter out extra props that are specific to this HOC and shouldn't be
// passed through
// 过滤出被指定给 HOC 使用的额外的 props 和 HOC 应当跳过的 props
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)(Comment);

如果你把它分开了,那就更容易看到发生了什么。

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

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

这张表可能看起来很混乱或不必要,但它有一个有用的属性。单参数的 HOC 像由 connect 函数返回的一样有签名 Component => Component. 输出类型与输入类型相同的函数很容易组合在一起。

// Instead of doing this...
const EnhancedComponent = connect(commentSelector)(withRouter(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
connect(commentSelector),
withRouter
)
const EnhancedComponent = enhance(WrappedComponent)

(同样的属性也允许 connect 和其他增强型 HOC 用作装饰器,装饰器是一个实验性 JavaScript 提案)。

组合(compose) 工具被很多第三方库提供,包括 lodash (as lodash.flowRight)、 ReduxRamda.

约定:包装 displayName 方便调试

由 HOC 创建的容器组件与其他组件一样显示在 React Developer Tools 中。为了方便调试,选择一个显示名称(displayName),通知它是 HOC 的结果。

常见的命名方式是用 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

React 的 diff 算法 (被称为 reconciliation) 使用组件标识来确定它是应该更新现有的子树还是将其丢弃并挂载一个新的。 如果 render 函数返回的组件和上一次渲染的组件是绝对相等的(===) , React 通过和新的子树比较来递归更新子树。如果它们不相等,则先前的子树将被完全卸载。

通常,你不需要考虑这个。但是对于HOC来说重要,因为这意味着你不能在组件的 render 方法中将 HOC 应用于组件:

render() {
// A new version of EnhancedComponent is created on every render
// EnhancedComponent1 !== EnhancedComponent2
// 每次渲染都被创建一个新的 EnhancedComponent
// EnhancedComponent1 !== EnhancedComponent2
const EnhancedComponent = enhance(MyComponent);
// That causes the entire subtree to unmount/remount each time!
// 这会导致整个子树每次卸载/重新挂载!
return <EnhancedComponent />;
}

这个问题不仅是性能的问题,重新挂载组件会导致该组件及其所有子项的状态丢失。

相反,将 HOC 应用于组件定义之外,以便生成的组件只能创建一次。这样,它的标识在渲染中是一致的,这正是是你想要的。

在需要动态应用 HOC 的罕见情况下,你还可以在组件的生命周期方法或其构造函数中执行此操作。

静态方法必须复制

有时,在 React 组件上定义一个静态方法很有用。例如,中继容器暴露了一个静态方法 getFragment,以便于构建 GraphQL 片段。

当你将 HOC 应用于组件时,原始组件将以容器组件包装。这意味着新组件没有原始组件的任何静态方法。

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

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

To solve this, you could copy the methods onto the container before returning it:

要解决这个问题,你可以在返回之前将该方法复制到容器上:

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 没有通过

虽然高阶组件是通过传递所有的 props 到被包装的组件,但是不可能传递 refs 属性。因为 refskey 一样并不是真正的属性,它是由 React 特别处理的。如果你添加一个 ref 引用到 HOC 产生的组件上,则 ref 将引用最外层的容器组件的实例,而不是被包装的组件。

如果你发现自己面临这个问题,理想的解决方案是找出如何避免使用 ref。有时候,React 新手的用户依赖 refs,在这种情况下,有 prop 会更好地工作。

也就是说,有些时候,refs 是必要的“逃生舱口”——然而 React 不会提供支持。input 元素获得焦点是命令式控制组件的一个示例。在这种情况下,一个解决方案是传递一个 ref 回调作为一个普通的 prop ,给它一个不同的名字:

function Field({ inputRef, ...rest }) {
return <input ref={inputRef} {...rest} />;
}

// Wrap Field in a higher-order component
const EnhancedField = enhance(Field);

// Inside a class component's render method...
<EnhancedField
inputRef={(inputEl) => {
// This callback gets passed through as a regular prop
this.inputEl = inputEl
} }
/>

// Now you can call imperative methods
this.inputEl.focus();

This is not a perfect solution by any means. We prefer that refs remain a library concern, rather than require you to manually handle them. We are exploring ways to solve this problem so that using an HOC is unobservable.

这不是一个完美的解决办法。我们正在探索解决这个问题的方法,以便使用 HOC 是不可观察的。