高阶组件(HOC)是React中组件重用的一种高级技术。HOC本身并不是React API的一部分。它是从React可组合性质中冒出来的一种模式。
具体地讲,一个高阶组件是一个函数,它输入一个组件,然后返回一个新的组件。
const EnhancedComponent = higherOrderComponent(WrappedComponent);
组件会把属性转化成UI,而高阶组件会把一个组件转化成另外一个组件。
HOC是React第三方库常用的一种技术,比如Redux的connect和Relay的createFragmentContainer。
这篇文档中,我们会讨论高阶组件为什么有用,并且教你写出自己的高阶组件。
注意 :
我们以前推荐使用混入(mixin)作为解决切面问题的方法。但是现在我们意识到跟带来的好处相比,混入带来了更多的麻烦。这里介绍了我们为什么放弃了混入,以及怎样移植你现有的组件。
React中,组件是基本的代码单元。然而,你会发现有些模式并不直接适用于传统组件。
比如说,你有一个CommentList
组件,订阅了一个外部数据源用来渲染一个评论列表:
class CommentList extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
// 这里"DataSource"是一个全局的数据源
comments: DataSource.getComments()
};
}
componentDidMount() {
// 组件挂载时订阅数据源的变化
DataSource.addChangeListener(this.handleChange);
}
componentWillUnmount() {
// 组件卸载时删除事件监听器订阅
DataSource.removeChangeListener(this.handleChange);
}
handleChange() {
// 数据源发生变化时更新状态
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
的模式总是会反复出现。所以,我们想要这样一种抽象,它能够让我们在一个地方定义这个逻辑然后在很多组件中共用。我们认为这正是高阶组件的优点。
我们可以写一个创建组件的函数,比如创建CommentList
和BlogPost
这样的组件,同时订阅到DataSource
上。这个函数会把它的一个参数当成一个子组件,并且这个子组件会通过自己的属性接收所订阅的数据。我们这里把这个函数叫做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
获取的最新的数据:
// 这个函数接收一个组件
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() {
// ... 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切换到另外一个,只要它们向所封装的组件提供了同样的属性。比如你正想换个数据获取三方库,这个方法就很有用。
在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
上应用另外一个也要修改componentWillReceiveProps
的HOC,第一个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组合,甚至可以跟自己组合。
HOC向一个组件添加了功能。他们不应该较大幅度地修改它的约定(contract)。HOC所返回的组件和所被包装的组件的接口应该很相似。
HOC应该把跟自己的关注点无关的属性(props)传递给所包装的组件。绝大多数HOC会包含类似下面的一个渲染方法:
render() {
// 将 this.props 分成两类 :
// extraProp 将是该HOC所关注的属性,
// 而除了 extraProp 之外的其他属性是该HOC不关注的,需要将它们继续传递
const { extraProp, ...passThroughProps } = this.props;
// 这里是该HOC将要向所封装的组件计划注入的属性,也就是那些增强:比如一些状态,或者实例方法
const injectedProp = someStateOrInstanceMethod;
// 封装输入组件,并向其传递属性,添加增强属性
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
是一个高阶函数,它返回了一个高阶组件!
这种形式看起来有点迷惑人,或者有点没必要,但它有一个有用的特性。单参数HOC,如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 Tools中显示。为了方便调试,可以为其选择一个显示名称表明这是一个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新手,你需要注意,使用高阶组件有一些使用警告,需要留神。
React的差分(diffing )算法(也被叫做一致化算法,reconciliation)使用组件标识符(identity)来确定是更新一个已经存在的子树,还是直接丢其他然后挂在一个新的。如果 render
方法返回的组件跟之前渲染使用的组件相等(===),React会将它跟新的组件递归比较和进行子树更新。如果它们不等,之前的子树会准备整个卸载掉。
一般来说,你不需要关心这一点。但是对于HOC,如果你在一个组件的render
方法内使用了HOC,问题就出现了:
render() {
// A new version of EnhancedComponent is created on every render
// EnhancedComponent1 !== EnhancedComponent2
// 每次 render 调用产生一个新的 EnhancedComponent 实例
// 前后两次 render 方法调用中产生的两个实例一定会有 :
// EnhancedComponent1 !== EnhancedComponent2
const EnhancedComponent = enhance(MyComponent);
// That causes the entire subtree to unmount/remount each time!
// 这会导致每次 render 方法调用时整个子树被卸载和再挂载!
return <EnhancedComponent />;
}
这个问题不仅关乎性能 – 重新挂载一个组件还会导致这个组件的状态和他所有的子节点都被丢掉了。
相反,在组件定义外应用HOC可以让结果组件仅被创建一次。然后,它的标识符会在组件的多次render
方法调用之间不变。这才是通常我们想要的效果。
在这种不多见的需要动态应用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这种工具自动复制所有非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';
使用高阶组件的惯用做法是用来传递所有属性给被封装组件,但是这个做法对ref
无效。这是因为ref
并非真正的属性 — 它就像是 key
,它会被React特殊对待和处理。如果你某个HOC返回的组件的某个元素增加了一个ref
属性,那么这个ref
指向的实例是最外层的容器组件,而不是被封装组件。
这个问题的解决方案是使用React.forwardRef
API(在React 16.3中引入),这里有关于这个解决方案更多的内容。