在React中高阶组件(HOC)是一种为了重用组件逻辑的增强技术。就本身来说,高阶组件并不是React API的一部分。它是由React的组合特性中衍生出的一种模式。
具体地说,高阶组件是一个接受组件并返回新组件的函数。
const EnhancedComponent = higherOrderComponent(WrappedComponent);
如同组件将属性转换为UI,而高阶组件将组件转换为另一个组件。
高阶组件在第三方React库中很常见,例如,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" 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上调用不同的方法,并呈现不同的输出。但它们的大部分实现是相同的:
- 在挂载时,将更改侦听器添加到
DataSource
。 - 在侦听器内部,每当数据源发生更改时就调用
setState
。 - 在卸载时,删除更改侦听器。
您可以想象,在大型应用程序中,订阅DataSource
和调用setState
的相同模式将反复出现。我们需要一个抽象,它允许我们在一个地方定义这个逻辑,并在多个组件之间共享它。这就是高阶组件擅长的地方。
我们可以编写一个函数来创建订阅DataSource
的组件,如CommentList
和BlogPost
。该函数将接收作为其参数之一的子组件,该组件将接收订阅的数据作为一个属性。我们把这个函数命名为withSubscription
。
const CommentListWithSubscription = withSubscription(
CommentList,
(DataSource) => DataSource.getComments()
);
const BlogPostWithSubscription = withSubscription(
BlogPost,
(DataSource, props) => DataSource.getBlogPost(props.id)
);
第一个参数是包装组件。第二个参数接收我们感兴趣的数据,即给定DataSource
和当前的属性。
当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} />;
}
};
}
请注意,HOC不会修改输入组件,也不会使用继承来复制其行为。相反,HOC通过将原始组件包装在容器组件中来组成它。HOC是一个没有任何副作用的纯函数。
这就是高阶组件!包装的组件接收容器的所有属性,以及一个新属性data
,用于呈现其输出。HOC不关心如何或为什么使用数据,而封装的组件不关心数据来自何处。
因为withSubscription
是一个普通的函数,您可以添加任意多的参数,也可以添加任意少的参数。例如,您可能希望将data
属性的名称变为可配置的,以进一步将HOC与包装的组件隔离。或者您可以接受一个配置shouldComponentUpdate
的参数,或者一个配置数据源的参数。这些都是可能的,因为HOC完全控制组件的定义方式。
与组件一样,withSubscription
和包装组件之间的契约完全是基于属性的。只要它们为包装的组件提供相同的属性,就可以轻松地将一个高阶组件切换为另一个高阶组件。例如,如果更改数据获取库,这可能会很有用。
不要改变原始组件。使用组合。
请抵制在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);
这里有一些问题。一是输入组件不能与增强组件分开重用。更重要的是,如果您对EnhancedComponent
应用另一个HOC,它也会修改componentWillReceiveProps
,那么第一个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与修改版本具有相同的功能,同时避免了潜在的冲突。它同样适用于类和函数组件。因为它是一个纯函数,它可以和其他高阶组件组合,甚至可以和它自己组合。
您可能已经注意到高阶组件与容器组件之间的相似性。容器组件是分离高层次和低层次关注之间的责任的策略的一部分。容器管理订阅和状态之类的事情,并将属性传递给处理呈现UI之类事情的组件。高阶组件使用容器作为其实现的一部分。可以将高阶组件看作参数化的容器组件定义。
约定:将不相关的属性传递给包装好的组件
高阶组件为组件添加特性。他们不应该大幅度修改约定。从HOC返回的组件应该与封装的组件具有类似的接口。HOC可能需要传递与其特定关注点无关的属性。大多数hoc包含一个渲染方法,看起来像这样:
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
函数返回的单参数HOC具有签名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
和其他增强样式的hoc用作装饰器,这是一个实验性的JavaScript提案。)
许多第三方库都提供了compose
函数,包括lodash(例如lodash. flowright)、Redux和Ramda。
约定:包装显示名称以便于调试
由hoc创建的容器组件与其他组件一样显示在React Developer工具中。要简化调试,请选择一个显示名称,该名称表示它是特定的结果。
最常用的技术是包装被包装组件的显示名称。因此,如果您的高阶组件名为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';
}
警告
高阶组件附带一些注意事项,如果您是新手,这些注意事项不会立即显现出来。
不要在render方法中使用高阶组件
React的扩散算法(称为协调)使用组件标识来决定是更新现有的子树还是丢弃它并挂载一个新树。如果从render
返回的组件与前一个render
返回的组件相同(===
),那么React递归地更新子树,将其与新的子树区分开来。如果它们不相等,则完全卸载前一个子树。
通常,你不需要考虑这个。但这对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 />;
}
这里的问题不仅仅是性能——重新挂载组件会导致组件及其所有子组件的状态丢失。
相反,应该在组件定义之外应用hoc,以便只创建一次结果组件。然后,在渲染期间它的身份将是一致的。不管怎样,这通常是你想要的。
在需要动态应用HOC的罕见情况下,也可以在组件的生命周期方法或其构造函数中进行。
必须复制静态方法
有时在React组件上定义静态方法很有用。例如,Relay 容器公开一个静态方法getFragment
,以便于组合GraphQL片段。
但是,当您将高阶组件应用于组件时,原始组件将被容器组件包装。这意味着新组件没有任何原始组件的静态方法。
// 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自动复制所有非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不起作用。这是因为和key
一样,ref
并不是一个真实的属性,它被React特殊处理。如果你为高阶组件的元素添加了引用,这个引用指向最外层的容器组件,而不是被包裹的组件。