共计 8911 个字符,预计需要花费 23 分钟才能阅读完成。
高阶组件 (HOC) 是react
为组件复用提供的一套更为先进的技术。HOC 并非 react
所提供的 api。他是 react
组件化思想的自然呈现。
具体的说,一个高阶组件是一个接受 component
并返回一个新的 component
的函数.
“
const EnhancedComponent = higherOrderComponent(WrappedComponent);
“
然而一个 component
将props
转化为 UI, 高阶组件则是将一个 compnent
转化为另一个component
。
HOC 在第三方库中很常见,比如 Redux
中的 connect 以及 Relay
中的 createFragmentContainer
Use HOCs For Cross-Cutting Concerns
我们之前推荐使用
mixins
来处理 Cross-Cutting Concerns。现在我们已经意识到mixins
带来了更多的问题。这里可以查阅到为什么我们移除了mixins
以及你如何改造已使用的component
。
component
是 React
中最主要的组件复用单元。However, you’ll find that some patterns aren’t a straightforward fit for traditional components.
比如, 你现在持有一个订阅了外部数据源, 用于渲染一项评论列表的 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
. - 卸载后,移除事件监听。
你可以想象到, 在一个大型应用中, 这些相同的数据订阅以及 setState
将会不断的重复。我们希望抽象出这部分逻辑,来使得我们可以在一处定义之后可以在多处组件中分享这套逻辑。这个就是高阶组件所擅长的地方。
我们可以通过一个函数来创建这个 component
,比如之前的CommentList
和BlogPost
, 都向 DataSource
进行订阅。The function will accept as one of its arguments a child component that receives the subscribed data as a prop. Let’s call the function withSubscription:
const CommentListWithSubscription = withSubscription(
CommentList,
(DataSource) => DataSource.getComments());
const BlogPostWithSubscription = withSubscription(
BlogPost,
(DataSource, props) => DataSource.getBlogPost(props.id)
);
传入的第一个参数就是被包裹的 component
. 第二个参数接受我们所需要的数据, 这里是DataSource
以及props
。
当 CommentListWithSubscription
以及 BlogPostWithSubscription
需要进行渲染时,CommentList
和 BlogPost
会将从 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} />;
}
};
}
注意: 我们并没有修改输入的 component
, 或者使用继承来复制他的逻辑。高阶组件通过一个容器组件来 包裹 原始组件。高阶组件是一个没有任何副作用的纯函数。
这就是关键点!这个被包裹的原始组件接受容器的所有 props
, 与新的props
,data
以及任何它所需要的渲染的数据。高阶组件并不关心它的数据被怎样使用,而原始组件也不关心数据从哪里获取到。
由于 withSubscription
是一个普通函数, 你可以添加你所需要的任意参数。比如, 你希望 data
的读取的数据名可以配置,来进一步将被包裹的组件独立出来, 或者你也可以接受一个参数来配置shouldComponentUpdate
, 又或者一个配置数据源的参数。以上所有操作都是可行的, 因为高阶组件对于被包裹的原始组件拥有完整的控制权限。
withSubscription
和被包裹的组件之间是完整的 props-based
。这使得他很容易将一个高阶组件替换到另一个, 只要可以提供同样的props
即可。这对于修改数据来源的库是非常有用的。
不要修改原始组件. 使用组合
使用高阶组件来代替修改 component
的prototype
的方法(或是其他修改原型的办法)
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);
这会产生一些问题. 其一是传入的组件无法被复用。更为关键的是,如果你使用另一个类似方式来修改组件, 它也会修改 componentWillReceiveProps
方法。那么前一次修改的逻辑就会被 == 覆盖 ==!这种方式对于函数组件也不会起作用,因为它们不存在生命周期函数。
修改原组件的方式来实现的 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
拥有与继承版本同样的功能,同时避免了潜在的冲突。它在 class 以及 function component
都能够良好的运作。而且由于这是一个纯函数,它能够与其他的 HOC
或者是它自己一起正常运作。
你可能已经听说过与 HOC
相似的模式 – 容器组件 (container components). 容器组件是分离高阶和低阶问题的策略中的一部分。容器管理着订阅,状态以及传递props
到渲染 UI 的组件中。HOC
使用容器作为实现逻辑的一部分。你可以认为 HOC
是参数化的容器组件定义。
约定:传递非相关 Props
到被包裹的组件
HOC
给组件添加了特性。他们并非彻底地改变其逻辑。我们期望从 HOC
中返回的组件依然是拥有相似接口的组件。
HOC
应该将与自身无关的 props
传递下去。大多数 HOC
都包含一个类似下面的 render
方法:
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
函数返回一个 Component => Component
模式的单参数HOC
. 这样输出与输入类型一致的函数很容易组合在一起。
// 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
被用作装饰器。这是一项实验性的 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
, 对于使用高阶组件,我们有以下不太常用的提醒
不要使用 HOC
来替换 render
方法
React
的差异算法使用组件标识来决定需要更新当前的子树或是丢弃现有的重新创建一个全新的。如果这组件通过 render
返回的结果与之前的完全一致 (===
),React
会通过差异算法来递归的更新其子树。如果他们并不相同, 之前的子树则会完整的卸载掉。
通常情况下, 您不需要考虑这个方面。但这对于 HOC
非常重要, 因为这意味着你无法应用 HOC
的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 />;
}
这个并不仅仅关乎性能, 重新装载一个组件会使得组件内的 state
以及他的子节点都会丢失。
我们可以使用 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 来自动的拷贝所有的 non-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
传递给被包裹的组件, 但这个对引用并不起作用。这是因为 ref
并非是一个真实的 prop
– 比如key
, 它会由React
特别处理。如果你添加了一个 ref
到一个 HOC
返回的组件上, 这个 ref
实际持有的是最外层的容器组件,而非被包裹的组件。
这个的解决方法是使用React.forwardRef
API(在 React 16.3 中引入). 关于React.forwardRef