React 中的 HOC

React 中的 HOC

我们在之前的 React 中的 Mixin 章节当中介绍了一些 Mixin 的基本原理和它存在的一些问题,而且在之前我们也提到过,React 现在已经不再推荐使用 Mixin 来解决代码复用问题,因为 Mixin 带来的危害比他产生的价值还要巨大,推荐我们使用高阶组件来替代它,所以在本章当中我们就来深入的了解一下什么是高阶组件,它的实现方式和应用场景以及存在的一些问题

使用 HOC 的动机

我们在之前的文章当中提到过使用 Mixin 所带来的风险,在这里我们在简单的总结一下

  • Mixin 可能会相互依赖,相互耦合,不利于代码维护
  • 不同的 Mixin 中的方法可能会相互冲突
  • Mixin 非常多时,组件是可以感知到的,甚至还要为其做相关处理,这样会给代码造成滚雪球式的复杂性

HOC 的出现可以解决这些问题

  • 高阶组件就是一个没有副作用的纯函数,各个高阶组件不会互相依赖耦合
  • 高阶组件也有可能造成冲突,但我们可以在遵守约定的情况下避免这些行为
  • 高阶组件并不关心数据使用的方式和原因,而被包裹的组件也不关心数据来自何处,高阶组件的增加不会为原组件增加负担

不过在深入高阶组件之前,让我们先来看看装饰器模式和高阶函数的相关内容,了解这两者的内容以后有助于我们更好的理解高阶组件

装饰器模式

之所以先介绍装饰器模式,这是因为 React 当中的高阶组件其实就是装饰器模式的一种实现,所谓装饰器模式(Decorator Pattern),它允许向一个现有的对象添加新的功能,同时又不改变其结构,这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装,与继承相比,装饰者是一种更轻便灵活的做法

这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能,这里需要注意的是,它是不会改变原本类的,这一点很关键

JavaScript 当中的装饰器

装饰器是 ES7 中的一个新语法,正如其字面意思而言,它可以对类、方法、属性进行修饰,从而进行一些相关功能定制,简而言之就是对对象进行包装,返回一个新的对象描述(descriptor),这个概念其实和 React 中的高阶组件也类似,我们来看下面这个示例,假设我们现在要对一个函数 log,打印出它的执行记录,如果不使用装饰器是下面这样的

1
2
3
4
5
6
7
8
9
10
11
const log = (fn) => {
return (...arguments) => {
console.info(`${arguments.join(',')}`)
fn(...arguments)
}
}

const plus = (a, b) => a + b
const logPlus = log(plus)

logPlus(1, 2) // 1, 2

而如果使用装饰器则是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const log = (target, name, descriptor) => {
var oldValue = descriptor.value
descriptor.value = function () {
console.log(arguments)
return oldValue.apply(this, arguments)
}
return descriptor
}

class Math {
@log
plus(a, b) {
return a + b
}
}

const math = new Math()

math.add(1, 2) // 1, 2

从上面的代码可以看出,如果有的时候我们并不需要关心函数的内部实现,仅仅是想调用它的话,装饰器能够带来比较好的可读性,使用起来也是非常的方便

装饰器原理

其实简单来说,JavaScript 当中的装饰器本质也是一个函数,利用的是 JavaScriptObjectdescriptor,这个函数会接收三个参数

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
27
/**
* 装饰器函数
* @param {Object} target 被装饰器的类的原型
* @param {string} name 被装饰的类、属性、方法的名字
* @param {Object} descriptor 被装饰的类、属性、方法的描述
*/
function Decorator(target, name, descriptor) {
// 以此可以获取实例化的时候此属性的默认值
let v = descriptor.initializer && descriptor.initializer.call(this)
// 返回一个新的描述对象作为被修饰对象的描述,或者直接修改也可以
return {
enumerable: true,
configurable: true,
get() {
return v
},
set(c) {
v = c
},
}
}

// USE
class Fudao {
@Decorator
title = '标题'
}

当然装饰器也可以接受参数,其实就是将外部包装一个函数,而函数可以带参数

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
27
/**
* 装饰器函数
* @param {Object} target 被装饰器的类的原型
* @param {string} name 被装饰的类、属性、方法的名字
* @param {Object} descriptor 被装饰的类、属性、方法的描述
*/
function Decorator(type) {
return (target, name, descriptor) => {
let v = descriptor.initializer && descriptor.initializer.call(this)
return {
enumerable: true,
configurable: true,
get() {
return v + type
},
set(c) {
v = c
},
}
}
}

// USE
class Fudao {
@Decorator('string')
title = '标题'
}

常见的装饰器

这里我们通过 core-decorators 这个库来简单介绍几个比较常用的装饰器,比如 autobind 修饰器可以让方法中的 this 对象绑定原始对象,使得 this 始终指向绑定的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
import { autobind } from 'core-decorators'

class Person {
@autobind
getPerson() {
return this
}
}

let person = new Person()
let getPerson = person.getPerson

getPerson() === person // true

readonly 修饰器使得属性或方法不可写

1
2
3
4
5
6
7
8
9
import { readonly } from 'core-decorators'

class Fudao {
@readonly
title = '标题'
}

var fudao = new Fudao()
fudao.title = '新的标题'

高阶函数

关于高阶函数的定义,维基 上的定义是,高阶函数是至少满足下列一个条件的函数

  • 接受一个或多个函数作为输入
  • 输出一个函数

比如下面这个示例,就是一个简单的高阶函数

1
2
3
const add = (x, y, f) => f(x) + f(y)

add(-5, 6, Math.abs)

当我们在调用的时候,参数 xyf 分别接收 -56Math.abs,根据函数定义,我们可以推导计算过程为

1
2
3
4
5
x ==> -5
y ==> 6
f ==> Math.abs

f(x) + f(y) ==> Math.abs(-5) + Math.abs(6) ==> 11

所以上面代码执行后的结果为 11,在简单了解了装饰器模式和高阶函数的基本概念以后,下面就让我们正式的来看看高阶组件的相关内容

高阶组件

那么,什么是高阶组件呢?类比高阶函数的定义,高阶组件就是接受一个组件作为参数并返回一个新组件的函数,这里需要注意高阶组件是一个函数,并不是组件,这一点一定要注意

更通俗地描述为,高阶组件通过包裹(wrapped)被传入的 React 组件,经过一系列处理,最终返回一个相对增强(enhanced)的 React 组件,供其他组件调用,下面我们先来实现一个简单的高阶组件(函数)看看它是如何工作的,它接受一个 React 组件,包裹后然后返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function withHeader(WrappedComponent) {
return class HOC extends Component {
render() {
return (
<div>
<div className="demo-header">
我是标题
</div>
<WrappedComponent {...this.props} />
</div>
)
}
}
}

上面的代码就是一个 HOC 的简单应用,函数接收一个组件作为参数,并返回一个新组件,我们在其他组件里就可以来引用这个高阶组件,用来强化它

1
2
3
4
5
6
@withHeader
export default class Demo extends Component {
render() {
return <div>我是一个普通组件</div>
}
}

在这里我们使用了上面介绍到的装饰器模式来让写法变得更为优雅,当然下面这种写法也是可以的

1
const EnhanceDemo = withHeader(Demo)

如下图所示,我们可以发现 Demo 组件已经被 HOC 组件包裹起来了,符合了高阶组件的预期,即组件是层层包裹起来的,如同洋葱一样

但是随之带来的问题是,如果这个高阶组件被使用了多次,那么在调试的时候,将会看到一大堆 HOC,所以这个时候需要做一点小优化,就是在高阶组件包裹后,应当保留其原有名称,我们改写一下上述的高阶组件代码,增加了 getDisplayName 函数以及静态属性 displayName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function getDisplayName(component) {
return component.displayName || component.name || 'Component'
}

export default function (WrappedComponent) {
return class HOC extends Component {
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
render() {
return (
<div>
<div className="demo-header">
我是标题
</div>
<WrappedComponent {...this.props} />
</div>
)
}
}
}

此时我们再去观察就会如下图所示,可以发现此时原本组件的名称也会正确的显示了

由此可以看出,高阶组件的主要功能是封装并抽离组件的通用逻辑,让此部分逻辑在组件间更好地被复用,但是我们仔细观察上方的示例可以发现,此时这个高阶组件的作用仅仅只是展示了标题名称,但是为了更好的抽象,此标题应当可以被参数化,如下方式调用

1
2
3
4
5
6
7
8
9
// 如果传入参数,则传入的参数将作为组件的标题呈现
@withHeader('Demo')
export default class Demo extends Component {
render() {
return (
//...
)
}
}

所以我们来简单的调整一下 withHeader,让它接受一个参数,然后返回一个高阶组件(函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function (title) {
return function (WrappedComponent) {
return class HOC extends Component {
render() {
return (
<div>
<div className="demo-header">
{ title ? title : '我是标题' }
</div>
<WrappedComponent {...this.props} />
</div>
)
}
}
}
}

也使用 ES6 写法来进行简化

1
2
3
4
5
6
7
8
9
10
11
12
export default (title) => (WrappedComponent) => class HOC extends Component {
render() {
return (
<div>
<div className="demo-header">
{ title ? title : '我是标题' }
</div>
<WrappedComponent {...this.props} />
</div>
)
}
}

组合多个高阶组件

我们在上面使用高阶组件为 React 组件添加一个显示标题的功能,但是如果需要同时增加多个功能的话需要如何处理呢?这种场景非常常见,例如我们既需要增加一个组件标题,又需要在此组件未加载完成时显示 Loading,即下面这种情况

1
2
3
4
5
@withHeader
@withLoading
class Demo extends Component {

}

针对于这种情况,我们可以使用 compose 来简化上述过程,这样也能体现函数式编程的思想

1
2
3
4
5
6
const enhance = compose(withHeader, withLoading)

@enhance
class Demo extends Component {

}

这里我们简单的介绍一下 composecompose 可以帮助我们组合任意个(包括 0 个)高阶函数,例如 compose(a, b, c) 返回一个新的函数 d,函数 d 依然接受一个函数作为入参,只不过在内部会依次调用 c, b, a,从表现层对使用者保持透明

基于这个特性,我们便可以非常便捷地为某个组件增强或减弱其特征,只需要去变更 compose 函数里的参数个数便可,更多详细内容可以参考 Redux-Compose

在简单介绍了高阶组件的基本用法之后,下面我们就来深入的了解一下 React 中的高阶组件,比如它的实现方式,实际应用以及注意事项等内容

高阶组件的实现方式

React 中的高阶组件主要有两种形式,即属性代理和反向继承,它们的区别如下

  • 属性代理(props proxy),即高阶组件通过被包裹的 React 组件来操作 props
  • 反向继承(inheritance inversion),即高阶组件继承于被包裹的 React 组件

两者的区别可以看继承的组件,一般属性代理继承的都是 React.Component,而反向继承通常继承的是传入的组件 WrappedComponent

属性代理

属性代理是最常见的高阶组件的使用方式,函数返回一个我们自己定义的组件,然后在 render 中返回要包裹的组件,这样我们就可以代理所有传入的 props,并且决定如何渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { Component } from 'React'

// 高阶组件定义
const HOC = (WrappedComponent) =>
class WrapperComponent extends Component {
render() {
return <WrappedComponent {...this.props} />
}
}

// 普通的组件
class WrappedComponent extends Component {
render() {
//....
}
}

// 高阶组件使用
export default HOC(WrappedComponent)

我们可以看见函数 HOC 返回了新的组件(WrapperComponent),这个组件原封不动的返回作为参数的组件(也就是被包裹的组件 WrappedComponent),并将传给它的参数(props)全部传递给被包裹的组件(WrappedComponent

其实简单来说,属性代理其实就是一个函数接受一个 WrappedComponent 组件作为参数传入,并返回一个继承了 React.Component 组件的类,且在该类的 render() 方法中返回被传入的 WrappedComponent 组件,又因为属性代理类型的高阶组件返回的其实是一个标准的 React.Component 组件,相对比于原生组件来说,它可以增强下列一些额外操作

  • 可操作所有传入的 props
  • 可操作组件的生命周期
  • 可操作组件的 static 方法
  • 获取 refs

反向继承

反向继承其实就是一个函数接受一个 WrappedComponent 组件作为参数传入,并返回一个继承了该传入 WrappedComponent 组件的类,且在该类的 render() 方法中返回 super.render() 方法

由于继承了原组件,能通过 this 访问到原组件的生命周期,propsstaterender 等,相比属性代理它能操作更多的属性(有点类似于 Render Props 的感觉)

1
2
3
4
5
6
const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
return super.render()
}
}

其实我们仔细观察可以发现,其实属性代理和反向继承的实现有些类似的地方,都是返回一个继承了某个父类的子类,只不过属性代理中继承的是 React.Component,反向继承中继承的是传入的组件 WrappedComponent,它相对比于原生组件来说,可以增强下列一些额外操作

  • 可操作所有传入的 props
  • 可操作组件的生命周期
  • 可操作组件的 static 方法
  • 获取 refs
  • 可操作 state
  • 可以渲染劫持

高阶组件的功能实现

我们在上面介绍了高阶组件的两种实现方式,也就是属性代理和反向继承,下面我们就来看看利用这两种方式可以实现哪些额外功能

组合渲染

最容易想到的莫过于组合渲染,即可使用任何其他组件和原组件进行组合渲染,达到样式、布局复用等效果,通过属性代理实现方式如下

1
2
3
4
5
6
7
8
9
10
11
const HOC = (WrappedComponent) =>
class extends Component {
render() {
return (
<div>
<div className="title">{this.props.title}</div>
<WrappedComponent {...this.props} />
</div>
)
}
}

通过反向继承实现方式如下

1
2
3
4
5
6
7
8
9
10
11
const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
return (
<div>
<div className="title">{this.props.title}</div>
{super.render()}
</div>
)
}
}

条件渲染

这个也是一个比较常见的使用场景,根据特定的属性决定原组件是否渲染,通过属性代理实现方式如下

1
2
3
4
5
6
7
const HOC = (WrappedComponent) =>
class extends Component {
render() {
if (this.props.visible === false) return null
return <WrappedComponent {...props} />
}
}

通过反向继承实现方式如下

1
2
3
4
5
6
7
8
9
10
const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
if (this.props.visible === false) {
return null
} else {
return super.render()
}
}
}

操作 props

我们也可以对传入组件的 props 进行增加、修改、删除或者根据特定的 props 进行特殊的操作,通过属性代理实现方式如下

1
2
3
4
5
6
7
8
9
10
const HOC = (WrappedComponent) =>
class extends Component {
render() {
const newProps = {
...this.props,
user: 'zhangsan'
}
return <WrappedComponent {...newProps} />
}
}

获取 refs

有的时候我们需要访问 DOM 元素(比如使用第三方 DOM 操作库等)的时候就会用到组件的 ref 属性,它只能声明在 Class 类型的组件上,而无法声明在函数(无状态)类型的组件上,ref 的值可以是字符串(不推荐使用)也可以是一个回调函数,如果是回调函数的话,它的执行时机是

  • 组件被挂载后(componentDidMount),回调函数立即执行,回调函数的参数为该组件的实例
  • 组件被卸载(componentDidUnmount)或者原有的 ref 属性本身发生变化的时候,此时回调函数也会立即执行,且回调函数的参数为 null

那么我们如何在高阶组件中获取到 WrappedComponent 组件的实例呢?答案就是可以通过 WrappedComponent 组件的 ref 属性,该属性会在组件 componentDidMount 的时候执行 ref 的回调函数并传入该组件的实例

1
2
3
4
5
6
7
8
9
const HOC = (WrappedComponent) =>
class extends React.Component {
executeInstanceMethod = (wrappedComponentInstance) => {
wrappedComponentInstance.someMethod()
}
render() {
return <WrappedComponent {...this.props} ref={this.executeInstanceMethod} />
}
}

这里需要注意的是,不能在无状态组件(函数类型组件)上使用 ref 属性,因为无状态组件没有实例

其实简单来说,就是调用高阶组件的时候并不能获取到原组件的真实 ref,需要我们手动的来进行传递,这里主要涉及到 React.forwardRef 这个 API,关于这点我们在下面的高阶组件的注意事项章节当中会详细来进行介绍,也可以参考官方文档当中的 Refs 转发 来了解更多

状态管理

将原组件的状态提取到 HOC 中进行管理,如下面的代码,我们将 Inputvalue 提取到 HOC 中进行管理,使它变成受控组件,同时不影响它使用 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
27
28
29
30
31
32
const proxyHoc = (WrappedComponent) =>
class extends Component {
constructor(props) {
super(props)
this.state = { value: '' }
}
onChange = (event) => {
const { onChange } = this.props
this.setState({
value: event.target.value,
}, () => {
if (typeof onChange === 'function') {
onChange(event)
}
})
}
render() {
const newProps = {
value: this.state.value,
onChange: this.onChange,
}
return <WrappedComponent {...this.props} {...newProps} />
}
}

class HOC extends Component {
render() {
return <input {...this.props}></input>
}
}

export default proxyHoc(HOC)

操作 state

这里需要注意,不推荐直接修改或添加原组件的 state,因为这样有可能和组件内部的操作构成冲突

上面的例子我们通过属性代理的方式利用 HOCstate 对原组件进行了一定的增强,但并不能直接控制原组件的 state,而通过反向继承,我们可以直接操作原组件的 state,下面是通过反向继承实现

1
2
3
4
5
6
7
8
9
10
11
12
const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
console.log('props', this.props)
console.log('state', this.state)
return (
<div className="debuging">
{super.render()}
</div>
)
}
}

上面的 HOCrender 中将 propsstate 打印出来,可以用作调试阶段,当然你可以在里面写更多的调试代码,想象一下,只需要在我们想要调试的组件上加上 @debug 就可以对该组件进行调试,而不需要在每次调试的时候写很多冗余代码

渲染劫持

高阶组件可以在 render 函数中做非常多的操作,从而控制原组件的渲染输出,只要改变了原组件的渲染,我们都将它称之为一种渲染劫持,实际上上面的组合渲染和条件渲染都是渲染劫持的一种,通过反向继承,不仅可以实现以上两点,还可直接增强由原组件 render 函数产生的 React 元素,下面是通过反向继承的实现方式

1
2
3
4
5
6
7
8
9
10
11
12
13
const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
const tree = super.render()
let newProps = {}
if (tree && tree.type === 'input') {
newProps = { value: `渲染被劫持了` }
}
const props = Object.assign({}, tree.props, newProps)
const newTree = React.cloneElement(tree, props, tree.props.children)
return newTree
}
}

这里需要注意我们在上面提及的是增强而不是更改,因为 render 函数内实际上是调用 React.creatElement 产生的 React 元素,如下图所示

虽然我们能拿到它,但是我们不能直接修改它里面的属性,我们可以通过 getOwnPropertyDescriptors() 方法来看一下它的配置项

可以发现,所有的 writable 属性均被配置为了 false,即所有属性是不可变的,虽然不能直接修改,但是我们可以借助 cloneElement 方法来在原组件的基础上增强一个新组件,因为 React.cloneElement() 方法几乎相当于下面这样

1
<element.type {...element.props} {...props}>{children}</element.type>

这里我们简单介绍一下 React.cloneElement() 方法,它的基本语法为

1
React.cloneElement(element, [props], [...children])

React.cloneElement() 克隆并返回一个新的 React 元素,使用 element 作为起点,生成的元素将会拥有原始元素 props 与新 props 的浅合并,新的子级会替换现有的子级,来自原始元素的 keyref 将会保留

但是关于反向继承有一个重要的点,那就是反向继承不能保证完整的子组件树被解析,关于这一点我们会在下面的高阶组件存在的问题当中来详细介绍

高阶组件存在的问题

这里我们主要介绍下面三点

静态方法丢失

当我们应用 HOC 去增强另一个组件时,我们实际使用的组件已经不是原组件了,所以我们拿不到原组件的任何静态属性

1
2
3
4
5
6
7
8
// 定义静态方法
WrappedComponent.staticMethod = function () { }

// 使用高阶组件
const EnhancedComponent = HigherOrderComponent(WrappedComponent)

// 增强型组件没有静态方法
typeof EnhancedComponent.staticMethod === 'undefined' // true

但是我们可以在HOC当中手动的拷贝它们

1
2
3
4
5
6
7
8
function HigherOrderComponent(WrappedComponent) {
class Enhance extends React.Component { }

// 必须得知道要拷贝的方法
Enhance.staticMethod = WrappedComponent.staticMethod

return Enhance
}

但是如果原组件有非常多的静态属性,这个过程是非常痛苦的,而且你需要去了解需要增强的所有组件的静态属性是什么,不过我们可以使用 hoist-non-react-statics 这个库来帮助我们解决这个问题,它可以自动帮我们拷贝所有非 React 的静态方法

1
2
3
4
5
6
7
8
9
import hoistNonReactStatic from 'hoist-non-react-statics'

function HigherOrderComponent(WrappedComponent) {
class Enhance extends React.Component { }

hoistNonReactStatic(Enhance, WrappedComponent)

return Enhance
}

refs 属性不能透传

一般来说高阶组件可以传递所有的 props 给包裹的组件 WrappedComponent,但是有一种属性不能传递,它就是 ref,与其他属性不同的地方在于 React 对其进行了特殊的处理,如果你向一个由高阶组件创建的组件的元素添加 ref 引用,那么 ref 指向的是最外层容器组件实例的,而不是被包裹的 WrappedComponent 组件

但是很多情况下我们需要用到原组件的 ref,又因为高阶组件并不能像透传 props 那样将 refs 透传,所以我们可以用一个回调函数来完成 ref 的传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const HOC = (WrappedComponent) =>
class extends Component {
getWrappedRef = () => this.wrappedRef
render() {
return <WrappedComponent ref={ref => { this.wrappedRef = ref }} {...this.props} />
}
}

@HOC
class Input extends Component {
render() { return <input></input> }
}

class App extends Component {
render() {
return <Input ref={ref => { this.inpitRef = ref.getWrappedRef() }} ></Input>
}
}

React16.3 的版本当中提供了一个 forwardRef API 来帮助我们进行 refs 传递,这样我们在高阶组件上获取的 ref 就是原组件的 ref 了,而不需要再手动传递,更多内容可以参考 Refs 转发

1
2
3
4
5
6
7
8
9
10
11
function forwardRef(WrappedComponent) {
class HOC extends Component {
render() {
const { forwardedRef, ...props } = this.props
return <WrappedComponent ref={forwardedRef} {...props} />
}
}
return React.forwardRef((props, ref) => {
return <HOC forwardedRef={ref} {...props} />
})
}

反向继承不能保证完整的子组件树被解析

关于这一点可能不太好理解,但是我们可以借住 React Components, Elements, and Instances 这篇文章来进行了解,在文章当中主要明确了以下两点内容

  • 元素(element)是一个是用 DOM 节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children)中包含其他的元素,一旦创建就不会改变,我们通过 JSXReact.createClass 创建的都是元素
  • 组件(component)可以接受属性(props)作为输入,然后返回一个元素树(element tree)作为输出,有多种实现方式,比如 Class 或者函数(Function

所以,反向继承不能保证完整的子组件树被解析的意思就是,如果解析的元素树中包含了组件(函数类型或者 Class类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析,比如

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
27
28
29
30
31
32
33
34
35
import React, { Component } from 'react'

const MyFuncComponent = (props) => {
return <div>Hello World</div>
}

class MyClassComponent extends Component {
render() {
return <div>Hello World</div>
}
}

class WrappedComponent extends Component {
render() {
return (
<div>
<div>
<span>Hello World</span>
</div>
<MyFuncComponent />
<MyClassComponent />
</div>
)
}
}

const HOC = (WrappedComponent) =>
class extends WrappedComponent {
render() {
const elementsTree = super.render()
return elementsTree
}
}

export default HOC(WrappedComponent)

运行结果如下

通过观察控制台当中的页面结构可以发现,div 下的 span 是可以被完全被解析的,但是 MyFuncComponentMyClassComponent 都是组件类型的,其子组件就不能被完全解析了

高阶组件的约定

高阶组件带给我们极大方便的同时,我们也要遵循一些约定

props 保持一致

高阶组件在为子组件添加特性的同时,要尽量保持原有组件的 props 不受影响,也就是说传入的组件和返回的组件在 props 上尽量保持一致

不要改变原始组件

不要在高阶组件内以任何方式修改一个组件的原型,比如下面的代码

1
2
3
4
5
6
7
8
9
10
const HOC = (WrappedComponent) =>
function withLogging(WrappedComponent) {
WrappedComponent.prototype.componentWillReceiveProps = function (nextProps) {
console.log('Current props', this.props)
console.log('Next props', nextProps)
}
return WrappedComponent
}

const EnhancedComponent = withLogging(SomeComponent)

可以发现,我们在高阶组件的内部对 WrappedComponent 进行了修改,一旦对原组件进行了修改,那么就失去了组件复用的意义,所以在这种情况下最好使用纯函数(相同的输入总有相同的输出)来返回新的组件

1
2
3
4
5
6
7
8
9
10
11
12

const HOC = (WrappedComponent) =>
class extends React.Component {
componentWillReceiveProps() {
console.log('Current props', this.props)
console.log('Next props', nextProps)
}
render() {
// 透传参数,而不要去修改它
return <WrappedComponent {...this.props} />
}
}

这样优化之后的 withLogging 是一个纯函数,并不会修改 WrappedComponent 组件,所以不需要担心有什么副作用,进而达到组件复用的目的

透传不相关 props 属性给被包裹的组件

使用高阶组件,我们可以代理所有的 props,但往往特定的 HOC 只会用到其中的一个或几个 props,我们需要把其他不相关的 props 透传给原组件,如下面的代码

1
2
3
4
5
6
7
8
const HOC = (WrappedComponent) =>
class extends Component {
render() {
const { visible, ...props } = this.props
if (visible === false) return null
return <WrappedComponent {...props} />
}
}

这里我们只使用 visible 属性来控制组件的显示可隐藏,然后把其他的 props 全部透传下去

不要在 render() 方法中使用高阶组件

React Diff 算法的原则是

  • 使用组件标识确定是卸载还是更新组件
  • 如果组件的和前一次渲染时标识是相同的,递归更新子组件
  • 如果标识不同卸载组件重新挂载新组件

每次调用高阶组件生成的都是是一个全新的组件,组件的唯一标识响应的也会改变,如果在 render 方法调用了高阶组件,这会导致组件每次都会被卸载后重新挂载

1
2
3
4
5
6
7
8
9
class SomeComponent extends React.Component {
render() {
// 调用高阶函数的时候每次都会返回一个新的组件
const EnchancedComponent = enhance(WrappedComponent)
// 每次 render 的时候,都会使子对象树完全被卸载和重新
// 重新加载一个组件会引起原有组件的状态和它的所有子组件丢失
return <EnchancedComponent />
}
}

使用 compose 组合高阶组件

1
2
// 不要这么使用
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

针对于上面这种情况,我们可以使用一个 compose 函数组合这些高阶组件,lodash/redux/ramda 等第三方库都提供了类似 compose 功能的函数

1
2
3
const enhance = compose(withRouter, connect(commentSelector))

const EnhancedComponent = enhance(WrappedComponent)

因为按照约定实现的高阶组件其实就是一个纯函数,如果多个函数的参数一样(在这里 withRouter 函数和 connect(commentSelector) 所返回的函数所需的参数都是 WrappedComponent),所以就可以通过 compose 方法来组合这些函数

关于 compose 相关内容我们在上方的组合多个高阶组件的章节中已经简单介绍过了,更多详细内容可以参考 Redux-Compose 来了解更多

包装显示名字以便于调试

高阶组件创建的容器组件在 React Developer Tools 中的表现和其它的普通组件是一样的,为了便于调试,可以选择一个显示名字,传达它是一个高阶组件的结果

1
2
3
4
5
6
7
8
9
const getDisplayName = WrappedComponent => WrappedComponent.displayName || WrappedComponent.name || 'Component'

function HigherOrderComponent(WrappedComponent) {
class HigherOrderComponent extends React.Component { /* ... */ }

HigherOrderComponent.displayName = `HigherOrderComponent(${getDisplayName(WrappedComponent)})`

return HigherOrderComponent
}

实际上有一些第三方类库,比如 recompose 等已经帮我们实现了类似的功能,如下

1
2
3
4
5
6
7
8
9
import getDisplayName from 'recompose/getDisplayName'

HigherOrderComponent.displayName = `HigherOrderComponent(${getDisplayName(BaseComponent)})`

// or

import wrapDisplayName from 'recompose/wrapDisplayName'

HigherOrderComponent.displayName = wrapDisplayName(BaseComponent, 'HigherOrderComponent')

高阶组件的应用场景

最后我们来看一下如何在业务场景中使用高阶组件

权限控制

利用高阶组件的条件渲染特性可以对页面进行权限控制,权限控制一般分为两个维度,页面级别和页面元素级别,这里以页面级别为例,首先是我们的高阶组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function withAdminAuth(WrappedComponent) {
return class extends React.Component {
state = {
isAdmin: false,
}
async componentDidMount() {
const currentRole = await getCurrentUserRole()
this.setState({
isAdmin: currentRole === 'Admin',
})
}
render() {
if (this.state.isAdmin) {
return <WrappedComponent {...this.props} />
} else {
return <div>您没有权限查看该页面,请联系管理员!</div>
}
}
}
}

然后是两个页面 PageAPageB

1
2
3
4
5
6
7
8
9
10
11
12
13
class PageA extends React.Component {
constructor(props) {
super(props)
}
componentDidMount() {
// fetching data
}
render() {
// render page with data
}
}

export default withAdminAuth(PageA)
1
2
3
4
5
6
7
8
9
10
11
12
13
class PageB extends React.Component {
constructor(props) {
super(props)
}
componentDidMount() {
// fetching data
}
render() {
// render page with data
}
}

export default withAdminAuth(PageB)

使用高阶组件对代码进行复用之后,可以发现是非常方便就可以进行拓展的,但是如果后续需求有所调整,比如某个组件需只要 VIP 权限就可以访问了,那该如何处理呢?当然你可以新写一个高阶组件 withVIPAuth 来进行使用

但是在这里我们可以采用一种更为高效的方式,那就是在高阶组件之上再抽象一层,所以我们就无需去实现各种 withXXXAuth 高阶组件,我们要做的就是实现一个返回高阶组件的函数,把变的部分(比如这里的 AdminVIP 等)抽离出来,保留不变的部分,具体实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const withAuth = role => WrappedComponent => {
return class extends React.Component {
state = {
permission: false
}
async componentDidMount() {
const currentRole = await getCurrentUserRole()
this.setState({
permission: currentRole === role
})
}
render() {
if (this.state.permission) {
return <WrappedComponent {...this.props} />
} else {
return <div>您没有权限查看该页面,请联系管理员!</div>
}
}
}
}

可以发现经过对高阶组件再进行了一层抽象后,前面的 withAdminAuth 可以写成 withAuth('Admin') 了,如果此时需要 VIP 权限的话,只需在 withAuth 函数中传入 'VIP' 就可以了,可以发现其实是和 React-Reduxconnect 方法的使用方式非常像,关于这部分更为详细的内容可以参考我们之前手动实现的 connect 方法

组件渲染性能追踪

借助父组件子组件生命周期规则捕获子组件的生命周期,可以方便的对某个组件的渲染时间进行记录

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
27
28
29
class Home extends React.Component {
render() {
return <h1>Hello World.</h1>
}
}

function withTiming(WrappedComponent) {
return class extends WrappedComponent {
constructor(props) {
super(props)
this.start = 0
this.end = 0
}
UNSAFE_componentWillMount() {
super.UNSAFE_componentWillMount && super.UNSAFE_componentWillMount()
this.start = Date.now()
}
componentDidMount() {
super.componentDidMount && super.componentDidMount()
this.end = Date.now()
console.log(`${WrappedComponent.name} 组件渲染时间为 ${this.end - this.start} ms`)
}
render() {
return super.render()
}
}
}

export default withTiming(Home)

如下,withTiming 是利用反向继承实现的一个高阶组件,功能是计算被包裹组件(这里是 Home 组件)的渲染时间

页面复用

假设我们有两个页面 pageApageB 分别渲染两个分类的电影列表,普通写法可能是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PageA extends React.Component {
state = {
movies: [],
}

// ...

async componentDidMount() {
const movies = await fetchMoviesByType('science-fiction')
this.setState({
movies,
})
}

render() {
return <MovieList movies={this.state.movies} />
}
}

export default PageA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PageB extends React.Component {
state = {
movies: [],
}

// ...

async componentDidMount() {
const movies = await fetchMoviesByType('action')
this.setState({
movies,
})
}

render() {
return <MovieList movies={this.state.movies} />
}
}

export default PageB

页面少的时候可能没什么问题,但是假如随着业务的进展,需要上线的越来越多类型的电影,就会写很多的重复代码,所以我们需要重构一下

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
const withFetching = fetching => WrappedComponent => {
return class extends React.Component {
state = {
data: [],
}
async componentDidMount() {
const data = await fetching()
this.setState({
data,
})
}
render() {
return <WrappedComponent data={this.state.data} {...this.props} />
}
}
}

// A 组件
export default withFetching(fetching('science-fiction'))(MovieList)

// B 组件
export default withFetching(fetching('action'))(MovieList)

// C 组件
export default withFetching(fetching('some-other-type'))(MovieList)

重构完以后可以发现,其实 withFetching 和前面的 withAuth 函数类似,我们只是把变的部分(fetching(type))抽离到外部传入,从而实现页面的复用

高阶组件的缺陷

虽然高阶组件解决了很多我们在之前 Mixin 章节当中介绍到过的一些问题,但是它依然还是存在一些缺陷的

  • HOC 需要在原组件上进行包裹或者嵌套,如果大量使用 HOC,将会产生非常多的嵌套,这让调试变得非常困难
  • HOC 可以劫持 props,在不遵守约定的情况下也可能造成冲突

但是不用担心,我们在接下来会介绍一种更为简单,也是现在更为流行的的解决方式,那就是 Hook,使用它可以帮助我们同时解决 MixinHOC 带来的问题

参考

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×