高阶组件(higher-order component, HOC)是 React 中一个复用组件逻辑的高级技巧。高阶组件本身不是 React API 的一部分。它是从 React 组合生态中产生的一种模式。 具体来说,高阶组件是接受一个组件并生成一个新的组件的函数
const EnhancedComponent = higherOrderComponent(WrappedComponent);
与组件把属性渲染成 UI 不同的是,高阶组件通过组件生成一个新的组件。 高阶组件在第三方组件库中非常常见,例如 Redux's connect 和 Relay's createFragmentContainer 在这篇文档中,我们将讨论高阶组件的意义以及如何自定义高阶组件
用高阶组件解决横切关注点问题
注意 我们以前推荐用混入(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这种相似的模式一次又一次的出现。我们想要允许在一处定义逻辑然后在多个组件间共享的抽象。这就是高阶组件擅长的。
我们可以实现一个创建组件(例如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取回的最新数据。
// 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} />;
}
};
}
注意高阶组件不会改变原组件,也不是通过继承去复制它的功能。相反,高阶组件是由容器组件包装原组件的方式组合而成的。高阶组件是没有副作用的纯函数。
而且,被包装的组件接受容器组件的所有属性,同时也接受新的用于渲染输出的data属性。高阶组件不关心数据如何使用,被包装的组件不关心数据来自于哪里。
因为withSubscription是纯函数,你可以添加任何想要的参数。例如,你可能为了进一步实现和被包装组件解耦而想要让data属性名称可配置。或者你想要接受一个参数配置shouldComponentUpdate或者配置数据源。以上这些都是有可能的,因为高阶组件对生成的新组件定义拥有完全控制的能力。
就像组件,withSubscription和被包装组件直接的联系是完全基于属性的。只要为被包装组件提供相同的参数,那么更换高阶组件将变得非常容易。例如数据拉取这样的库,这将变得非常有用。
不要改变原始组件,而应该使用组合
不要试图在高阶组件中修改组件原型(或者以其他方式改变它)
function logProps(InputComponent) {
InputComponent.prototype.componentDidUpdate = function(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
};
// 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, 它也将改变componentDidUpdate, 第一个高阶组件的功能将被覆盖。这样的高阶组件也不能应用于函数组件,函数组件没有生命周期方法。
改变高阶组件是有漏洞的抽象,为了避免与其他高阶组件冲突,用户必须知道它是如何实现的。
高阶组件应该使用容器组件包装原组件的方式组合,而不是改变原组件。
function logProps(WrappedComponent) {
return class extends React.Component {
componentDidUpdate(prevProps) {
console.log('Current props: ', this.props);
console.log('Previous props: ', prevProps);
}
render() {
// Wraps the input component in a container, without mutating it. Good!
return <WrappedComponent {...this.props} />;
}
}
}
这个高阶组件与改变组件的版本具有相同的功能,同时又避免了改变组件的潜在风险。并且对函数组件和类组件都生效。因为它是纯函数,所以还能与其他高阶组件组合,甚至还能和它自己组合。 你可能已经注意到高阶组件和容器组件组合模式之间的相似性。容器组件是顶层和低层关注点分离责任策略的一部分。容器组件管理订阅、状态和传递属性到组件渲染UI。高阶组件就是对容器使用的一种实现。可以把高阶组件视作为一种带参数的容器组件定义。
约定:将不相关的属性传递给被包装的组件
高阶组件给组件添加功能。不应该彻底改变原有的协议。通过高阶组件生成的新组件也应该和被包装的组件拥有相似的接口。 高阶组件应该透传不相关的属性。大部分的高阶组件都应该包含一个像这样的渲染方法:
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}
/>
);
}
这个约定有助于尽可能地保证高阶组件的可扩展性和可复用性。
约定:最大化可组合性
并非所有的高阶组件看起来都一样。有时只需要接受一个参数,被包装组件。
const NavbarWithRouter = withRouter(Navbar);
通常,高阶组件也可以接受额外的参数。在这个来自于 Relay 的示例中,config参数用于指定组件的数据依赖:
const CommentWithRelay = Relay.createContainer(Comment, config);
最常见的高阶组件特点是这样:
// 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. 输入类型和输出类型一样的函数组合起来更简单。
// 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这种生成单参数高阶组件的增强型函数更容易被用作注解(decorators, 一种实验性的 JavaScript 协议)
很多第三方库提供compose工具函数,包括lodash(lodash.flowRight), Redux 和 Ramda.
约定:包装显示名称方便调试
通过高阶组件创建的容器组件在 React Developer Tools 中的展示形式和其他组件一样。为了方便调试,选择合适的显示名称用来表达它是由高阶组件生成的。
最常见的技巧就是对被包装组件的显示名称进行简单包装。高阶组件如果叫做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 的 diff 算法(称作协调)使用组件标识去决定是应该更新已存在的子树,还是卸载并挂载新的子树。如果从render返回的组件和上一次返回的组件相同(===),React 将递归地通过 diff 算法更新子树。如果不相同,之前的子树将被完全卸载。
正常情况下,不需要思考这个问题。但高阶组件就会有影响,这意味着无法在组件的render方法内将高阶组件应用于组件。
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 />;
}
这不仅仅是性能的问题,重新挂载组件将导致组件的状态和其所有的子元素都会被丢弃。 相反,在组件定义的外部应用高阶组件的方式可以只创建一次新组件。因此其标识将在渲染中保持一致。无论如何这正是我们想要的。 在特殊场景情况下,需要动态地使用高阶组件,也可以在组件的生命周期方法或者构造函数中使用。
务必复制静态方法
有时在 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 不会被传递
虽然高阶组件把所有的属性传递给被包装的组件非常方便,但是没法传递 Refs. 这是因为ref不是属性,就像key一样,是由 React 指定管理的。如果向由高阶组件生成的组件添加 ref, 那么这个 ref 指的是最外层容器组件实例,而不是被包装的组件。
这个问题的解决方案是使用React.forwardRef API(在 React 16.3 中有介绍)。了解更多请阅读forwardRef部分
本文译自 React 文档 《Higher-Order Components》
欢迎关注我的微信公众号:乘风破浪的Coder