React高阶组件

链接:https://zhuanlan.zhihu.com/p/24776678?group_id=802649040843051008

摘要

这篇文章主要面向想要使用 HOC(Higher Order Component,高阶组件) 模式的进阶开发者。如果你是 React 新手,建议你从阅读 React 的文档开始。

高阶组件是一种很好的模式,很多 React 库已经证明了其价值。这篇文章中我们将会详细的讲解什么是 HOC,你能用它做什么,它有哪些局限,如何实现它。

在附录中有一些相关的话题,可能不是 HOC 的核心,但是我认为应该提到。

这篇文章尽量做到详尽无遗,如果你发现任何遗漏的,请提出来,我会做出必要的改动。

这篇文章假设你已经了解 ES6。

让我们开始吧!

什么是高阶组件?

高阶组件就是一个 React 组件包裹着另外一个 React 组件

这种模式通常使用函数来实现,基本上是一个类工厂(是的,一个类工厂!),它的函数签名可以用类似 haskell 的伪代码表示

1
hocFactory:: W: React.Component => E: React.Component

其中 W (WrappedComponent) 指被包裹的 React.Component,E (EnhancedComponent) 指返回类型为 React.Component 的新的 HOC。

我们有意模糊了定义中“包裹”的概念,因为它可能会有以下两种不同的含义之一:

  • Props Proxy: HOC 对传给 WrappedComponent W 的 porps 进行操作;

  • Inheritance Inversion: HOC 继承 WrappedComponent W。

(译注:原作者在评论中提到希望对 Props Proxy 和 Inheritance Inversion 不做翻译,故保留原文)

我们会深入地探究这两种模式。

HOC 工厂的实现方法

这一节我们将会研究 React 中两种 HOC 的实现方法:Props Proxy (PP) and Inheritance Inversion (II)。两种方法都可以操作 WrappedComponent。

Props Proxy

Props Proxy (PP) 的最简实现:

1
2
3
4
5
6
7
function ppHOC(WrappedComponent) {
return class PP extends React.Component {
render() {
return <WrappedComponent {...this.props}/>
}
}
}

这里主要是 HOC 在 render 方法中 返回 了一个 WrappedComponent 类型的 React Element。我们还传入了 HOC 接收到的 props,这就是名字 Props Proxy 的由来。

1
2
3
<WrappedComponent {...this.props}/>
// 等价于
React.createElement(WrappedComponent, this.props, null)

在 React 内部的一致化处理(reconciliation process)中,两者都创建了一个 React Element 用于渲染。如果你想了解关于 React Elements vs Components ,请看 Dan Abramov 的这篇文章,想了解一致化处理请参考文档。

(译注:一致化处理(reconciliation process)可理解为 React 内部将虚拟 DOM 同步更新到真实 DOM 的过程,包括新旧虚拟 DOM 的比较及计算最小 DOM 操作)

使用 Props Proxy 可以做什么?

  • 操作 props
  • 通过 Refs 访问到组件实例
  • 提取 state
  • 用其他元素包裹 WrappedComponent

操作 props

你可以读取、添加、编辑、删除传给 WrappedComponent 的 props。

当删除或者编辑重要的 props 时要小心,你可能应该通过命名空间(命名空间是什么鬼?)确保高阶组件的 props 不会破坏 WrappedComponent。

例子:添加新的 props。在这个应用中,当前登录的用户可以在 WrappedComponent 中通过 this.props.user 访问到。

1
2
3
4
5
6
7
8
9
10
function ppHOC(WrappedComponent) {
return class PP extends React.Component {
render() {
const newProps = {
user: currentLoggedInUser
}
return <WrappedComponent {...this.props} {...newProps}/>
}
}
}

通过 Refs 访问到组件实例

你可以通过引用(ref)访问到 this (WrappedComponent 的实例),但为了得到引用,WrappedComponent 还需要一个初始渲染,意味着你需要在 HOC 的 render 方法中返回 WrappedComponent 元素,让 React 开始它的一致化处理,你就可以得到 WrappedComponent 的实例的引用。

例子:如何通过 refs 访问到实例的方法和实例本身:

1
2
3
4
5
6
7
8
9
10
11
12
function refsHOC(WrappedComponent) {
return class RefsHOC extends React.Component {
proc(wrappedComponentInstance) {
wrappedComponentInstance.method()
}
render() {
const props = Object.assign({}, this.props, {ref: this.proc.bind(this)})
return <WrappedComponent {...props}/>
}
}
}

Ref 的回调函数会在 WrappedComponent 渲染时执行,你就可以得到 WrappedComponent 的引用。这可以用来读取/添加实例的 props ,调用实例的方法。

提取 state

你可以通过传入 props 和回调函数把 state 提取出来,类似于 smart component 与 dumb component。更多关于 dumb and smart component

提取 state 的例子:提取了 input 的 value 和 onChange 方法。这个简单的例子不是很常规,但足够说明问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function ppHOC(WrappedComponent) {
return class PP extends React.Component {
constructor(props) {
super(props)
this.state = {
name: ''
}
this.onNameChange = this.onNameChange.bind(this)
}
onNameChange(event) {
this.setState({
name: event.target.value
})
}
render() {
const newProps = {
name: {
value: this.state.name,
onChange: this.onNameChange
}
}
return <WrappedComponent {...this.props} {...newProps}/>
}
}
}

你可以这样用:

1
2
3
4
5
6
@ppHOC
class Example extends React.Component {
render() {
return <input name="name" {...this.props.name}/>
}
}

这个 input 会自动成为受控input。

更多关于常规的双向绑定 HOC 请点击 链接

用其他元素包裹 WrappedComponent

为了封装样式、布局或别的目的,你可以用其它组件和元素包裹 WrappedComponent。基本方法是使用父组件(附录 B)实现,但通过 HOC 你可以得到更多灵活性。

例子:包裹样式

1
2
3
4
5
6
7
8
9
10
11
function ppHOC(WrappedComponent) {
return class PP extends React.Component {
render() {
return (
<div style={{display: 'block'}}>
<WrappedComponent {...this.props}/>
</div>
)
}
}
}

Inheritance Inversion

Inheritance Inversion (II) 的最简实现:

1
2
3
4
5
6
7
function iiHOC(WrappedComponent) {
return class Enhancer extends WrappedComponent {
render() {
return super.render()
}
}
}

你可以看到,返回的 HOC 类(Enhancer)继承了 WrappedComponent。之所以被称为 Inheritance Inversion 是因为 WrappedComponent 被 Enhancer 继承了,而不是 WrappedComponent 继承了 Enhancer。在这种方式中,它们的关系看上去被反转(inverse)了。

Inheritance Inversion 允许 HOC 通过 this 访问到 WrappedComponent,意味着它可以访问到 state、props、组件生命周期方法和 render 方法。

关于生命周期方法可以用来做什么,我不想细说,因为它是 React 的特性而不是 HOC 的特性。但请注意通过 II 你可以创建新的生命周期方法。为了不破坏 WrappedComponent,记得调用 super.[lifecycleHook]。

一致化处理(Reconciliation process)

开始之前我们先理清一些概念。

React 元素决定描述了在 React 执行一致化处理时它要渲染什么。

React 元素有两种类型:字符串和函数。字符串类型的 React 元素代表 DOM 节点,函数类型的 React 元素代表继承 React.Component 的组件。更多关于元素(Element)和组件(Component)请看这篇文章

函数类型的 React 元素会在一致化处理中被解析成一个完全由字符串类型 React 组件组成的树(而最后的结果永远是 DOM 元素)。

这很重要,意味着 Inheritance Inversion 的高阶组件不一定会解析完整子树

Inheritance Inversion 的高阶组件不一定会解析完整子树

这在学习渲染劫持(Render Highjacking)时非常重要。

你可以用 Inheritance Inversion 做什么?

  • 渲染劫持(Render Highjacking)

  • 操作 state

渲染劫持

之所以被称为渲染劫持是因为 HOC 控制着 WrappedComponent 的渲染输出,可以用它做各种各样的事。

通过渲染劫持你可以:

  • 在由 render输出的任何 React 元素中读取、添加、编辑、删除 props
  • 读取和修改由 render 输出的 React 元素树
  • 有条件地渲染元素树
  • 把样式包裹进元素树(就像在 Props Proxy 中的那样)

*render 指 WrappedComponent.render 方法

你不能编辑或添加 WrappedComponent 实例的 props,因为 React 组件不能编辑它接收到的 props,但你可以修改由 render 方法返回的组件的 props。

就像我们刚才学到的,II 类型的 HOC 不一定会解析完整子树,意味着渲染劫持有一些限制。根据经验,使用渲染劫持你可以完全操作 WrappedComponent 的 render 方法返回的元素树。但是如果元素树包括一个函数类型的 React 组件,你就不能操作它的子组件了。(被 React 的一致化处理推迟到了真正渲染到屏幕时)

例1:条件渲染。当 this.props.loggedIn 为 true 时,这个 HOC 会完全渲染 WrappedComponent 的渲染结果。(假设 HOC 接收到了 loggedIn 这个 prop)

1
2
3
4
5
6
7
8
9
10
11
function iiHOC(WrappedComponent) {
return class Enhancer extends WrappedComponent {
render() {
if (this.props.loggedIn) {
return super.render()
} else {
return null
}
}
}
}

例2:修改由 render 方法输出的 React 组件树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function iiHOC(WrappedComponent) {
return class Enhancer extends WrappedComponent {
render() {
const elementsTree = super.render()
let newProps = {};
if (elementsTree && elementsTree.type === 'input') {
newProps = {value: 'may the force be with you'}
}
const props = Object.assign({}, elementsTree.props, newProps)
const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children)
return newElementsTree
}
}
}

在这个例子中,如果 WrappedComponent 的输出在最顶层有一个 input,那么就把它的 value 设为 “may the force be with you”。

你可以在这里做各种各样的事,你可以遍历整个元素树,然后修改元素树中任何元素的 props。这也正是样式处理库 Radium 所用的方法(案例分析一节中有更多关于 Radium 的信息)。

注:在 Props Proxy 中不能做到渲染劫持。

虽然通过 WrappedComponent.prototype.render 你可以访问到 render 方法,不过还需要模拟 WrappedComponent 的实例和它的 props,还可能亲自处理组件的生命周期,而不是交给 React。根据我的实验,这么做不值,你要是想做到渲染劫持你应该用 Inheritance Inversion 而不是 Props Proxy。记住,React 在内部处理了组件实例,你处理实例的唯一方法是通过 this 或者 refs。

操作 state

HOC 可以读取、编辑和删除 WrappedComponent 实例的 state,如果你需要,你也可以给它添加更多的 state。记住,这会搞乱 WrappedComponent 的 state,导致你可能会破坏某些东西。要限制 HOC 读取或添加 state,添加 state 时应该放在单独的命名空间里,而不是和 WrappedComponent 的 state 混在一起。

例子:通过访问 WrappedComponent 的 props 和 state 来做调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function IIHOCDEBUGGER(WrappedComponent) {
return class II extends WrappedComponent {
render() {
return (
<div>
<h2>HOC Debugger Component</h2>
<p>Props</p> <pre>{JSON.stringify(this.props, null, 2)}</pre>
<p>State</p><pre>{JSON.stringify(this.state, null, 2)}</pre>
{super.render()}
</div>
)
}
}
}

这里 HOC 用其他元素包裹着 WrappedComponent,还输出了 WrappedComponent 实例的 props 和 state。JSON.stringify 的小技巧是由 Ryan Florence 和 Michael Jackson 教我的。这个调试器完整的实现在这里。

命名

用 HOC 包裹了一个组件会使它失去原本 WrappedComponent 的名字,可能会影响开发和调试。

通常会用 WrappedComponent 的名字加上一些 前缀作为 HOC 的名字。下面的代码来自 React-Redux:

1
2
3
4
5
6
7
8
HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`
//或
class HOC extends ... {
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
...
}

getDisplayName 函数:

1
2
3
4
5
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName ||
WrappedComponent.name ||
‘Component’
}

实际上你不用自己写,recompose 提供了这个函数。