1. 前言 👀
- 在
React 16.8
以前,像状态和生命周期函数这样的React
特性只适用于类组件,函数组件由于无法访问状态和生命周期函数,只能用来作为 UI 组件。React Hooks
的出现使函数组件能够以新的方式编写、重用和共享React
代码。 - 在我们的多个项目中也开始使用了
React Hooks
。然而,在实际开发过程中,我发现React Hooks
除了带来简洁的代码外,也存在对其使用不当的情况。 - 在这篇文章中,我对
React Hooks
的使用进行了总结,分享出我认为的最佳实践,供大家参考。
2. 一些错误示范 🙅
2.1 使用 React.memo() 包装类组件
-
现在有一个显示日期的
<ShowDate/>
组件,每一秒都会重新渲染一次,考虑页面性能,我们需要控制<Title/>
组件的重新渲染。 -
错误示范 ❌
-
问题剖析 🌟
React.memo()
是React
的一个顶层API
且为高阶组件,它对组件做的事类似于React.PureComponet
,用于控制组件的重新渲染,不同的是:React.memo()
是对函数组件进行优化,React.PureComponent
是定义类组件时使用的。- 相比于
React.PureComponent
,React.memo()
可以支持指定一个参数,相当于shouldComponentUpdate
的作用,如果该参数不传递,则默认只会进行props
的浅比较。
-
正确示范 ✅
-
类组件:
-
函数式组件:
2.2 使用 useState 导致了不必要的重新渲染
-
现在组件有两个按钮,点【增加数量】按钮会对
count
进行加 1 操作,点击【提交数量】按钮会将数量提交给后端。 -
错误示范 ❌
-
问题剖析 🌟
React
中任何state
更新都会触发组件以及它的子组件重新渲染。上面的示例我们没有在render
部分用到count
这个state
,当我们每次设置计数器的时候将会触发不需要的渲染,可能会影响性能或者产生其它副作用。React
提供了一个useRef Hook
,返回一个可变的ref
对象(这个ref
对象只有一个current
属性),其在组件的整个生命周期内保持不变。useRef
变化不会主动使页面渲染,利用这个特性,我们可以对代码进行改造。
-
正确示范 ✅
2.3 使用过时状态
-
现在有个组件,目标是在单击按钮时增加状态变量
count
。 -
错误示范 ❌
-
问题剖析 🌟
- 虽然按钮被点击时,
React
调用setCount(count + 1)
3次,但计数只会只增加1。发生这种情况是因为状态值只会在下一次渲染中更新。 - 这里有一个好规则可以避免遇到过时的变量:如果你使用当前状态来计算下一个状态,总是使用函数方式来更新状态。
- 虽然按钮被点击时,
-
正确示范 ✅
3. 最佳实践 🙆
- 在第二章我们介绍了一些
React Hooks
的错误示范,如果不改邪归正,可能会对业务代码造成意想不到的结果。与前面章节不同的是,本章将介绍一些React Hooks
在项目中的可以更好的实现。
3.1 使用 ESLint 的 React Hooks 插件
-
尽管
React
官方文档给出了两条 Hook 规则,但无论是新手还是经验丰富的 React 开发人员,都常常会忘记遵循React Hooks
的规则。 -
因此,
React
团队开发了一个名为eslint-plugin-react-hooks
的ESLint
插件,以帮助开发人员在自己的项目中以正确的方式编写React Hooks
。 -
这个插件能够帮助我们在尝试运行应用程序之前捕获并修复
Hooks
错误,所以最好将此插件添加到我们有使用React Hooks
的项目中。 -
需要注意的是,
eslint-plugin-react-hooks
插件约定,当在以大驼峰法命名的函数(视作一个组件)或在useSomething
函数(视作一个自定义Hook
)中调用Hooks
时,lint
规则才能正常地工作: -
如以下代码所示:
-
当我们在项目中使用了
eslint-plugin-react-hooks
插件后,会发生eslint
报错,提示我们不能条件式调用Hooks
: -
而当我们将组件改成匿名默认导出时:
-
可以看到,
lint
规则并没有生效(相关issue): -
所以在实际开发过程中,在写函数式组件时,要给组件进行大驼峰法命名;而在写自定义
Hook
时,也要遵循useSomething
的命名规则。
3.2 以正确的顺序创建函数组件
- 当创建类组件时,遵循一定的顺序可以帮助我们更好地维护和改进
React
应用程序代码:static
开头的类属性- 构造函数,
constructor
getter
/setter
- 组件生命周期
_
开头的私有方法- 事件监听方法,
handle*
render*
开头的方法,有时候render()
方法里面的内容会分开到不同函数里面进行,这些函数都以render*
开头render()
方法
- 虽然不按上面顺序组织代码,组件功能也不会有什么影响。但是,如果所有的组件都按这种顺序来编写,那么维护起来就会方便很多,多人协作的时候别人理解代码也会一目了然。
- 对函数组件而言,并没有所谓的构造器和生命周期函数,只要编写的代码按照 Hook 规则实施,一般都不会遇到什么问题:
- 与类组件一样,虽然编写顺序对功能实现没有大的影响,但是,为函数组件创建定义的结构能够改善项目的可读性。
- 推荐在函数顶部使用
useState Hook
和useRef Hook
,然后使用useEffect Hook
编写订阅,接着编写与组件作业相关的其他函数,最后返回要由浏览器渲染的元素: - 通过强制使用一种结构,可以让代码流在众多组件之间保持一致,利于组件后期的维护,提升团队开发效率。
3.3 掌握useEffect中的异步用法
- 我们都知道,
useEffect
用来引入具有副作用的操作,最常见的就是向服务器请求数据。对React Hook
不熟悉的小白可能会在没有使用Typescript
的项目中犯以下错误: - 当在跳转路由(离开当前界面)的时候会遇到如下错误:
- 而我们都知道,
Typescript
能帮我们做静态类型检查,若在使用Typescript
的项目的useEffect
中错误使用了异步函数,编译器就会及时地报出下面的错误(而不至于在组件卸载时才触发报错):
Argument of type '() => Promise' is not assignable to parameter of type 'EffectCallback'.
- 发生这样错误的原因是使用异步函数导致我们在使用
useEffect()
的时候返回的是一个值而非函数。通常,effects
需要在组件销毁之前清除创建的资源,例如订阅或计时器ID。为此,传递给useEffect
的函数应该返回一个清理函数。原本需要返回的是一个cleanup
函数,而使用异步函数会使callback
返回Promise
而不是cleanup
函数,因此当组件销毁时候,就会发生报错的情况。 - 为了避免直接将
Promise
作为effects
返回值,我们可以把异步函数独立出来: - 或使用IIFE( Immediately Invoked Function Expression)匿名函数表达式:
3.4 尽量避免使用 useLayoutEffect
useEffect
和useLayoutEffect
,是两个工作方式很相似的React Hook
,它们二者的不同在于执行时机:useEffect
是在渲染函数执行完成,并绘制到屏幕之后,再异步执行useLayoutEffect
是在渲染函数执行之后,屏幕重绘前同步执行
- 因为
useLayoutEffect
是同步执行的,因此会发生阻塞,直到该effect
执行完成才会进行页面重绘,如果effect
内部有执行很慢的代码,可能会引起性能问题。因此,React
官方指出,尽可能使用标准的useEffect
以避免阻塞视觉更新。 useLayoutEffect
也不是毫无作用,下面介绍它的一个使用场景。- 当点击按钮时,会发现页面发生闪烁:
- 原因在于,
useEffect
的触发时机会被延迟到DOM
绘制完成。因此我们点击按钮setState
后,其实经历了设置高度为 50
->绘制屏幕
->设置高度为100
的两次render
过程,所以肉眼才会看到中间过渡的state
导致的闪烁的感觉。 - 而前面提到,
useLayoutEffect
会在所有DOM
改变后,同步调用。在浏览器运行绘制之前,useLayoutEffect
内部的更新将被同步刷新。正因为这个hook
的特性,我们可以使用它来让DOM
的渲染慢一拍,等待state
真正更新完后才去渲染浏览器的画面。 - 我们将
useEffect
改为useLayoutEffect
: - 可以看到,页面不再闪烁:
- 🏁 大部分场景中,
useEffect
是正确的选择。如果遇到闪烁的场景,可以换到useLayoutEffect
,看一下是否能解决问题。
3.5 使用 useContext 避免 prop-drilling
prop-drilling
是React
应用程序中的常见问题,指的是将数据从一个父组件通过多个中间React
组件层向下传递,直到最终到达指定的子组件,而中间的组件实际上并不需要这些数据。如以下的代码所示:- 可以看到,即使
<Hello/>
组件不需要props
,<App/>
组件也会通过<Hello/>
组件将name props
传递给<Greeting/>
组件。当组件层级很深,如果我们想把name props
的属性名改成userName
,则需要改动到多个组件,将产生许多多余的工作量。 React Context
是一项功能,它提供了一种通过组件树向下传递数据的方法,这种方法无需在组件之间手动传props
。父组件中定义的React Context
的值可由其子级通过useContext Hook
访问。- 改造后,
<App/>
的任何子组件都可以通过useContext Hook
访问数据。
3.6 善用 useMemo / useCallback
- 前面提到,我们可以使用
React.memo()
这个高阶组件来控制函数组件的重复渲染。 - 导致重复渲染的原因是
React Hooks
使用的是函数组件,父组件的任何一次修改,都会导致子组件的函数执行,从而重新进行渲染。 - 因此,为了性能方面考虑,除了使用
React.memo()
对函数组件进行包装,我们还可以使用React
提供的useCallback
和useMemo
来对针对函数和函数的返回值进行缓存。 - 需要注意的是,
useCallback
和useMemo
要结合React.memo()
才能避免子组件无效渲染。
3.7 善用惰性初始化函数提升性能
useState
的函数的 ts 定义如下:
- 可以看到,
useState
支持我们在调用的时候直接传入一个值,来指定state
的默认值,如useState(0)
,useState({ name: 'Tom' })
等,还支持我们传入一个函数,来通过逻辑计算出默认值: - 如下面的例子:
- 当函数组件更新时,函数组件内所有代码都会重新执行一遍。获取
initialState
的初始值是一个相对开销较大的IO
操作。而当每次函数组件re-render
时,第一行代码都会被执行一次,引起不必要的性能损耗。因此,我们可以对代码改造如下: - 当我们的初始
state
需要通过复杂计算或通过比较大开销获得时,则可以传入一个函数(我们称为惰性初始化函数),在函数中计算并返回初始的state
,此函数只在初始渲染时被调用,组件更新时不会再调用该函数,可以在这种场景下规避不必要的性能问题。
3.8 善用自定义 Hooks 捆绑封装逻辑与相关 state
-
在
React Hooks
出现以前,我们可以通过 Render Props 和高阶组件(HOC)两种方式来实现React
组件的状态逻辑共享。 -
而如今,得益于
Hooks
的逻辑封装能力,我们可以将常见的逻辑封装起来,以减少代码复杂度。 -
而且一个页面上往往有很多状态,这些状态分别有各自的处理逻辑,如果用类组件的话,这些状态和逻辑都会混在一起,不够直观:
-
通过使用
React Hooks
后,我们可以把状态和逻辑关联起来,分拆成多个自定义Hooks
,代码结构就会变得更清晰: -
比如现在有多个页面,都需要用到在
antd Modal
组件中嵌入自己的表单,同时可以由组件自己控制Modal
的显示: -
为了避免在每个页面都写重复的逻辑,我们实现了
useActionModal
的自定义Hooks
,代码如下: -
在群组页面使用代码示例:
-
可以看到,通过将多个页面共有的逻辑封装在
useActionModal
中,可以大大减少代码量和维护成本。 -
需要强调的是,我们不能在类组件中使用
Hooks
,所以如果项目中还有老式的类组件,需要使用自定义Hooks
,就需要将它们转换为函数式组件或者使用其他可重用逻辑模式(HOC
或Render Props
),如可将Hook
包装成HOC
: -
使用的时候将我们的目标组件用上述的
withHooksHOC
包装起来,那么我们就可以将width
属性传递给目标组件: -
由于官方自带的
Hooks
远远无法满足我们的开发需求 ,目前社区上别人也封装了一些自定义Hooks
库,如 react-use 等,大家可以学习一下相关自定义Hooks
的实现。
3.9 对自定义 Hooks 增加单元测试
-
作为当下比较流行的自定义
Hooks
库, react-use 提供了健全的单元测试。通过翻看其package.json
文件可以看到,它使用了@testing-library/react-hooks
这个包对其实现的自定义Hooks
做单元测试。 -
🏁 本节介绍的重点是介绍一下如何在项目中引入
@testing-library/react-hooks
这个库,实现我们的最终目标 - 对我们编写的自定义Hooks
增加单元测试。 -
一开始,我按照 react-hooks-testing-library 文档说明,安装
@testing-library/react-hooks
后,以为就可以开始写单测了,结果运行的时候报了 (0 , _reactTestRenderer.act) is not a function 的错误,通过一番搜索,发现虽然文档写了react-dom
和react-test-renderer
只需安装其中一个就可以。但是文档所言非实 😒,尽管项目中安装了react-dom
,大家还是需要安装react-test-renderer
才能解决这个报错问题: -
而且,非常重要的一点是,安装的版本
react
/react-dom
和react-test-renderer
版本要匹配,否则,还是会报上述错误。 -
当安装了
react-test-renderer
以后,可以成功跑单测了,但是在运行应用程序时,提示了 解决React中遇到的 “xxxx”不能用作 JSX 组件 问题,原因是 react-hooks-testing-library 文档中提到,如果我们项目中同时安装了react-dom
和react-test-renderer
,将使用react-test-renderer
的默认设置:react-test-renderer
的package.json
中的dependencies
设置了@types/react
版本为>=16.9.0
,虽然在我们项目的package.json
中为@types/react
指定了版本为^16.9.23
,但是react-test-renderer
还是会安装自己的依赖包@types/react
到现在最新的18.x
版本,并作为项目默认设置。- 由于
React 18
带来很多新的能力,也新定义了许多组件类型,安装的@types/react
版本与我们项目使用的react
版本不匹配,导致会出现这样的 TS 类型报错。
-
最终解决方案是在
package.json
中通过resolutions
字段指定@types/react
版本,则顺利引入了react-test-renderer
这个单元测试包: -
由于自定义
Hooks
封装了一些常用逻辑,会在多个组件中进行复用,所以对自定义Hooks
进行单元测试是十分有必要的!
3.10 * 使用 Redux Hooks 替代 connect
-
本条是针对
React + Redux
项目的实践建议,如果项目中使用的是Mobx
或其他状态管理库,可跳过本条实践。 -
React Redux
在7.1
版本中提供了一组Hooks
作为现有connect()
高阶组件的替代方案:useSelector
:替换connect()
的mapStateToProps
方法。它接受一个函数作为参数,该函数使用Redux
的存储状态并返回所需的状态。useDispatch
:替换connect()
的mapDispatchToProps
方法。它所做的只是返回store
的dispatch
方法,因此我们也可以手动调用dispatch
。
-
下面通过代码来对比两者写法不同:
-
使用
connect()
:
-
使用
Redux Hooks
: -
可见,将
useSelector
和useDispatch
作为connect()
的替代方案,代码更干净,也显得更有条理。
4. 总结 ✍️
React Hooks
作为React
库的重要补充,它使函数组件能够以新的方式编写、重用和共享React
代码。。- 随着
Hooks
开始改变开发人员编写React
组件的方式,需要编写一套React Hooks
的最佳实践,以便团队内成员更轻松地开发和协作。 - 虽然本文肯定还有遗漏的内容,但我希望本文分享的技巧能多少帮助大家在项目中以正确的方式编写
React Hooks
。