我们在之前的 React 中的 Mixin 章节当中介绍了一些 Mixin
的基本原理和它存在的一些问题,而且在之前我们也提到过,React
现在已经不再推荐使用 Mixin
来解决代码复用问题,因为 Mixin
带来的危害比他产生的价值还要巨大,推荐我们使用高阶组件来替代它,所以在本章当中我们就来深入的了解一下什么是高阶组件,它的实现方式和应用场景以及存在的一些问题
使用 HOC 的动机
我们在之前的文章当中提到过使用 Mixin
所带来的风险,在这里我们在简单的总结一下
Mixin
可能会相互依赖,相互耦合,不利于代码维护- 不同的
Mixin
中的方法可能会相互冲突 Mixin
非常多时,组件是可以感知到的,甚至还要为其做相关处理,这样会给代码造成滚雪球式的复杂性
而 HOC
的出现可以解决这些问题
- 高阶组件就是一个没有副作用的纯函数,各个高阶组件不会互相依赖耦合
- 高阶组件也有可能造成冲突,但我们可以在遵守约定的情况下避免这些行为
- 高阶组件并不关心数据使用的方式和原因,而被包裹的组件也不关心数据来自何处,高阶组件的增加不会为原组件增加负担
不过在深入高阶组件之前,让我们先来看看装饰器模式和高阶函数的相关内容,了解这两者的内容以后有助于我们更好的理解高阶组件
装饰器模式
之所以先介绍装饰器模式,这是因为 React
当中的高阶组件其实就是装饰器模式的一种实现,所谓装饰器模式(Decorator Pattern
),它允许向一个现有的对象添加新的功能,同时又不改变其结构,这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装,与继承相比,装饰者是一种更轻便灵活的做法
这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能,这里需要注意的是,它是不会改变原本类的,这一点很关键
JavaScript 当中的装饰器
装饰器是 ES7
中的一个新语法,正如其字面意思而言,它可以对类、方法、属性进行修饰,从而进行一些相关功能定制,简而言之就是对对象进行包装,返回一个新的对象描述(descriptor
),这个概念其实和 React
中的高阶组件也类似,我们来看下面这个示例,假设我们现在要对一个函数 log
,打印出它的执行记录,如果不使用装饰器是下面这样的
1 | const log = (fn) => { |
而如果使用装饰器则是下面这样的
1 | const log = (target, name, descriptor) => { |
从上面的代码可以看出,如果有的时候我们并不需要关心函数的内部实现,仅仅是想调用它的话,装饰器能够带来比较好的可读性,使用起来也是非常的方便
装饰器原理
其实简单来说,JavaScript
当中的装饰器本质也是一个函数,利用的是 JavaScript
中 Object
的 descriptor
,这个函数会接收三个参数
1 | /** |
当然装饰器也可以接受参数,其实就是将外部包装一个函数,而函数可以带参数
1 | /** |
常见的装饰器
这里我们通过 core-decorators 这个库来简单介绍几个比较常用的装饰器,比如 autobind
修饰器可以让方法中的 this
对象绑定原始对象,使得 this
始终指向绑定的对象
1 | import { autobind } from 'core-decorators' |
而 readonly
修饰器使得属性或方法不可写
1 | import { readonly } from 'core-decorators' |
高阶函数
关于高阶函数的定义,维基 上的定义是,高阶函数是至少满足下列一个条件的函数
- 接受一个或多个函数作为输入
- 输出一个函数
比如下面这个示例,就是一个简单的高阶函数
1 | const add = (x, y, f) => f(x) + f(y) |
当我们在调用的时候,参数 x
,y
和 f
分别接收 -5
,6
和 Math.abs
,根据函数定义,我们可以推导计算过程为
1 | x ==> -5 |
所以上面代码执行后的结果为 11
,在简单了解了装饰器模式和高阶函数的基本概念以后,下面就让我们正式的来看看高阶组件的相关内容
高阶组件
那么,什么是高阶组件呢?类比高阶函数的定义,高阶组件就是接受一个组件作为参数并返回一个新组件的函数,这里需要注意高阶组件是一个函数,并不是组件,这一点一定要注意
更通俗地描述为,高阶组件通过包裹(wrapped
)被传入的 React
组件,经过一系列处理,最终返回一个相对增强(enhanced
)的 React
组件,供其他组件调用,下面我们先来实现一个简单的高阶组件(函数)看看它是如何工作的,它接受一个 React
组件,包裹后然后返回
1 | export default function withHeader(WrappedComponent) { |
上面的代码就是一个 HOC
的简单应用,函数接收一个组件作为参数,并返回一个新组件,我们在其他组件里就可以来引用这个高阶组件,用来强化它
1 | @withHeader |
在这里我们使用了上面介绍到的装饰器模式来让写法变得更为优雅,当然下面这种写法也是可以的
1 | const EnhanceDemo = withHeader(Demo) |
如下图所示,我们可以发现 Demo
组件已经被 HOC
组件包裹起来了,符合了高阶组件的预期,即组件是层层包裹起来的,如同洋葱一样
但是随之带来的问题是,如果这个高阶组件被使用了多次,那么在调试的时候,将会看到一大堆 HOC
,所以这个时候需要做一点小优化,就是在高阶组件包裹后,应当保留其原有名称,我们改写一下上述的高阶组件代码,增加了 getDisplayName
函数以及静态属性 displayName
1 | function getDisplayName(component) { |
此时我们再去观察就会如下图所示,可以发现此时原本组件的名称也会正确的显示了
由此可以看出,高阶组件的主要功能是封装并抽离组件的通用逻辑,让此部分逻辑在组件间更好地被复用,但是我们仔细观察上方的示例可以发现,此时这个高阶组件的作用仅仅只是展示了标题名称,但是为了更好的抽象,此标题应当可以被参数化,如下方式调用
1 | // 如果传入参数,则传入的参数将作为组件的标题呈现 |
所以我们来简单的调整一下 withHeader
,让它接受一个参数,然后返回一个高阶组件(函数)
1 | export default function (title) { |
也使用 ES6
写法来进行简化
1 | export default (title) => (WrappedComponent) => class HOC extends Component { |
组合多个高阶组件
我们在上面使用高阶组件为 React
组件添加一个显示标题的功能,但是如果需要同时增加多个功能的话需要如何处理呢?这种场景非常常见,例如我们既需要增加一个组件标题,又需要在此组件未加载完成时显示 Loading
,即下面这种情况
1 | @withHeader |
针对于这种情况,我们可以使用 compose
来简化上述过程,这样也能体现函数式编程的思想
1 | const enhance = compose(withHeader, withLoading) |
这里我们简单的介绍一下 compose
,compose
可以帮助我们组合任意个(包括 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 | import React, { Component } from 'React' |
我们可以看见函数 HOC
返回了新的组件(WrapperComponent
),这个组件原封不动的返回作为参数的组件(也就是被包裹的组件 WrappedComponent
),并将传给它的参数(props
)全部传递给被包裹的组件(WrappedComponent
)
其实简单来说,属性代理其实就是一个函数接受一个 WrappedComponent
组件作为参数传入,并返回一个继承了 React.Component
组件的类,且在该类的 render()
方法中返回被传入的 WrappedComponent
组件,又因为属性代理类型的高阶组件返回的其实是一个标准的 React.Component
组件,相对比于原生组件来说,它可以增强下列一些额外操作
- 可操作所有传入的
props
- 可操作组件的生命周期
- 可操作组件的
static
方法 - 获取
refs
反向继承
反向继承其实就是一个函数接受一个 WrappedComponent
组件作为参数传入,并返回一个继承了该传入 WrappedComponent
组件的类,且在该类的 render()
方法中返回 super.render()
方法
由于继承了原组件,能通过 this
访问到原组件的生命周期,props
,state
,render
等,相比属性代理它能操作更多的属性(有点类似于 Render Props
的感觉)
1 | const HOC = (WrappedComponent) => |
其实我们仔细观察可以发现,其实属性代理和反向继承的实现有些类似的地方,都是返回一个继承了某个父类的子类,只不过属性代理中继承的是 React.Component
,反向继承中继承的是传入的组件 WrappedComponent
,它相对比于原生组件来说,可以增强下列一些额外操作
- 可操作所有传入的
props
- 可操作组件的生命周期
- 可操作组件的
static
方法 - 获取
refs
- 可操作
state
- 可以渲染劫持
高阶组件的功能实现
我们在上面介绍了高阶组件的两种实现方式,也就是属性代理和反向继承,下面我们就来看看利用这两种方式可以实现哪些额外功能
组合渲染
最容易想到的莫过于组合渲染,即可使用任何其他组件和原组件进行组合渲染,达到样式、布局复用等效果,通过属性代理实现方式如下
1 | const HOC = (WrappedComponent) => |
通过反向继承实现方式如下
1 | const HOC = (WrappedComponent) => |
条件渲染
这个也是一个比较常见的使用场景,根据特定的属性决定原组件是否渲染,通过属性代理实现方式如下
1 | const HOC = (WrappedComponent) => |
通过反向继承实现方式如下
1 | const HOC = (WrappedComponent) => |
操作 props
我们也可以对传入组件的 props
进行增加、修改、删除或者根据特定的 props
进行特殊的操作,通过属性代理实现方式如下
1 | const HOC = (WrappedComponent) => |
获取 refs
有的时候我们需要访问 DOM
元素(比如使用第三方 DOM
操作库等)的时候就会用到组件的 ref
属性,它只能声明在 Class
类型的组件上,而无法声明在函数(无状态)类型的组件上,ref
的值可以是字符串(不推荐使用)也可以是一个回调函数,如果是回调函数的话,它的执行时机是
- 组件被挂载后(
componentDidMount
),回调函数立即执行,回调函数的参数为该组件的实例 - 组件被卸载(
componentDidUnmount
)或者原有的ref
属性本身发生变化的时候,此时回调函数也会立即执行,且回调函数的参数为null
那么我们如何在高阶组件中获取到 WrappedComponent
组件的实例呢?答案就是可以通过 WrappedComponent
组件的 ref
属性,该属性会在组件 componentDidMount
的时候执行 ref
的回调函数并传入该组件的实例
1 | const HOC = (WrappedComponent) => |
这里需要注意的是,不能在无状态组件(函数类型组件)上使用
ref
属性,因为无状态组件没有实例
其实简单来说,就是调用高阶组件的时候并不能获取到原组件的真实 ref
,需要我们手动的来进行传递,这里主要涉及到 React.forwardRef
这个 API
,关于这点我们在下面的高阶组件的注意事项章节当中会详细来进行介绍,也可以参考官方文档当中的 Refs 转发 来了解更多
状态管理
将原组件的状态提取到 HOC
中进行管理,如下面的代码,我们将 Input
的 value
提取到 HOC
中进行管理,使它变成受控组件,同时不影响它使用 onChange
方法进行一些其他操作,依然是通过属性代理的方式来进行实现
1 | const proxyHoc = (WrappedComponent) => |
操作 state
这里需要注意,不推荐直接修改或添加原组件的
state
,因为这样有可能和组件内部的操作构成冲突
上面的例子我们通过属性代理的方式利用 HOC
的 state
对原组件进行了一定的增强,但并不能直接控制原组件的 state
,而通过反向继承,我们可以直接操作原组件的 state
,下面是通过反向继承实现
1 | const HOC = (WrappedComponent) => |
上面的 HOC
在 render
中将 props
和 state
打印出来,可以用作调试阶段,当然你可以在里面写更多的调试代码,想象一下,只需要在我们想要调试的组件上加上 @debug
就可以对该组件进行调试,而不需要在每次调试的时候写很多冗余代码
渲染劫持
高阶组件可以在 render
函数中做非常多的操作,从而控制原组件的渲染输出,只要改变了原组件的渲染,我们都将它称之为一种渲染劫持,实际上上面的组合渲染和条件渲染都是渲染劫持的一种,通过反向继承,不仅可以实现以上两点,还可直接增强由原组件 render
函数产生的 React
元素,下面是通过反向继承的实现方式
1 | const HOC = (WrappedComponent) => |
这里需要注意我们在上面提及的是增强而不是更改,因为 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
的浅合并,新的子级会替换现有的子级,来自原始元素的 key
和 ref
将会保留
但是关于反向继承有一个重要的点,那就是反向继承不能保证完整的子组件树被解析,关于这一点我们会在下面的高阶组件存在的问题当中来详细介绍
高阶组件存在的问题
这里我们主要介绍下面三点
静态方法丢失
当我们应用 HOC
去增强另一个组件时,我们实际使用的组件已经不是原组件了,所以我们拿不到原组件的任何静态属性
1 | // 定义静态方法 |
但是我们可以在HOC当中手动的拷贝它们
1 | function HigherOrderComponent(WrappedComponent) { |
但是如果原组件有非常多的静态属性,这个过程是非常痛苦的,而且你需要去了解需要增强的所有组件的静态属性是什么,不过我们可以使用 hoist-non-react-statics 这个库来帮助我们解决这个问题,它可以自动帮我们拷贝所有非 React
的静态方法
1 | import hoistNonReactStatic from 'hoist-non-react-statics' |
refs 属性不能透传
一般来说高阶组件可以传递所有的 props
给包裹的组件 WrappedComponent
,但是有一种属性不能传递,它就是 ref
,与其他属性不同的地方在于 React
对其进行了特殊的处理,如果你向一个由高阶组件创建的组件的元素添加 ref
引用,那么 ref
指向的是最外层容器组件实例的,而不是被包裹的 WrappedComponent
组件
但是很多情况下我们需要用到原组件的 ref
,又因为高阶组件并不能像透传 props
那样将 refs
透传,所以我们可以用一个回调函数来完成 ref
的传递
1 | const HOC = (WrappedComponent) => |
React
在 16.3
的版本当中提供了一个 forwardRef API
来帮助我们进行 refs
传递,这样我们在高阶组件上获取的 ref
就是原组件的 ref
了,而不需要再手动传递,更多内容可以参考 Refs 转发
1 | function forwardRef(WrappedComponent) { |
反向继承不能保证完整的子组件树被解析
关于这一点可能不太好理解,但是我们可以借住 React Components, Elements, and Instances 这篇文章来进行了解,在文章当中主要明确了以下两点内容
- 元素(
element
)是一个是用DOM
节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children
)中包含其他的元素,一旦创建就不会改变,我们通过JSX
和React.createClass
创建的都是元素 - 组件(
component
)可以接受属性(props
)作为输入,然后返回一个元素树(element tree
)作为输出,有多种实现方式,比如Class
或者函数(Function
)
所以,反向继承不能保证完整的子组件树被解析的意思就是,如果解析的元素树中包含了组件(函数类型或者 Class
类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析,比如
1 | import React, { Component } from 'react' |
运行结果如下
通过观察控制台当中的页面结构可以发现,div
下的 span
是可以被完全被解析的,但是 MyFuncComponent
和 MyClassComponent
都是组件类型的,其子组件就不能被完全解析了
高阶组件的约定
高阶组件带给我们极大方便的同时,我们也要遵循一些约定
props 保持一致
高阶组件在为子组件添加特性的同时,要尽量保持原有组件的 props
不受影响,也就是说传入的组件和返回的组件在 props
上尽量保持一致
不要改变原始组件
不要在高阶组件内以任何方式修改一个组件的原型,比如下面的代码
1 | const HOC = (WrappedComponent) => |
可以发现,我们在高阶组件的内部对 WrappedComponent
进行了修改,一旦对原组件进行了修改,那么就失去了组件复用的意义,所以在这种情况下最好使用纯函数(相同的输入总有相同的输出)来返回新的组件
1 |
|
这样优化之后的 withLogging
是一个纯函数,并不会修改 WrappedComponent
组件,所以不需要担心有什么副作用,进而达到组件复用的目的
透传不相关 props 属性给被包裹的组件
使用高阶组件,我们可以代理所有的 props
,但往往特定的 HOC
只会用到其中的一个或几个 props
,我们需要把其他不相关的 props
透传给原组件,如下面的代码
1 | const HOC = (WrappedComponent) => |
这里我们只使用 visible
属性来控制组件的显示可隐藏,然后把其他的 props
全部透传下去
不要在 render() 方法中使用高阶组件
React Diff
算法的原则是
- 使用组件标识确定是卸载还是更新组件
- 如果组件的和前一次渲染时标识是相同的,递归更新子组件
- 如果标识不同卸载组件重新挂载新组件
每次调用高阶组件生成的都是是一个全新的组件,组件的唯一标识响应的也会改变,如果在 render
方法调用了高阶组件,这会导致组件每次都会被卸载后重新挂载
1 | class SomeComponent extends React.Component { |
使用 compose 组合高阶组件
1 | // 不要这么使用 |
针对于上面这种情况,我们可以使用一个 compose
函数组合这些高阶组件,lodash/redux/ramda
等第三方库都提供了类似 compose
功能的函数
1 | const enhance = compose(withRouter, connect(commentSelector)) |
因为按照约定实现的高阶组件其实就是一个纯函数,如果多个函数的参数一样(在这里 withRouter
函数和 connect(commentSelector)
所返回的函数所需的参数都是 WrappedComponent
),所以就可以通过 compose
方法来组合这些函数
关于
compose
相关内容我们在上方的组合多个高阶组件的章节中已经简单介绍过了,更多详细内容可以参考 Redux-Compose 来了解更多
包装显示名字以便于调试
高阶组件创建的容器组件在 React Developer Tools
中的表现和其它的普通组件是一样的,为了便于调试,可以选择一个显示名字,传达它是一个高阶组件的结果
1 | const getDisplayName = WrappedComponent => WrappedComponent.displayName || WrappedComponent.name || 'Component' |
实际上有一些第三方类库,比如 recompose 等已经帮我们实现了类似的功能,如下
1 | import getDisplayName from 'recompose/getDisplayName' |
高阶组件的应用场景
最后我们来看一下如何在业务场景中使用高阶组件
权限控制
利用高阶组件的条件渲染特性可以对页面进行权限控制,权限控制一般分为两个维度,页面级别和页面元素级别,这里以页面级别为例,首先是我们的高阶组件
1 | function withAdminAuth(WrappedComponent) { |
然后是两个页面 PageA
和 PageB
1 | class PageA extends React.Component { |
1 | class PageB extends React.Component { |
使用高阶组件对代码进行复用之后,可以发现是非常方便就可以进行拓展的,但是如果后续需求有所调整,比如某个组件需只要 VIP
权限就可以访问了,那该如何处理呢?当然你可以新写一个高阶组件 withVIPAuth
来进行使用
但是在这里我们可以采用一种更为高效的方式,那就是在高阶组件之上再抽象一层,所以我们就无需去实现各种 withXXXAuth
高阶组件,我们要做的就是实现一个返回高阶组件的函数,把变的部分(比如这里的 Admin
、VIP
等)抽离出来,保留不变的部分,具体实现如下
1 | const withAuth = role => WrappedComponent => { |
可以发现经过对高阶组件再进行了一层抽象后,前面的 withAdminAuth
可以写成 withAuth('Admin')
了,如果此时需要 VIP
权限的话,只需在 withAuth
函数中传入 'VIP'
就可以了,可以发现其实是和 React-Redux
的 connect
方法的使用方式非常像,关于这部分更为详细的内容可以参考我们之前手动实现的 connect 方法
组件渲染性能追踪
借助父组件子组件生命周期规则捕获子组件的生命周期,可以方便的对某个组件的渲染时间进行记录
1 | class Home extends React.Component { |
如下,withTiming
是利用反向继承实现的一个高阶组件,功能是计算被包裹组件(这里是 Home
组件)的渲染时间
页面复用
假设我们有两个页面 pageA
和 pageB
分别渲染两个分类的电影列表,普通写法可能是这样
1 | class PageA extends React.Component { |
1 | class PageB extends React.Component { |
页面少的时候可能没什么问题,但是假如随着业务的进展,需要上线的越来越多类型的电影,就会写很多的重复代码,所以我们需要重构一下
1 | const withFetching = fetching => WrappedComponent => { |
重构完以后可以发现,其实 withFetching
和前面的 withAuth
函数类似,我们只是把变的部分(fetching(type)
)抽离到外部传入,从而实现页面的复用
高阶组件的缺陷
虽然高阶组件解决了很多我们在之前 Mixin
章节当中介绍到过的一些问题,但是它依然还是存在一些缺陷的
HOC
需要在原组件上进行包裹或者嵌套,如果大量使用HOC
,将会产生非常多的嵌套,这让调试变得非常困难HOC
可以劫持props
,在不遵守约定的情况下也可能造成冲突
但是不用担心,我们在接下来会介绍一种更为简单,也是现在更为流行的的解决方式,那就是 Hook,使用它可以帮助我们同时解决 Mixin
和 HOC
带来的问题