揭秘 React 异步获取数据的进化历程
点击上方 前端瓶子君,关注公众号
回复算法,加入前端编程面试算法每日一题群
本篇文章,以模拟从『Hacker News API[1]』获取热门文章为例,通过一步步地代码优化和封装,阐述 React
异步获取数据的进化历程(useEffect
-> useReducer
-> custom hook
-> Suspense
)。
1. 使用 useEffect Hook 异步获取数据
1.1 异步获取数据
虽然 React 16.6 新增了 Suspense
组件用于异步获取数据,但我们相信,截止到 react 18 新特性发布之前,异步获取数据最常见的方法仍是使用 useEffect
,我们称这种方法为“fetch-on-render”(渲染之后获取数据),因为数据的获取是发生在组件被渲染到屏幕之后。
本小节内容将以实现关键词搜索热门文章的需求为例,阐述通过 React Hook 异步获取数据的实践过程。
首先,我们使用useState
创建data
来管理数据的本地状态,当组件第一次渲染时,从useState()
拿到data
的初始值[],当我们调用setData(val)
,React 会再次渲染组件,这一次data
的值为val
。
我们在useEffect
这个钩子中使用`axios`[2]来异步请求数据,并将返回的结果存储到data
中。但是,当您运行这个程序时,您会发现,useEffect
在组件挂载时触发,在组件更新时也会频繁触发,导致一次又一次的请求相同的数据。我们只想在组件挂载时请求一次数据,因此我们给useEffect
的传递一个空数组([])作为第二个参数。这就告诉 React 你的 effect 不依赖于 props
或 state
中的任何值,所以它永远都不需要重复执行。
这时,已经能够正常获取数据了,但是useEffect
会报以下错误:Effect callbacks are synchronous to prevent race conditions
。问题的原因是请求结果返回的顺序不能保证一致,比如我将App
这个组件做为子组件,当接收关键词不同时,会重新渲染组件,那么先请求关键词为react
的文章,然后请求关键词为vue
的文章,但关键词为vue
的请求更先返回。请求更早但返回更晚的情况会错误地覆盖状态值data
(此时关键词是vue
,但显示的是后返回react
的结果),这就叫做竞态(race conditions
),具体可以点此查看**示例代码**[3]。
如果你对“竞态”问题还很迷惑,那一定是因为你对useEffect
了解得不够透彻。请先记住这句话,effects 会在每次渲染后运行,并且概念上它是组件输出的一部分,可以“看到”属于某次特定渲染的 props 和 state。 仍然以刚才那个例子为例,假设第一次渲染的时候search
(存储关键词的state
)是react
,第二次渲染的时候是search
(存储关键词的state
)是vue
。你可能会认为发生了下面的这些事:
渲染关键词为 react
的 UI。运行关键词为 react
的 effect。 - 从这之后开始搜索关键词vue
清除关键词为 react
的 effect。渲染关键词为 vue
的 UI。运行关键词为 vue
的 effect。
你可能会认为清除过程“看到”的是旧的 props 因为它是在重新渲染之前运行的,新的 effect“看到”的是新的 props 因为它是在重新渲染之后运行的。其实这个认知时错误的,React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数 effects 并不会阻塞屏幕的更新。Effect 的清除同样被延迟了。上一次的 effect 会在重新渲染后被清除,真正的执行顺序如下:
渲染关键词为 react
的 UI。运行关键词为 react
的 effect。 - 从这之后开始搜索关键词vue
渲染关键词为 vue
的 UI。清除关键词为 react
的 effect。运行关键词为 vue
的 effect。
你可能会好奇:如果清除上一次的 effect 发生在search
变成vue
之后,那它为什么还能“看到”旧的react
?这是因为组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获定义它们的那次渲染中的props
和state
。 这正是为什么 React 能做到在绘制后立即处理 effects — 并且默认情况下使你的应用运行更流畅。但这么处理有利有弊,就导致了异步情况引发的“竞态”问题。
useEffect
并没有解决这个问题,而是通过提示警告你不要使用异步函数。当然,可以对useEffect
做如下改造:
如上图所示,将异步请求函数封装在useEffect
中,同时使用didCancel
这个布尔值来让我们的数据获取逻辑知道组件的状态(安装/卸载)。如果组件卸载/更新了,则应设置该标志为true
,以防止组件卸载/更新后异步请求结果返回设置组件状态导致出现竞态的问题。以刚才那个例子为例,先请求关键词为react
的文章,在请求结果还没返回的时候,关键词变更为vue
,组件即将重新渲染,组件执行清除函数,将didCancel
置为true
,此时,react
请求的结果返回,并不会执行setData
,从而避免竞态的问题,可点击查看**示例代码**[4]。
接下来,我们增加关键词搜索功能,添加input
供用户任意输入,添加button
确认用户要检索的关键词,并设置query
和search
两个状态值,同时对请求 Url 拼接上查询参数。由于请求依赖search
这个状态值,所以在useEffect
的依赖数组中,诚实地添加上search
(不添加只会在组件挂载时执行一次),这样,如果search
发生改变,就会重新请求数据。
至此,我们已经实现了关键词搜索热门文章的功能,可在此沙箱[5]中查看代码示例。
1.2 加载提示
从使用 UseEffect 异步获取数据[6]示例中能明显感受到,当用户搜索新的关键词直至响应时,查询结果的展示有明显的“滞后”效果,为了提升用户体验,一般都会加个加载中的状态提示。如下图代码所示,添加isLoading
状态值,请求发起时将isLoading
设为true
,接收到请求结果后将isLoading
设置为false
。UI 展示通过二元组进行判断,当isLoading
设为true
时,展示加载中动画,相反,则展示请求结果。
1.3 错误提示
网络环境错综复杂,当一个请求发起时,可能会遇到丢包、超时、服务器宕机、请求出错等各种情况,对于一个高鲁棒性的组件而言,添加错误处理机制是必不可少的。如下图代码所示,添加isError
状态值,请求发起时将isError
设为false
,请求结果出错被catch
捕获则将isError
设置为true
。UI 展示通过三元组进行判断,当isError
设为true
时,展示错误提示,否则如果当isLoading
设为true
时,展示加载中动画,相反,则展示请求结果。
至此,我们已经实现了异步获取数据的各种状态处理方案,你可在此沙箱[7]查看示例代码。发送请求时到接收请求结果期间,会有一个加载动画。当输入script
等不安全的关键词,会报错。
2. 使用 useReducer Hook 异步获取数据
在上面的 Demo 中,我们使用data
、isLoading
、isError
三个状态值来标记页面的展示状态(通过三元组判断逻辑),在数据请求加载中、成功、失败等不同状态下都会进行一系列的setState
,降低可读性的同时,useEffect
重新执行时机变得更加难以预料。这时,可以使用useReducer
进行解耦,组件层面只需要发出action
,无需知道如何更新状态(reducer
负责更新状态)。另一方面,useEffect
再也不需要依赖状态值,而是依赖dispatch
,能够避免useEffect
、useMemo
、useCallback
需要频繁重执行的问题,同时也提高了性能。
如下图所示,使用useReducer
返回一个状态对象state
和一个改变状态对象的函数dispatch
,通过dispatch
发出action
去改变状态值,组件层面是无需关心状态值如何改变,这一切都封装在dataFetchReducer
函数中。第 11 行dispatch({ type: "LOADING" })
表示将状态对象中的isLoading
设置为加载中,第 17 行dispatch({ type: "SUCCESS", payload: result.data.hits })
表示将状态对象中的data
设置为请求获取得到的文章列表数据,第 20 行dispatch({ type: "FAIL" })
表示将状态对象中的isError
设置为状态错误。
接下来,我们关注dataFetchReducer
函数的实现,type
不同时,进行对应的操作。具体可以点击此沙箱[8]查看代码示例。
3. 使用自定义 Hook 异步获取数据
3.1 封装 useAsyncFn
Hook
至此,我们已经使用useReducer
进行解耦,将状态管理从组件中分离出来,组件层面只需要通过dispatch
发出action
去改变状态值,从某种程度上而言,已经具有一定的可复用性。但是,异步获取数据是前后端通信的“桥梁”,几乎所有的业务模块都会使用,尽管每个业务模块都可以复用同一套dataFetchReducer
方法,但仍然需要在数据的获取过程中通过dispatch
发出不同的action
去改变状态值。其实对于业务开发人员而言,仅需要关心向哪个 URL 发起请求,已经请求得到的结果是什么,并不需要关心状态值是如何变化的。
因此,我们可以封装一个自定义 hook 来实现这个功能,这里将这个 hook 命名为useAsyncFn
。思考一下,useAsyncFn
这个 hook 的入参和出参该如何设计?首先明确该 hook 的目的是实现异步获取数据过程中的状态管理,也就是对dispatch
发出action
去改变状态值这些操作进行二次封装。那么入参必然有异步函数Fn
,异步函数Fn
所依赖的数组deps
,以及初始状态值initialState
。出参则是状态对象state
,以及对Fn
进行状态处理后的回调函数callback
。
useAsyncFn
函数的入参和出参如上所示,接下来讲述每个参数的类型定义,首先入参异步函数Fn
是返回Promise
对象的函数,可定义为type FunctionReturningPromise = (...args: any[]) = Promiseany;
,由于出参callback
的类型依赖异步函数Fn
,所以可以将其使用泛型T
代替。异步函数Fn
所依赖的数组deps
类型同 hook 中的依赖数组一样,使用react
暴露的DependencyList
定义。接下来是初始状态值initialState
,通过前面章节的描述,它应该包含有data
、isLoading
、error
三个状态值,类型定义为AsyncState
,由于data
这个状态值的类型依赖异步函数Fn
返回的结果,所以可用泛型A
表示,AsyncState
类型存在如下图所示四种状态,第一种状态是初始化,isLoading
的值未知;第二种状态时加载中;第三种状态是返回异常结果,此时isLoading
值为false
,error
也有对应的赋值;第四种状态是返回成功结果,此时isLoading
值为false
,data
也有对应的赋值;由于泛型A
需要有明确的定义,所以将AsyncState
二次封装成StateFromFunctionReturningPromise
,具体定义可参照下图:
入参和出参讨论完毕,接下来我们讲述useAsyncFn
函数的内部实现,如下图所示: 定义一个state
(useState
hook)来管理状态,定义callback
(useCallback
hook)作为出参供外部调用,该callBack
是否执行,依赖数组deps
。callback
函数就是对状态管理的二次封装,第 10 行执行setState
将状态设置为加载中,第 11 行执行异步函数Fn
,并等待返回结果。如果返回成功结果,那么跳转到第 13 行执行setState
将取消加载中状态并将返回结果赋值给data
。如果返回异常结果,那么跳转到第 17 行执行setState
将取消加载中状态并将抛出的异常赋值给error
。
至此,你是否感觉到好像缺了点什么?没错,就是前面章节提到的竞态问题,解决方法就是设置一个布尔值判断组件是否挂载,等组件卸载/更新后才返回的结果无效,不更改状态。该方法其实也可以封装成一个自定义 hook,这里将其命名为useMountedState
。如下图所示,使用useRef
定义mountedRef
实时追踪组件的状态,在useEffect
执行时,将值设置为true
,表示当前组件已经挂载。在组件卸载/更新时,将值设置为false
。同时在第三行使用useCallback
定义一个回调函数可以实时获得mountedRef
的值,并将该回调函数返回供外部使用。
接下来讲一下useMountedState
如何解决组件(挂载/卸载)引起的异步获取数据的竞态问题。如下图所示,使用useMountedState
定义一个“看不见”的状态值实时追踪组件的状态,并将回调函数返回给变量isMounted
,执行isMonted
函数可以判断当前组件是否还处于挂载中,只有组件仍然处于挂载中,才会对error
或者data
赋值(分别对应 14、19 行代码)。
处理完上面的竞态问题,你是否觉得万事无忧了。那么我想请你思考一下,当你反复调用对外暴露的callback
函数时,会发生什么?没错,还是竞态问题。
如何解决这种竞态问题呢?这就需要对业务形态有一个正确的认知,当反复调用对外暴露的callback
函数时,其实用户想要获得的只是最新的一次结果。基于这个认知的前提,你可以使用useRef
定义一个变量lastCallId
实时追踪当前调用函数的id
,每调用一次,就累加一。只有最新一次调用函数才能对error
或者data
赋值(分别对应 16、21 行代码)。
3.2 useAsyncFn
Hook 应用
自此,我们已经实现useAsyncFn
这个自定义 hook,接下来我们用它来改造我们之前的 Demo,如下图所示,这里不做赘述。
4 用于数据获取的 Suspense
4.1 何为 Suspense
上一小节,我们已经通过封装自定义 Hook 将数据获取和组件渲染进行解耦,但仍然存在一些不可避免的问题:
丑陋的三元表达式:如下图所示,加载和错误状态是通过渲染中的三元组定义的,从而使代码不必要地复杂化。我们不是描述了一个渲染函数,我们描述了三个。
Fetch-on-render
导致的瀑布问题:先开始渲染组件,每个完成渲染的组件都可能在它们的 effects 或者生命周期函数中获取数据,拿到数据后对状态进行赋值,触发更新,就经常导致“瀑布”问题。
这时,React 16.6 新增的 Suspense
组件派上用场了,让你可以“等待”任何内容(图像、脚本或者其他异步的操作)加载,并且可以直接指定一个加载的界面。
通常的想法是,Suspense
允许组件“悬停”它们的渲染。例如,如果需要从外部来源加载额外数据,一旦所有依赖的资源(数据或资源文件)都存在了,React 将重新尝试渲染组件。
为了实现上面描述的功能,React 使用Promises
。组件可以在其render
方法中抛出Promise
,React 捕获抛出的Promise
并在组件树上查找最接近的Suspense
组件,它充当一种边界。Suspense
组件接受一个组件作为fallback
属性,当其子树中的任何子项被挂起时,都会呈现该元素。
React 还会跟踪抛出的Promise
。一旦Promise
被resolve
了,就会再次渲染组件。这假定由于Promise
被resolve
,被"悬停"的组件现在已经获取了能够正确渲染所需的所有信息。为此,我们使用某种形式的缓存来存储数据,在每次渲染时,我们通过这个缓存来确定数据是否已经可用(然后它只是从变量中读取它), 在这种情况下它会触发fetch
,并抛出Promise
的结果来让 React 捕获。
4.2 Suspense 原理
Suspense
的核心概念与错误边界[9]非常相似,错误边界在 React 16 中引入,允许在应用程序内的任何位置捕获未捕获的异常,然后在组件树中展示跟错误信息相关的组件。以同样的方式,Suspense
组件从其子节点捕获任何抛出的Promises
,不同之处在于对于Suspense
我们不必使自定义组件充当边界,Suspense
组件就是那个边界!
我们知道,无论是什么异常,JavaScript
都能捕获,React 就是利用了这个语言特性,捕获了所有生命周期函数,render
函数,以及事件回调中的任何错误,封装进一个特殊的生命周期里:ComponentDidCatch
。实际上,Suspense
就是依赖ComponentDidCatch
实现的。
如下图所示,是一个简单版本的Suspense
实现,通过ComponentDidCatch
捕获异常,只不过这个异常是个Promise
,当Promise
处于pending
状态时,渲染fallback
组件,当Promise
处于resolved
状态时,渲染children
组件。
4.3 使用 Suspense 改造案例
如下图所示,业务组件无需关心数据的获取情况,将其封装在fetchData
中,第 5 行定义一个resource
存储这个支持Suspense
的特殊对象。第 6-8 行当搜索关键词发生变化时,重新触发fetchData
,并对resource
赋值。第 19-28 行对文章列表组件ArticleList
进行封装,第 21 行已经在尝试加载文章列表信息,但是此时可能请求还未加载完毕。如果未加载完毕就会跑出一个Ptomise
被包裹的Suspense
组件捕获,而渲染fallback
传入的组件。React 还会跟踪抛出的Promise
。一旦Promise
被resolve
了,就会渲染组件ArticleList
组件。
接下来,我们谈谈fetchData
的具体实现,第 1-11 行的fetchArticleData
函数发起请求,并返回一个Promise
对象。第 13-18 行的fetchData
是所有请求的封装(假设业务功能丰富,还有其他请求),第 11 行同步执行fetchArticleData
函数,articlePromise
的值是这个Promise
对象,然后通过wrapPromise
函数进行封装,将其返回给业务层。
wrapPromise
函数的实现如下图所示,接收Promise
对象作为参数,由于我们是同步执行fetchArticleData
函数,所以当前的状态必为pending
,第 4 行设置suspender
存储Promise
对象执行后扭转对应的状态。最后返回一个read
方法,当状态为pending
时,抛出这个suspender
对象给业务层,这个会被Suspense
组件的ComponentDidCatch
生命周期捕获,并渲染渲染fallback
传入的组件。当状态为error
时,抛出异常,Suspense
组件并不处理这个异常,而是继续向外抛出。当状态为success
时,返回结果给业务层进行渲染。
每当使用 Promises
,大概率我们会用 catch()
来做错误处理。但当我们用 Suspense
时,我们不等待 Promises
就直接开始渲染,这时 catch()
就不适用了。这种情况下,错误处理该怎么进行呢?
在 Suspense
中,获取数据时抛出的错误和组件渲染时的报错处理方式一样——你可以在需要的层级渲染一个错误边界[10]组件来“捕捉”层级下面的所有的报错信息。如下图所示,定义ErrorBoundary
这个类,当抛出错误后,使用 static getDerivedStateFromError()
更新state
用户判断渲染备用 UI ,使用 componentDidCatch()
打印错误信息。第 11-20 行,当state
中的hasError
为true
时,渲染备用 UI,否则渲染children
组件。
你可以在此沙箱[11]中查看示例代码。
5. 最后
从长远来看,Suspense
是为数据请求而生的,会允许第三方库通过一些方式告诉 React 暂停渲染直到某些异步事物(任何东西:代码,数据,图片)准备就绪。根据 React 18 发布预告,将包括对 React 服务器端渲染 (SSR) 性能的架构改进,这里的优化思路就用到Suspense
。当Suspense
逐渐地覆盖到更多的数据请求使用场景,我觉得useEffect
会退居幕后作为一个强大的工具,用于同步props
和state
到某些副作用。不过在那之前,自定义的 Hooks 比如这儿提到的是复用数据请求逻辑很好的方式。
关于本文
作者:蔡小真
来源:https://juejin.cn/post/6981728346937753630