在前面一篇文中,我们知道,如果直接使用 use 获取未直接 resolve 的 Promise 中的值,会抛出一个异常。
1const _api3 = () => {2return new Promise(resolve => {3resolve({ value: '_api3' })4})5}67// bad: get an error8const result = use(_api3())
在实践中,大多数情况都是这种并没有直接得到 Promise resolve 的结果状态,那我们应该怎么办呢?这个时候我们可以利用 Suspense 来解决这个问题。
Suspense 可以捕获 use 无法读取到数据时抛出的异常,然后此时会在页面上渲染回退组件 fallback
1<Suspense fallback={<Skeleton />}>2<Message promise={promise} />3</Suspense>
接下来看一个简单的演示案例。在这个例子中,为了让 Suspense 捕获更小范围的组件,我们单独定义了一个子组件 Message
来使用 use 获取 promise 中的数据。
10import { Suspense } from 'react'20import Message from './message'30import Skeleton from 'components/ui/skeleton'40import { getMessage } from './api'5060export default function Page() {70const promise = getMessage()80return (90<Suspense fallback={<Skeleton />}>10<Message promise={promise} />11</Suspense>12)13}14
在开发中更常见的场景是使用 use 读取异步 promise,主要就是接口请求。
1<Suspense fallback={<Loading />}>2<Albums />3</Suspense>
在 React 19 中,use(promise)
被设计成完全符合 Suspense 规范的 hook,因此我们可以轻松的结合他们两者来完成页面开发。当 use(promise)
读取数据失败时,会抛出一个异常交给 Suspense 捕获,此时 Suspense 会渲染 fallback
回退组件。当请求成功之后,组件会重新渲染,此时 use(promise)
则可以读取到正确的值。
我们来梳理一下代码流程。
首先,我们定义好一个用于接口请求的函数,该函数执行返回 promise
10import { createRandomMessage } from '@/utils';2030var requestOptions: RequestInit = {40method: 'GET',50redirect: 'follow'60};7080const url = 'https://randomuser.me/api/?results=2&inc=name,gender,email,nat,picture&noinfo'9010export async function getMessage() {11await fetch(url, requestOptions)12return { value: createRandomMessage()}13}
然后我们定义一个子组件 Message,该子组件接受一个 promise 作为参数。然后在子组件内部,我们使用 use 读取该 promise 中的值。
10import { Tent } from 'lucide-react'20import { use } from 'react'30import { getMessage } from './api'4050const Message = (props: { promise: ReturnType<typeof getMessage> }) => {60const message = use(props.promise);70return (80<div className='flex border border-gray-200 p-4 rounded items-start'>90<Tent />10<div className='flex-1 ml-3'>11<div>React introduction</div>12<div className='text-sm leading-6 mt-2 text-gray-600'>13{message.value}14</div>15</div>16</div>17)18}1920export default Message
有了这个子组件之后,我们使用 Suspense 包裹捕获该组件的错误,防止错误溢出到更高层级的组件。
10import { Suspense } from 'react'20import Skeleton from 'components/ui/skeleton'30import Message from './message'40import { getMessage } from './api'5060export default function Page() {70const promise = getMessage()80return (90<Suspense fallback={<Skeleton />}>10<Message promise={promise} />11</Suspense>12)13}14
完整代码及演示
10import { Suspense } from 'react'20import Skeleton from 'components/ui/skeleton'30import Message from './message'40import { getMessage } from './api'5060export default function Page() {70const promise = getMessage()80return (90<Suspense fallback={<Skeleton />}>10<Message promise={promise} />11</Suspense>12)13}14
当 Message 组件首次渲染时,由于直接读取 promise 导致报错,Suspense 捕获到该异常后,会渲染 fallback
中设置的组件。此时我们设置了一个骨架屏 Skeleton 组件,大家可以多次点击 reload 按钮查看演示效果。
因此,这个案例的视觉表现应该为:首先渲染 Skeleton 组件。然后请求成功之后,渲染 Message 组件。
Suspense 提供了一个加载数据的标准。在源码中,Suspense 的子组件被称为 primary
。
当 react 在 beginWork 的过程中(diff 过程),遇到 Suspense
时,首先会尝试加载 primary
组件。如果 primary
组件只是一个普通组件,那么就顺利渲染完成。
如果 primary
组件是一个包含了 use 读取异步 promise 的组件,它会在首次渲染时,抛出一个异常。react 捕获到该异常之后,发现是一个我们在语法中约定好的 promise,那么就会将其 then
的回调函数保存下来,并将下一个 next
beginWork 的组件重新指定为 Suspense
。
此时 promise 在请求阶段,因此再次 beginWork Suspense 组件时,会跳过 primary
的执行而直接渲染 fallback
当 primary
中的 promise 执行完成时「resolve」,会执行刚才保存的 then
方法,此时会触发 Suspense
再次执行「调度一个更新任务」。由于此时 primary
中的 promise 已经 resolve,因此此时就可以拿到数据直接渲染 primary
组件。
整个流程可以简单表示为:
1Suspense ->2primary ->3Suspense ->4fallback ->5waiting -> resolve() ->6Suspense ->7primary ->
当 primary
为普通组件时,会直接渲染普通组件,如下案例所示。
10import React, { Suspense } from 'react';20import Skeleton from 'components/ui/skeleton'30import { createRandomMessage } from '@/utils'40import Message from './message'5060export default function Demo03() {70return (80<Suspense fallback={<Skeleton />}>90<Message message={createRandomMessage()} />10</Suspense>11)12}13
在前面我们 结合 use 与 Suspense 实现了一个初始化加载的案例。该案例的视觉表现是在初始化时,首先显示 Skeleton 组件,请求成功之后,显示 Message 组件。
刷新页面时重新请求数据渲染,请求过程中显示骨架屏组件 Skeleton
核心代码与演示效果如下,点击刷新按钮重复观察执行效果
10import { Suspense } from 'react'20import Skeleton from 'components/ui/skeleton'30import Message from './message'40import { getMessage } from './api'5060export default function Page() {70const promise = getMessage()80return (90<Suspense fallback={<Skeleton />}>10<Message promise={promise} />11</Suspense>12)13}14
这里我们需要关注的是,对比以前必须要借助 state
useEffect
的实现方式,体会一下差别
10import React, { useEffect, useState } from 'react'20import Skeleton from 'components/ui/skeleton'30import Message from './message'40import { getMessage } from './api'5060export default function Demo04() {70const [content, update] = useState({ value: '' })80const [loading, setLoading] = useState(true)9010useEffect(() => {11getMessage().then(res => {12update(res)13setLoading(false)14})15}, []);1617if (loading) {18return <Skeleton />19}2021return <Message message={content.value} />22}23
可以很明显的看出,新的方式使用 use + Suspense ,代码更加简洁。
除此之外,在严格模式下,开发环境组件首次加载会执行两次,因此我们还需要想额外的办法防止重复执行,代码会变得更加冗余。一个很明显的差别就是 Suspense + use
的方式会自动帮助我们弃用第二次的请求数据。而使用 useEffect
则需要我们自己来处理防止重复请求的逻辑。
与老版本使用 state
+ useEffect
完成首页初始化的需求相比,新的开发方式更加的简洁,代码舒适度更高。
不过,在以前的开发方式中,我们可以通过自定义 hook 的方式,把状态与 useEffect
封装成自定义 hook.
10function useFetch() {20const [content, update] = useState({value: ''})30const [loading, setLoading] = useState(true)4050useEffect(() => {60api().then(res => {70setLoading(false)80update(res)90})10}, [])1112return {content, loading}13}
最终在应用组件中也可以写出非常类似的非常简洁的代码。
10function Index() {20const {content, loading} = useFetch()3040if (loading) {50return <Skeleton />60}7080return (90<Message message={content.value} />10)11}
这是我们之前版本的最佳实践。注意体会他们之间的区别。相似,但却不同。我们后续会列举更多案例,尽可能用新的开发思路去复现开发过程中会出现的场景。除此之外,Suspense 的实现方案,还能够更好的与并发 API 结合使用,这是老版本实现方案并不具备的优势,在后续的章节中我们会进一步学习。