揭秘 React 异步获取数据的进化历程

586次阅读  |  发布于3年以前

本篇文章,以模拟从『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 不依赖于 propsstate 中的任何值,所以它永远都不需要重复执行。

这时,已经能够正常获取数据了,但是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。你可能会认为发生了下面的这些事:

  1. 渲染关键词为react的 UI。
  2. 运行关键词为react的 effect。 - 从这之后开始搜索关键词vue
  3. 清除关键词为react的 effect。
  4. 渲染关键词为vue的 UI。
  5. 运行关键词为vue的 effect。

你可能会认为清除过程“看到”的是旧的 props 因为它是在重新渲染之前运行的,新的 effect“看到”的是新的 props 因为它是在重新渲染之后运行的。其实这个认知时错误的,React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数 effects 并不会阻塞屏幕的更新。Effect 的清除同样被延迟了。上一次的 effect 会在重新渲染后被清除,真正的执行顺序如下:

  1. 渲染关键词为react的 UI。
  2. 运行关键词为react的 effect。 - 从这之后开始搜索关键词vue
  3. 渲染关键词为vue的 UI。
  4. 清除关键词为react的 effect。
  5. 运行关键词为vue的 effect。

你可能会好奇:如果清除上一次的 effect 发生在search变成vue之后,那它为什么还能“看到”旧的react?这是因为组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获定义它们的那次渲染中的propsstate 这正是为什么 React 能做到在绘制后立即处理 effects — 并且默认情况下使你的应用运行更流畅。但这么处理有利有弊,就导致了异步情况引发的“竞态”问题。

useEffect并没有解决这个问题,而是通过提示警告你不要使用异步函数。当然,可以对useEffect做如下改造:

如上图所示,将异步请求函数封装在useEffect中,同时使用didCancel这个布尔值来让我们的数据获取逻辑知道组件的状态(安装/卸载)。如果组件卸载/更新了,则应设置该标志为true,以防止组件卸载/更新后异步请求结果返回设置组件状态导致出现竞态的问题。以刚才那个例子为例,先请求关键词为react的文章,在请求结果还没返回的时候,关键词变更为vue,组件即将重新渲染,组件执行清除函数,将didCancel置为true,此时,react请求的结果返回,并不会执行setData,从而避免竞态的问题,可点击查看**示例代码**[4]。

接下来,我们增加关键词搜索功能,添加input供用户任意输入,添加button确认用户要检索的关键词,并设置querysearch两个状态值,同时对请求 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 中,我们使用dataisLoadingisError三个状态值来标记页面的展示状态(通过三元组判断逻辑),在数据请求加载中、成功、失败等不同状态下都会进行一系列的setState,降低可读性的同时,useEffect重新执行时机变得更加难以预料。这时,可以使用useReducer进行解耦,组件层面只需要发出action,无需知道如何更新状态(reducer负责更新状态)。另一方面,useEffect再也不需要依赖状态值,而是依赖dispatch,能够避免useEffectuseMemouseCallback需要频繁重执行的问题,同时也提高了性能

如下图所示,使用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,通过前面章节的描述,它应该包含有dataisLoadingerror三个状态值,类型定义为AsyncState,由于data这个状态值的类型依赖异步函数Fn返回的结果,所以可用泛型A表示,AsyncState类型存在如下图所示四种状态,第一种状态是初始化,isLoading的值未知;第二种状态时加载中;第三种状态是返回异常结果,此时isLoading值为falseerror也有对应的赋值;第四种状态是返回成功结果,此时isLoading值为falsedata也有对应的赋值;由于泛型A需要有明确的定义,所以将AsyncState二次封装成StateFromFunctionReturningPromise,具体定义可参照下图:

入参和出参讨论完毕,接下来我们讲述useAsyncFn函数的内部实现,如下图所示: 定义一个state(useState hook)来管理状态,定义callback(useCallback hook)作为出参供外部调用,该callBack是否执行,依赖数组depscallback函数就是对状态管理的二次封装,第 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 将数据获取和组件渲染进行解耦,但仍然存在一些不可避免的问题:

1 . 丑陋的三元表达式:如下图所示,加载和错误状态是通过渲染中的三元组定义的,从而使代码不必要地复杂化。我们不是描述了一个渲染函数,我们描述了三个。

2 . Fetch-on-render导致的瀑布问题:先开始渲染组件,每个完成渲染的组件都可能在它们的 effects 或者生命周期函数中获取数据,拿到数据后对状态进行赋值,触发更新,就经常导致“瀑布”问题。

这时,React 16.6 新增的 Suspense 组件派上用场了,让你可以“等待”任何内容(图像、脚本或者其他异步的操作)加载,并且可以直接指定一个加载的界面。

通常的想法是,Suspense允许组件“悬停”它们的渲染。例如,如果需要从外部来源加载额外数据,一旦所有依赖的资源(数据或资源文件)都存在了,React 将重新尝试渲染组件。

为了实现上面描述的功能,React 使用Promises。组件可以在其render方法中抛出Promise,React 捕获抛出的Promise并在组件树上查找最接近的Suspense组件,它充当一种边界。Suspense组件接受一个组件作为fallback属性,当其子树中的任何子项被挂起时,都会呈现该元素。

React 还会跟踪抛出的Promise。一旦Promiseresolve了,就会再次渲染组件。这假定由于Promiseresolve,被"悬停"的组件现在已经获取了能够正确渲染所需的所有信息。为此,我们使用某种形式的缓存来存储数据,在每次渲染时,我们通过这个缓存来确定数据是否已经可用(然后它只是从变量中读取它), 在这种情况下它会触发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。一旦Promiseresolve了,就会渲染组件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中的hasErrortrue时,渲染备用 UI,否则渲染children组件。

你可以在此沙箱[11]中查看示例代码。

5 . 最后

从长远来看,Suspense是为数据请求而生的,会允许第三方库通过一些方式告诉 React 暂停渲染直到某些异步事物(任何东西:代码,数据,图片)准备就绪。根据 React 18 发布预告,将包括对 React 服务器端渲染 (SSR) 性能的架构改进,这里的优化思路就用到Suspense。当Suspense逐渐地覆盖到更多的数据请求使用场景,我觉得useEffect 会退居幕后作为一个强大的工具,用于同步propsstate到某些副作用。不过在那之前,自定义的 Hooks 比如这儿提到的是复用数据请求逻辑很好的方式。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8