React Hooks 最佳实践 🔧

15,089 阅读15分钟

1. 前言 👀

  • React 16.8 以前,像状态和生命周期函数这样的 React 特性只适用于类组件,函数组件由于无法访问状态和生命周期函数,只能用来作为 UI 组件。React Hooks 的出现使函数组件能够以新的方式编写、重用和共享 React 代码。
  • 在我们的多个项目中也开始使用了 React Hooks。然而,在实际开发过程中,我发现 React Hooks 除了带来简洁的代码外,也存在对其使用不当的情况。
  • 在这篇文章中,我对 React Hooks 的使用进行了总结,分享出我认为的最佳实践,供大家参考。

2. 一些错误示范 🙅

2.1 使用 React.memo() 包装类组件

  • 现在有一个显示日期的 <ShowDate/> 组件,每一秒都会重新渲染一次,考虑页面性能,我们需要控制 <Title/> 组件的重新渲染。 image.png

  • 错误示范 ❌
    image.png

  • 问题剖析 🌟

    • React.memo()React 的一个顶层 API 且为高阶组件,它对组件做的事类似于 React.PureComponet,用于控制组件的重新渲染,不同的是:React.memo() 是对函数组件进行优化,React.PureComponent 是定义类组件时使用的。
    • 相比于 React.PureComponentReact.memo() 可以支持指定一个参数,相当于 shouldComponentUpdate 的作用,如果该参数不传递,则默认只会进行 props 的浅比较。 image.png
  • 正确示范 ✅

  • 类组件: image.png

  • 函数式组件: image.png

2.2 使用 useState 导致了不必要的重新渲染

  • 现在组件有两个按钮,点【增加数量】按钮会对 count 进行加 1 操作,点击【提交数量】按钮会将数量提交给后端。

  • 错误示范 ❌ image.png

  • 问题剖析 🌟

    • React 中任何 state 更新都会触发组件以及它的子组件重新渲染。上面的示例我们没有在 render 部分用到 count 这个 state,当我们每次设置计数器的时候将会触发不需要的渲染,可能会影响性能或者产生其它副作用。
    • React 提供了一个 useRef Hook,返回一个可变的 ref 对象(这个 ref 对象只有一个 current 属性),其在组件的整个生命周期内保持不变。useRef 变化不会主动使页面渲染,利用这个特性,我们可以对代码进行改造。
  • 正确示范 ✅
    image.png

2.3 使用过时状态

  • 现在有个组件,目标是在单击按钮时增加状态变量 count

  • 错误示范 ❌
    image.png

  • 问题剖析 🌟

    • 虽然按钮被点击时,React 调用 setCount(count + 1) 3次,但计数只会只增加1。发生这种情况是因为状态值只会在下一次渲染中更新。
    • 这里有一个好规则可以避免遇到过时的变量:如果你使用当前状态来计算下一个状态,总是使用函数方式来更新状态。
  • 正确示范 ✅
    image.png

3. 最佳实践 🙆

  • 在第二章我们介绍了一些 React Hooks 的错误示范,如果不改邪归正,可能会对业务代码造成意想不到的结果。与前面章节不同的是,本章将介绍一些 React Hooks 在项目中的可以更好的实现。

3.1 使用 ESLint 的 React Hooks 插件

  • 尽管 React 官方文档给出了两条 Hook 规则,但无论是新手还是经验丰富的 React 开发人员,都常常会忘记遵循 React Hooks 的规则。

  • 因此,React 团队开发了一个名为 eslint-plugin-react-hooksESLint 插件,以帮助开发人员在自己的项目中以正确的方式编写 React Hooks

  • 这个插件能够帮助我们在尝试运行应用程序之前捕获并修复 Hooks 错误,所以最好将此插件添加到我们有使用 React Hooks 的项目中。

  • 需要注意的是,eslint-plugin-react-hooks 插件约定,当在以大驼峰法命名的函数(视作一个组件)或在 useSomething 函数(视作一个自定义 Hook)中调用Hooks 时,lint 规则才能正常地工作: image.png

  • 如以下代码所示: image.png

  • 当我们在项目中使用了 eslint-plugin-react-hooks 插件后,会发生 eslint 报错,提示我们不能条件式调用 Hooksimage.png

  • 而当我们将组件改成匿名默认导出时: image.png

  • 可以看到,lint 规则并没有生效(相关issue): image.png

  • 所以在实际开发过程中,在写函数式组件时,要给组件进行大驼峰法命名;而在写自定义 Hook 时,也要遵循 useSomething 的命名规则。

3.2 以正确的顺序创建函数组件

  • 当创建类组件时,遵循一定的顺序可以帮助我们更好地维护和改进 React 应用程序代码:
    • static 开头的类属性
    • 构造函数,constructor
    • getter/setter
    • 组件生命周期
    • _ 开头的私有方法
    • 事件监听方法,handle*
    • render*开头的方法,有时候 render() 方法里面的内容会分开到不同函数里面进行,这些函数都以 render* 开头
    • render() 方法
  • 虽然不按上面顺序组织代码,组件功能也不会有什么影响。但是,如果所有的组件都按这种顺序来编写,那么维护起来就会方便很多,多人协作的时候别人理解代码也会一目了然。
  • 对函数组件而言,并没有所谓的构造器和生命周期函数,只要编写的代码按照 Hook 规则实施,一般都不会遇到什么问题: image.png
  • 与类组件一样,虽然编写顺序对功能实现没有大的影响,但是,为函数组件创建定义的结构能够改善项目的可读性。
  • 推荐在函数顶部使用 useState HookuseRef Hook,然后使用 useEffect Hook 编写订阅,接着编写与组件作业相关的其他函数,最后返回要由浏览器渲染的元素: image.png
  • 通过强制使用一种结构,可以让代码流在众多组件之间保持一致,利于组件后期的维护,提升团队开发效率。

3.3 掌握useEffect中的异步用法

  • 我们都知道,useEffect 用来引入具有副作用的操作,最常见的就是向服务器请求数据。对 React Hook 不熟悉的小白可能会在没有使用 Typescript 的项目中犯以下错误: image.png
  • 当在跳转路由(离开当前界面)的时候会遇到如下错误: image.png
  • 而我们都知道,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 返回值,我们可以把异步函数独立出来: image.png
  • 或使用IIFE( Immediately Invoked Function Expression)匿名函数表达式: image.png

3.4 尽量避免使用 useLayoutEffect

  • useEffectuseLayoutEffect,是两个工作方式很相似的React Hook,它们二者的不同在于执行时机:
    • useEffect 是在渲染函数执行完成,并绘制到屏幕之后,再异步执行
    • useLayoutEffect是在渲染函数执行之后,屏幕重绘前同步执行
  • 因为 useLayoutEffect 是同步执行的,因此会发生阻塞,直到该 effect 执行完成才会进行页面重绘,如果 effect 内部有执行很慢的代码,可能会引起性能问题。因此,React 官方指出,尽可能使用标准的 useEffect 以避免阻塞视觉更新。
  • useLayoutEffect 也不是毫无作用,下面介绍它的一个使用场景。 image.png
  • 当点击按钮时,会发现页面发生闪烁: image.png
  • 原因在于,useEffect 的触发时机会被延迟到 DOM 绘制完成。因此我们点击按钮 setState 后,其实经历了 设置高度为 50 -> 绘制屏幕 -> 设置高度为100 的两次 render 过程,所以肉眼才会看到中间过渡的 state 导致的闪烁的感觉。
  • 而前面提到,useLayoutEffect 会在所有 DOM 改变后,同步调用。在浏览器运行绘制之前,useLayoutEffect 内部的更新将被同步刷新。正因为这个 hook 的特性,我们可以使用它来让 DOM 的渲染慢一拍,等待 state 真正更新完后才去渲染浏览器的画面。
  • 我们将 useEffect 改为 useLayoutEffectimage.png
  • 可以看到,页面不再闪烁: image.png
  • 🏁 大部分场景中,useEffect 是正确的选择。如果遇到闪烁的场景,可以换到 useLayoutEffect,看一下是否能解决问题。

3.5 使用 useContext 避免 prop-drilling

  • prop-drillingReact 应用程序中的常见问题,指的是将数据从一个父组件通过多个中间 React 组件层向下传递,直到最终到达指定的子组件,而中间的组件实际上并不需要这些数据。如以下的代码所示: image.png
  • 可以看到,即使 <Hello/> 组件不需要 props<App/> 组件也会通过 <Hello/> 组件将 name props 传递给 <Greeting/> 组件。当组件层级很深,如果我们想把 name props 的属性名改成 userName,则需要改动到多个组件,将产生许多多余的工作量。
  • React Context 是一项功能,它提供了一种通过组件树向下传递数据的方法,这种方法无需在组件之间手动传 props。父组件中定义的 React Context 的值可由其子级通过 useContext Hook 访问。 image.png
  • 改造后,<App/> 的任何子组件都可以通过 useContext Hook 访问数据。

3.6 善用 useMemo / useCallback

  • 前面提到,我们可以使用 React.memo() 这个高阶组件来控制函数组件的重复渲染。
  • 导致重复渲染的原因是 React Hooks 使用的是函数组件,父组件的任何一次修改,都会导致子组件的函数执行,从而重新进行渲染。
  • 因此,为了性能方面考虑,除了使用 React.memo() 对函数组件进行包装,我们还可以使用 React 提供的 useCallbackuseMemo 来对针对函数和函数的返回值进行缓存。
  • 需要注意的是, useCallbackuseMemo 要结合 React.memo() 才能避免子组件无效渲染。

3.7 善用惰性初始化函数提升性能

  • useState 的函数的 ts 定义如下:
    image.png
  • 可以看到,useState 支持我们在调用的时候直接传入一个值,来指定 state 的默认值,如 useState(0)useState({ name: 'Tom' }) 等,还支持我们传入一个函数,来通过逻辑计算出默认值: image.png
  • 如下面的例子: image.png
  • 当函数组件更新时,函数组件内所有代码都会重新执行一遍。获取 initialState 的初始值是一个相对开销较大的 IO 操作。而当每次函数组件 re-render 时,第一行代码都会被执行一次,引起不必要的性能损耗。因此,我们可以对代码改造如下: image.png
  • 当我们的初始 state 需要通过复杂计算或通过比较大开销获得时,则可以传入一个函数(我们称为惰性初始化函数),在函数中计算并返回初始的 state,此函数只在初始渲染时被调用,组件更新时不会再调用该函数,可以在这种场景下规避不必要的性能问题。

3.8 善用自定义 Hooks 捆绑封装逻辑与相关 state

  • React Hooks 出现以前,我们可以通过 Render Props高阶组件(HOC)两种方式来实现 React 组件的状态逻辑共享。

  • 而如今,得益于 Hooks 的逻辑封装能力,我们可以将常见的逻辑封装起来,以减少代码复杂度。

  • 而且一个页面上往往有很多状态,这些状态分别有各自的处理逻辑,如果用类组件的话,这些状态和逻辑都会混在一起,不够直观: image.png

  • 通过使用 React Hooks 后,我们可以把状态和逻辑关联起来,分拆成多个自定义 Hooks,代码结构就会变得更清晰: image.png

  • 比如现在有多个页面,都需要用到在 antd Modal 组件中嵌入自己的表单,同时可以由组件自己控制 Modal 的显示: image.png

  • 为了避免在每个页面都写重复的逻辑,我们实现了 useActionModal 的自定义 Hooks,代码如下: image.png

  • 在群组页面使用代码示例: image.png

  • 可以看到,通过将多个页面共有的逻辑封装在 useActionModal 中,可以大大减少代码量和维护成本。

  • 需要强调的是,我们不能在类组件中使用 Hooks,所以如果项目中还有老式的类组件,需要使用自定义 Hooks,就需要将它们转换为函数式组件或者使用其他可重用逻辑模式(HOCRender Props),如可将 Hook 包装成 HOCimage.png

  • 使用的时候将我们的目标组件用上述的 withHooksHOC 包装起来,那么我们就可以将 width 属性传递给目标组件: image.png

  • 由于官方自带的 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-domreact-test-renderer 只需安装其中一个就可以。但是文档所言非实 😒,尽管项目中安装了 react-dom ,大家还是需要安装 react-test-renderer 才能解决这个报错问题: image.png

  • 而且,非常重要的一点是,安装的版本 react / react-domreact-test-renderer 版本要匹配,否则,还是会报上述错误。

  • 当安装了 react-test-renderer 以后,可以成功跑单测了,但是在运行应用程序时,提示了 解决React中遇到的 “xxxx”不能用作 JSX 组件 问题,原因是 react-hooks-testing-library 文档中提到,如果我们项目中同时安装了 react-domreact-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 这个单元测试包: image.png

  • 由于自定义 Hooks 封装了一些常用逻辑,会在多个组件中进行复用,所以对自定义 Hooks 进行单元测试是十分有必要的!

3.10 * 使用 Redux Hooks 替代 connect

  • 本条是针对 React + Redux 项目的实践建议,如果项目中使用的是 Mobx 或其他状态管理库,可跳过本条实践。

  • React Redux7.1 版本中提供了一组 Hooks 作为现有 connect() 高阶组件的替代方案:

    • useSelector:替换 connect()mapStateToProps 方法。它接受一个函数作为参数,该函数使用 Redux 的存储状态并返回所需的状态。
    • useDispatch:替换 connect()mapDispatchToProps 方法。它所做的只是返回 storedispatch 方法,因此我们也可以手动调用dispatch
  • 下面通过代码来对比两者写法不同:

  • 使用 connect()
    image.png

  • 使用 Redux Hooksimage.png

  • 可见,将 useSelectoruseDispatch 作为 connect() 的替代方案,代码更干净,也显得更有条理。

4. 总结 ✍️

  • React Hooks 作为 React 库的重要补充,它使函数组件能够以新的方式编写、重用和共享 React 代码。。
  • 随着 Hooks 开始改变开发人员编写 React 组件的方式,需要编写一套 React Hooks 的最佳实践,以便团队内成员更轻松地开发和协作。
  • 虽然本文肯定还有遗漏的内容,但我希望本文分享的技巧能多少帮助大家在项目中以正确的方式编写 React Hooks

5. 参考文章 📝

  1. Hook 规则
  2. React Hooks 详解
  3. React 新的 Context API
  4. React高阶组件(HOC)的入门及实践
  5. react-hooks-testing-library
  6. React.memo & useMemo
  7. 面试官:如何解决React useEffect钩子带来的无限循环问题