在React Conf 2018宣布React Hooks后,我第一时间开始尝试使用React Hooks,现在新项目基本不写Class组件了。对我来说,它确实让我的开发效率提高了很多,改变了已有的组件开发思维和模式.
我在React组件设计实践总结04 - 组件的思维中已经总结过React Hooks的意义,以及一些应用场景。
那这篇文章就完全是介绍React Hooks的应用实例,列举了我使用React Hooks的一些实践。 希望通过这些案例,可以帮助你快速熟练,并迁移到React Hooks开发模式.
文章篇幅很长,建议收藏不看, 至少看看目录吧
把之前文章整理的React Hooks应用场景总结拿过来, 本文基本按照这个范围进行组织:
如果你想要了解React Hooks的原理可以阅读这些文章:
目录索引
- 1. 组件状态
- 2. 模拟生命周期函数
- 3. 事件处理
- 4. Context的妙用
- 5. 副作用封装
- 6. 副作用衍生
- 7. 简化业务逻辑
- 8. 开脑洞
- React Hooks 技术地图
- 总结
1. 组件状态
React提供了一个很基本的组件状态设置Hook:
1 | const [state, setState] = useState(initialState); |
useState返回一个state,以及更新state的函数. setState可以接受一个新的值,会触发组件重新渲染.
React会确保setState函数是稳定的,不会在组件重新渲染时改变。下面的useReducer的dispatch函数、useRef的current属性也一样。
这就意味着setState、dispatch、ref.current, 可以安全地在useEffect、useMemo、 useCallback中引用
1-1 useSetState 模拟传统的setState
useState和Class组件的setState不太一样.
Class组件的state属性一般是一个对象,调用setState时,会浅拷贝到state属性, 并触发更新, 比如:
1 | class MyComp extends React.Component { |
而useState会直接覆盖state值。为了实现和setState一样的效果, 可以这样子做:
1 | const initialState = {name: 'sx', age: 10} |
Ok,现在把它封装成通用的hooks,在其他组件中复用。这时候就体现出来Hooks强大的逻辑抽象能力:Hooks 旨在让组件的内部逻辑组织成可复用的更小单元,这些单元各自维护一部分组件‘状态和逻辑’
看看我们的useSetState, 我会使用Typescript进行代码编写:
1 | function useSetState<S extends object>( |
hooks命名以use为前缀
1-2 useReducer Redux风格状态管理
如果组件状态比较复杂,推荐使用useReducer来管理状态。如果你熟悉Redux,会很习惯这种方式。
1 | // 定义初始状态 |
了解更多reducer的思想可以参考Redux文档
1-3 useForceUpdate 强制重新渲染
Class组件可以通过forceUpdate实例方法来触发强制重新渲染。使用useState也可以模拟相同的效果:
1 | export default function useForceUpdate() { |
1-4 useStorage 简化localStorage存取
通过自定义Hooks,可以将状态代理到其他数据源,比如localStorage。 下面案例展示如果使用Hooks封装和简化localStorage的存取:
1 | import { useState, useCallback, Dispatch, SetStateAction } from 'react' |
1-5 useRefState 引用state的最新值
上图是今年六月份VueConf,尤雨溪的Slide截图,他对比了Vue最新的FunctionBase API和React Hook. 它指出React Hooks有很多问题:
- 每个Hooks在组件每次渲染时都执行。也就是说每次渲染都要重新创建闭包和对象
- 需要理解闭包变量
- 内容回调/对象会导致纯组件props比对失效, 导致组件永远更新
闭包变量问题是你掌握React Hooks过程中的重要一关。闭包问题是指什么呢?举个简单的例子, Counter:
1 | function Counter() { |
假设ComplexButton是一个非常复杂的组件,每一次点击它,我们会递增count,从而触发组将重新渲染。因为Counter每次渲染都会重新生成handleIncr,所以也会导致ComplexButton重新渲染,不管ComplexButton使用了PureComponent还是使用React.memo包装。
为了解决这个问题,React也提供了一个useCallback Hook, 用来‘缓存’函数, 保持回调的不变性. 比如我们可以这样使用:
1 | function Counter() { |
上面的代码是有bug的,不过怎么点击,count会一直显示为1!
再仔细阅读useCallback的文档,useCallback支持第二个参数,当这些值变动时更新缓存的函数, useCallback的内部逻辑大概是这样的:
1 | let memoFn, memoArgs |
Ok, 现在理解一下为什么会一直显示1?
首次渲染时缓存了闭包,这时候闭包捕获的count值是0。在后续的重新渲染中,因为useCallback第二个参数指定的值没有变动,handleIncr闭包会永远被缓存。这就解释了为什么每次点击,count只能为1.
解决办法也很简单,让我们在count变动时,让useCallback更新缓存函数:
1 | function Counter() { |
如果useCallback依赖很多值,你的代码可能是这样的:useCallback(fn, [a, b, c, d, e]). 反正我是无法接受这种代码的,很容易遗漏, 而且可维护性很差,尽管通过ESLint插件可以检查这些问题**。
其实通过useRef Hook,可以让我们像Class组件一样保存一些‘实例变量’, React会保证useRef返回值的稳定性,我们可以在组件任何地方安全地引用ref。
基于这个原理,我们尝试封装一个useRefState, 它在useState的基础上扩展了一个返回值,用于获取state的最新值:
1 | import { useState, useRef, useCallback, Dispatch, SetStateAction, MutableRefObject } from 'react' |
使用示例:
1 | function Counter() { |
useEffect、useMemo和useCallback一样存在闭包变量问题,它们和useCallback一个支持指定第二个参数,当这个参数变化时执行副作用。
1-5-1 每次重新渲染都创建闭包会影响效率吗?
函数组件和Class组件不一样的是,函数组件将所有状态和逻辑都放到一个函数中, 每一次重新渲染会重复创建大量的闭包、对象。而传统的Class组件的render函数则要简洁很多,一般只放置JSX渲染逻辑。相比大家都跟我一样,会怀疑函数组件的性能问题
我们看看官方是怎么回应的:
我在SegmentFault的react function组件与class组件性能问题也进行了详细的回答, 结论是:
目前而言,实现同样的功能,类组件和函数组件的效率是不相上下的。但是函数组件是未来,而且还有优化空间,React团队会继续优化它。而类组件会逐渐退出历史
为了提高函数组件的性能,可以在这些地方做一些优化:
能否将函数提取为静态的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 1️⃣例如将不依赖于组件状态的回调抽取为静态方法
const goback = () => {
history.go(-1)
}
function Demo() {
//const goback = () => {
// history.go(-1)
//}
return <button onClick={goback}>back</button>
}
// 2️⃣ 抽离useState的初始化函数
const returnEmptyObject = () => Object.create(null)
const returnEmptyArray = () => []
function Demo() {
const [state, setState] = useState(returnEmptyObject)
const [arr, setArr] = useState(returnEmptyArray)
// ...
}
复制代码简化组件的复杂度,动静分离
再拆分更细粒度的组件,这些组件使用React.memo缓存
1-6 useRefProps 引用最新的Props
现实项目中也有很多这种场景: 我们想在组件的任何地方获取最新的props值,这个同样可以通过useRef来实现:
1 | export default function useRefProps<T>(props: T) { |
1-7 useInstance ‘实例’变量存取
1 | function isFunction<T>(initial?: T | (() => T)): initial is () => T { |
注意不要滥用
1-9 usePrevious 获取上一次渲染的值
在Class组件中,我们经常会在shouldComponentUpdate或componentDidUpdate这类生命周期方法中对props或state进行比对,来决定做某些事情,例如重新发起请求、监听事件等等.
Hooks中我们可以使用useEffect或useMemo来响应状态变化,进行状态或副作用衍生. 所以上述比对的场景在Hooks中很少见。但也不是不可能,React官方案例中就有一个usePrevious:
1 | function usePrevious(value) { |
1-10 useImmer 简化不可变数据操作
这个案例来源于use-immer, 结合immer.js和Hooks来简化不可变数据操作, 看看代码示例:
1 | const [person, updatePerson] = useImmer({ |
实现也非常简单:
1 | export function useImmer(initialValue) { |
简洁的Hooks配合简洁的Immer,简直完美
1-11 封装’工具Hooks’简化State的操作
Hooks只是普通函数,所以可以灵活地自定义。下面举一些例子,利用自定义Hooks来简化常见的数据操作场景
1-11-1 useToggle 开关
实现boolean值切换
1 | function useToggle(initialValue?: boolean) { |
1-11-2 useArray 简化数组状态操作
1 | function useArray<T>(initial?: T[] | (() => T[]), idKey: string = 'id') { |
限于篇幅,其他数据结构, 例如Set、Map, 就不展开介绍了,读者可以自己发挥想象力.
2. 模拟生命周期函数
组件生命周期相关的操作依赖于useEffect Hook. React在函数组件中刻意淡化了组件生命周期的概念,而更关注‘数据的响应’.
useEffect名称意图非常明显,就是专门用来管理组件的副作用。和useCallback一样,useEffect支持传递第二个参数,告知React在这些值发生变动时才执行父作用. 原理大概如下:
1 | let memoCallback = {fn: undefined, disposer: undefined} |
关于useEffect官网有详尽的描述; Dan Abramov也写了一篇useEffect 完整指南, 推荐👍。
2-1 useOnMount 模拟componentDidMount
1 | export default function useOnMount(fn: Function) { |
如果需要在挂载/状态更新时请求一些资源、并且需要在卸载时释放这些资源,还是推荐使用useEffect,因为这些逻辑最好放在一起, 方便维护和理解:
1 | // 但是useEffect传入的函数不支持async/await(返回Promise) |
2-2 useOnUnmount 模拟componentWillUnmount
1 | export default function useOnUnmount(fn: Function) { |
2-3 useOnUpdate 模拟componentDidUpdate
1 | function useOnUpdate(fn: () => void, dep?: any[]) { |
其他生命周期函数的模拟:
- shouldComponentUpdate - React.memo包裹组件
- componentDidCatch - 暂不支持
3. 事件处理
3-1 useChange 简化onChange表单双向绑定
表单值的双向绑定在项目中非常常见,通常我们的代码是这样的:
1 | function Demo() { |
如果需要维护多个表单,这种代码就会变得难以接受。幸好有Hooks,我们可以简化这些代码:
1 | function useChange<S>(initial?: S | (() => S)) { |
3-2 useBind 绑定回调参数
绑定一些回调参数,并利用useMemo给下级传递一个缓存的回调, 避免重新渲染:
1 | function useBind(fn?: (...args: any[]) => any, ...args: any[]): (...args: any[]) => any { |
3-3 自定义事件封装
Hooks也可以用于封装一些高级事件或者简化事件的处理,比如拖拽、手势、鼠标Active/Hover等等;
3-3-1 useActive
举个简单的例子, useActive, 在鼠标按下时设置状态为true,鼠标释放时恢复为false:
1 | function useActive(refEl: React.RefObject<HTMLElement>) { |
3-3-2 useTouch 手势事件封装
更复杂的自定义事件, 例如手势。限于篇幅就不列举它们的实现代码,我们可以看看它们的Demo:
1 | function Demo() { |
useTouch的实现可以参考useTouch.ts
3-3-3 useDraggable 拖拽事件封装
拖拽也是一个典型的自定义事件, 下面这个例子来源于这里
1 | function useDraggable(ref: React.RefObject<HTMLElement>) { |
可运行例子
3-3-4 react-events 面向未来的高级事件封装
我在<谈谈React事件机制和未来(react-events)>介绍了React-Events这个实验性的API。当这个API成熟后,我们可以基于它来实现更优雅的高级事件的封装:
1 | import { PressResponder, usePressListener } from 'react-events/press'; |
3-4 useSubscription 通用事件源订阅
React官方维护了一个use-subscription包,支持使用Hooks的形式来监听事件源. 事件源可以是DOM事件、RxJS的Observable等等.
先来看看使用示例:
1 | // 监听rxjs behaviorSubject |
现在来看看实现:
1 | export function useSubscription<T>({ |
实现也不复杂,甚至可以说有点啰嗦.
3-5 useObservable Hooks和RxJS优雅的结合(rxjs-hooks)
如果要配合RxJS使用,LeetCode团队封装了一个rxjs-hooks库,用起来则要优雅很多, 非常推荐:
1 | function App() { |
3-6 useEventEmitter 对接eventEmitter
我在React组件设计实践总结04 - 组件的思维这篇文章里面提过:自定义 hook 和函数组件的代码结构基本一致, 所以有时候hooks 写着写着原来越像组件, 组件写着写着越像 hooks. 我觉得可以认为组件就是一种特殊的 hook, 只不过它输出 Virtual DOM
Hooks跟组件一样,是一个逻辑和状态的聚合单元。可以维护自己的状态、有自己的’生命周期’.
useEventEmitter就是一个典型的例子,可以独立地维护和释放自己的资源:
1 | const functionReturnObject = () => ({}) |
更多脑洞:
4. Context的妙用
通过useContext可以方便地引用Context。不过需要注意的是如果上级Context.Provider的value变化,使用useContext的组件就会被强制重新渲染。
4-1 useTheme 主题配置
原本需要使用高阶组件注入或Context.Consumer获取的Context值,现在变得非常简洁:
1 | /** |
Hooks方式
1 | import React, { useContext, FC } from 'react' |
4-2 unstated 简单状态管理器
Hooks + Context 也可以用于实现简单的状态管理。
我在React组件设计实践总结05 - 状态管理就提到过unstated-next, 这个库只有主体代码十几行,利用了React本身的机制来实现状态管理.
先来看看使用示例
1 | import React, { useState } from "react" |
看看它的源码:
1 | export function createContainer(useHook) { |
到这里,你会说,我靠,就这样? 这个库感觉啥事情都没干啊?
需要注意的是, Context不是万金油,它作为状态管理有一个比较致命的缺陷,我在浅谈React性能优化的方向文章中也提到了这一点:
它是可以穿透React.memo或者shouldComponentUpdate的比对的,也就是说,一旦 Context 的 Value 变动,所有依赖该 Context 的组件会全部 forceUpdate
所以如果你打算使用Context作为状态管理,一定要注意规避这一点. 它可能会导致组件频繁重新渲染.
其他状态管理方案:
4-3 useI18n 国际化
I18n是另一个Context的典型使用场景。react-intl和react-i18next都与时俱进,推出了自己的Hook API, 基本上原本使用高阶组件(HOC)实现的功能都可以用Hooks代替,让代码变得更加简洁:
1 | import React from 'react'; |
4-4 useRouter 简化路由状态的访问
React Hooks 推出已经接近一年,ReactRouter竟然还没有正式推出Hook API。不过它们也提上了计划 —— The Future of React Router and @reach/router,5.X版本会推出Hook API. 我们暂时先看看一些代码示例:
1 | function SomeComponent() { |
再等等吧!
4-5 react-hook-form Hooks和表单能擦出什么火花?
react-hook-form是Hooks+Form的典型案例,比较符合我理想中的表单管理方式:
1 | import React from 'react'; |
5. 副作用封装
我们可以利用Hooks来封装或监听组件外部的副作用,将它们转换为组件的状态。
5-1 useTimeout 超时修改状态
useTimeout由用户触发,在指定时间后恢复状态. 比如可以用于’短期禁用’按钮, 避免重复点击:
1 | function Demo() { |
实现:
1 | function useTimeout(ms: string) { |
5-2 useOnlineStatus 监听在线状态
副作用封装一个比较典型的案例就是监听主机的在线状态:
1 | function getOnlineStatus() { |
还有很多案例, 这里就不一一列举,读者可以自己尝试去实现,比如:
- useDeviceOrientation 监听设备方向
- useGeolocation 监听GPS坐标变化
- useScrollPosition 监听滚动位置
- useMotion 监听设备运动
- useMediaDevice 监听媒体设备
- useDarkMode 夜间模式监听
- useKeyBindings 监听快捷键
- ….
6. 副作用衍生
和副作用封装相反,副作用衍生是指当组件状态变化时,衍生出其他副作用. 两者的方向是相反的.
副作用衍生主要会用到useEffect,使用useEffect来响应状态的变化.
6-1 useTitle 设置文档title
useTitle是最简单的,当给定的值变化时,更新document.title
1 | function useTitle(t: string) { |
6-2 useDebounce
再来个复杂一点的,useDebounce:当某些状态变化时,它会延迟执行某些操作:
1 | function useDebounce(fn: () => void, args?: any[], ms: number = 100, skipMount?: boolean) { |
6-3 useThrottle
同理可以实现useThrottle, 下面的例子来源于react-use:
1 | const useThrottleFn = <T>(fn: (...args: any[]) => T, ms: number = 200, args: any[]) => { |
7. 简化业务逻辑
80%的程序员80%的时间在写业务代码. 有了Hooks,React开发者如获至宝. 组件的代码可以变得很精简,且这些Hooks可以方便地在组件之间复用:
7-1 usePromise 封装异步请求
第一个例子,试试封装一下promise,简化简单页面异步请求的流程. 先来看看usePromise的使用示例,我理想中的usePromise应该长这样:
1 | function Demo() { |
usePromise是我用得比较多的一个Hooks,所以我把它完整的代码,包括Typescript注解都贴出来,供大家参考参考:
1 | // 定义usePromise的返回值 |
7-2 usePromiseEffect 自动进行异步请求
很多时候,我们是在组件一挂载或者某些状态变化时自动进行一步请求的,我们在usePromise的基础上,结合useEffect来实现自动调用:
1 | // 为了缩短篇幅,这里就不考虑跟usePromise一样的函数重载了 |
看到这里,应该惊叹Hooks的抽象能力了吧!😸
7-3 useInfiniteList 实现无限加载列表
这里例子在之前的文章中也提及过
1 | export default function useInfiniteList<T>( |
使用示例:
1 | interface Item { |
7-4 usePoll 用hook实现轮询
下面使用Hooks实现一个定时轮询器
1 | export interface UsePollOptions<T> { |
使用示例:
1 | function Demo() { |
7-5 业务逻辑抽离
通过上面的案例可以看到, Hooks非常适合用于抽离重复的业务逻辑。
在React组件设计实践总结02 - 组件的组织介绍了容器组件和展示组件分离,Hooks时代,我们可以自然地将逻辑都放置到Hooks中,实现逻辑和视图的分离。
抽离的后业务逻辑可以复用于不同的’展示平台’, 例如 web 版和 native 版:
1 | Login/ |
8. 开脑洞
8-1 useScript: Hooks + Suspend = ❤️
这个案例来源于the-platform, 使用script标签来加载外部脚本:
1 | // 注意: 这还是实验性特性 |
使用示例:
1 | import { useScript } from 'the-platform'; |
同理还可以实现
- useStylesheet 用于加载样式表
- fetch-suspense
8-2 useModal 模态框数据流管理
我在React组件设计实践总结04 - 组件的思维也举到一个使用Hooks + Context来巧妙实现模态框管理的例子。
先来看看如何使用Context来渲染模态框, 很简单, ModalContext.Provider给下级组件暴露一个render方法,通过这个方法来传递需要渲染的模态框组件和props:
1 | // 模态框组件要实现的接口 |
再看看Hooks的实现, 也很简单,就是使用useContext来访问ModalContext, 并调用render方法:
1 | export function useModal<P extends BaseModalProps>( |
应用示例:
1 | const MyModal: FC<BaseModalProps & { a: number }> = props => { |
可运行的完整示例可以看这里
React Hooks 技术地图
全家桶和Hooks的结合:
一些有趣的Hooks集合:
Awesome
FAQ
- 官方Hooks FAQ 可以解答大部分的疑问