高阶组件(HOC)是React里的高级技术为了应对重用组件的逻辑。HOCs本质上不是React API的一部分。它是从React的组合性质中显露出来的模式。
具体来说,一个高阶组件就是一个获取一个组件并返回一个组件的函数。
const EnhancedComponent = higherOrderComponent(WrappedComponent);
然而一个组件将props转变为UI,一个高阶组件将一个组件转变为另外一个组件。
HOCs在第三方React库里也是有的,就像Redux里的connect和Relay里的createContainer。
在这篇文档里,我们将讨论为什么高阶组件有用处,还有怎样来写你自己的高阶组件。
为横切关注点使用HOCs
注意:
我们以前建议使用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} />; } }
CommentList和BlogPost不完全相同。它们在DateSource上调用不同的方法,然后它们渲染出不同的输出。但是它们大多数实现是一样的:
- 在初始装载的时候,给DataSource添加一个监听改变的监听器
- 在监听器内部,当数据源变化的时候调用setState
- 销毁的时候,移除监听器
你可以想象在一个大型app里,订阅到DataSource并且调用setState这个同样的模式会一遍又一遍的重复发生。我们因此就想将这重复的过程抽象化,让我们在一个单独的地方定义这段逻辑并且在多个组件中都可以使用这段逻辑。这就是高阶组件所擅长的。
我们可以写一个创建组件的函数,就像CommentList和BlogPost,它们订阅到DataSource。这个函数会接受一个参数作为子组件,这个子组件接收订阅的数据作为prop。让我们调用这个函数eithSubscription:
const CommentListWithSubscription = withSubscription( CommentList, (DataSource) => DataSource.getComments() ); const BlogPostWithSubscription = withSubscription( BlogPost, (DataSource, props) => DataSource.getBlogPost(props.id) });
withSubscription的第一个参数是被包裹起来的组件。第二个参数检索我们喜欢的数据,给出一个DateSource和当前的props。
但CommentListWithSubscription和BlogPostWithSubscription被渲染了,CommenList和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是一个没有副作用的纯函数。
就是这样!被包裹的组件接受所有容器组件的props,还有和一个新的属性,data一起渲染它的输出。HOC不会关心数据怎样或者为什么使用,被包裹的组件也不会关心数据是从哪里来的。
因为withSubscription是一个普通的函数,你可以添加或多或少的参数根据情况。举个例子,你也许想要使data属性的名字是可配置的,这样就可以进一步从包裹的组件隔离HOC。或者你可以接受一个参数来配置shouldComponentUpdate,或者一个参数来配置数据源。这些都可以因为HOC拥有所有权利去定义组件。
类似于组件,withSubscription和被包裹的组件之间的不同是完全基于props的。这就可以很容易地去交换一个HOC和另一个,只要他们提供给被包裹组件的props是一样的。举个例子,这样如果你改变提取数据的库就会很有用。
不要改变原始组件,使用组合
在HOC里要打消修改组件原型的想法。
function logProps(InputComponent) { InputComponent.prototype.componentWillReceiveProps(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的功能会被重写!这个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和容器组件这个模式的相似之处。容器组件是将高阶和低阶关注点的功能分离的策略的一部分。
容器管理类似订阅和state的东西,并且传递props给组件然后处理类似渲染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 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尽可能地灵活和可重用。
约定:最大化可组合性
不是所有的高阶组件看起来都一样。有时候它们只接收一个参数,被包裹的组件:
const NavbarWithRouter = withRouter(Navbar);
通常HOC会接收额外的参数。在Relay的例子里,一个config对象被用于指定组件的数据依赖:
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被作为装饰来使用,这是一个实验性的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新手,那么有一些有关高阶组件的说明不会立马就很明显。
不要在render函数里使用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 />; }
这里的问题不只只是性能--一个组件的重载会造成组件的state和它所有的子元素丢失。
在组件定义的外部来使用HOC使得结果组件只被创建一次。之后,它的身份在渲染过程中会一直保持不变。总之,这就是你经常想要的结果。
在这些罕见的情况里你需要动态的运用HOC,你也可以在组建的生命周期函数里或者构造函数里使用。
静态方法必须被复制
有些时候在React组件里定义一个静态方法是很有用的。举个例子,Relay容器暴露了一个静态方法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
为了解决这个问题,你可以在返回它之前在容器组件之上复制那些方法。
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';