- Published on
React Suspense实践及原理
引言
React Suspense的官方文档对它的描述非常简单,实际使用时,代码也非常简单。
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
第一眼看到会以为就是fallback代替 SomeComponent渲染完成之前的内容这么简单,实际上并不是。
这里有一个问题 Suspense 如何知道SomeComponent的渲染状态?
我们通过思考这个问题,就能弄懂 Suspense的原理。
实现原理
React的组件本质上就是js的函数,jsx只是语法糖。对应前面的JSX,实际的函数调用可以简单理解为:
function Suspense({fallback, children}) {
if (children pending) {
return fallback
}
return children
}
所以核心问题就是 Suspense
父函数如何感知children
子函数的状态?
既然是函数调用,js中最简单直接的方式就是子函数 throw
异常,父函数捕获异常。父函数通过 try catch
捕获异常,结合异常判断子函数的状态。
这里引出另一个问题,子函数状态修改时,父函数如何知道状态,做出对应的渲染?
答案是:子函数 throw promise
,父函数catch住该异常后,根据该promise的状态做出对应渲染。
function CustomSuspense({ children, fallback }) {
try {
children()
} catch (error) {
if (typeof error === 'object' && error !== null) {
if (error instanceof Promise) {
fallback()
error
.then(() => {
children()
})
.catch(() => {
console.log('Error Boundary')
})
} else {
fallback()
}
}
}
}
如这段代码,CustomSuspense
catch住后,判断error是promise,这时先渲染fallback
,然后在error.then
回调中渲染children
。
父组件判断子组件是pending,渲染 fallback已经实现。但这里还有一个问题:
父组件判断应该渲染子组件时,即子组件开启第二次渲染,又会抛出新的promise,又被父组件捕获,进入死循环,如何解决?
解决方式也很简单,子组件缓存promise即可,子组件每次渲染都是判断同一个promise的状态。第二次渲染时,promise已经resolve,子组件直接使用promise的result渲染。此时也不会throw
异常,所以也不会被父组件捕获。
// 包装一个 Promise,使其能被 Suspense 识别
function wrapPromise(promise) {
// 1. 状态变量(通过闭包保留)
let status = 'pending'
let result
// 2. 启动 Promise(仅执行一次)
const suspender = promise.then(
(res) => {
status = 'success'
result = res
},
(err) => {
status = 'error'
result = err
}
)
// 3. 返回一个对象,包含 read() 方法(闭包复用)
return {
read() {
if (status === 'pending') {
throw suspender // 首次调用:抛出 Promise
} else if (status === 'error') {
throw result // 错误状态:抛出错误
} else {
return result // 成功状态:返回数据
}
},
}
}
// 示例:模拟数据请求
const fetchUser = () => {
return wrapPromise(
new Promise((resolve) => {
setTimeout(() => resolve('Alice'), 1000)
})
)
}
// 关键点:fetchUser() 只执行一次,返回的对象会被复用
const userResource = fetchUser()
// 在组件中使用
//function ProfileDetails() {
// const user = userResource.read(); // 多次调用 read(),复用同一对象
// return <h1>{user.name}</h1>;
//}
const TestChildren = () => {
let res = 'init children'
res = userResource.read()
console.log(res)
return res
}
function CustomSuspense({ children, fallback }) {
try {
children()
} catch (error) {
if (typeof error === 'object' && error !== null) {
console.log('first error:', error)
if (error instanceof Promise) {
console.log('enter promise')
fallback()
error
.then(() => {
children()
})
.catch(() => {
console.log('Error Boundary')
})
} else {
fallback()
}
}
}
}
CustomSuspense({
children: TestChildren,
fallback: () => {
console.log('fallback')
},
})
如上面的代码,使用一个闭包缓存promise,每次子组件渲染都是读取同一个promise的结果。
最佳实践
结合上述的原理,我们知道使用的Suspense的场景是:子组件内部有异步任务,并且能throw出promise给Suspense组件。
但一般并不需要我们在开发时手动处理 throw promise这个环节。
一般通过使用支持 Suspense 的 API(如 use、lazy 或特定库如swr,react-query)触发异步任务,这些 API 内部会抛出 Promise 给 Suspense 组件捕获。
客户端
场景 1:数据加载(使用 use)
import { use } from 'react' // 实验性 API
let cache = new Map()
export function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url))
}
return cache.get(url)
}
async function getData(url) {
if (url) {
return await fetch(url)
} else {
throw Error('Not implemented')
}
}
function DataComponent() {
const data = use(fetchData('/api/xxx')) // 内部会 throw Promise 给 Suspense
return <div>{data}</div>
}
function App() {
return (
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
)
}
场景 2:代码拆分(React.lazy)
const LazyComponent = lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Loading />}>
<LazyComponent /> {/* 内部会 throw Promise */}
</Suspense>
)
}
场景 3:支持 Suspense 的库
import { useSuspenseQuery } from '@tanstack/react-query'
function User() {
const { data } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser })
return <div>{data.name}</div> // 库内部处理 Promise 抛出
}
// 包裹 Suspense 边界
export default function UserWithSuspense() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<User />
</Suspense>
)
}
上述场景一中我们必须要缓存子组件的promise实例,或者说,要保持多次渲染子组件时,promise是同一个,即Suspense监听的promise是用一个。
如果不是同一个promise,比如每次都是新的会发生什么?
会发生死循环,因为子组件抛出不同的promise,每次都会触发Suspense组件以为是子组件pending中,展示fallback的逻辑。
服务端
当使用服务端渲染时,可能的代码如下。
// app/page.jsx
import { Suspense } from 'react'
async function fetchData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
async function Content() {
const data = await fetchData() // Next.js 自动感知此 await
return <div>{data.value}</div>
}
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Content /> {/* 框架自动处理流式渲染 */}
</Suspense>
)
}
观察代码,这里和客户端的使用有一些不同,主要体现在子组件内部无需使用特殊的能抛出promise的react api或其他的库。直接在组件中使用await
即可。
这是因为Next.js 在服务端渲染时,直接控制了React的渲染流程,能通过await的阻塞行为隐式感知异步状态,无需走React的客户端Suspense协议(即抛出 Promise)。
也就是这里并不是子组件throw promise给Suspense组件,而是Nextjs能知道子组件内部有异步逻辑。
当子组件包含 await 时,框架会通过 编译时转换 和 运行时协作机制 自动感知异步状态,无需开发者手动抛出 Promise。以下是具体实现原理:
- 编译时的静态分析 Next.js 在构建阶段会对服务端组件(如
app/**/*.jsx
)进行代码分析:
标记异步组件: 如果组件是 async 函数或包含 await,Next.js 会将其识别为 潜在的可挂起组件。
注入元数据: 在生成的代码中插入标记,告知运行时该组件可能需要等待异步任务。 示例转换
// 开发者编写的代码 async function UserProfile() { const data = await fetchData() return <div>{data}</div> } // Next.js 编译后的代码(简化) function UserProfile() { next_async_component() // 标记为异步组件 return next_await(fetchData()).then((data) => <div>{data}</div>) }
- 运行时的协作机制 当渲染引擎执行到异步组件时,Next.js 会通过以下步骤处理:
(1) 异步任务注册 Next.js 的 React 渲染器会检测 __next_await
包裹的异步操作,将其注册为 待处理的 Promise。 这些 Promise 被收集到全局的 任务队列 中。
(2) 挂起状态触发 如果当前渲染周期内存在未完成的 Promise,Next.js 会:
- 暂停组件树的渲染,并向上查找最近的
<Suspense>
边界。 - 将 Suspense 的子组件标记为 挂起状态(类似客户端的行为,但无需抛出 Promise)。
(3) 流式 HTML 生成
- 首次渲染:立即发送 fallback 的 HTML 占位符。
- 异步完成:当 await 的 Promise 解决后,重新渲染组件并补发实际内容,通过分块传输(chunked encoding)发送到客户端。
- 关键源码逻辑(简化版) Next.js 的底层实现主要依赖以下模块:
(1) 异步组件标记
function next_async_component() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// 标记当前组件为异步,关联到渲染上下文
currentRenderContext.asyncComponents.add(this)
}
}
(2) 异步操作拦截
function next_await(promise) {
if (currentRenderContext) {
// 将 Promise 添加到待处理队列
currentRenderContext.pendingPromises.push(promise)
}
return promise
}
(3) 渲染控制
async function renderToStream() {
const fallback = await renderFallback() // 先渲染 fallback
stream.write(fallback)
await Promise.all(pendingPromises) // 等待所有异步任务
const content = await renderContent() // 渲染实际内容
stream.write(content)
}
并且由于Nextjs服务端渲染是 一次性过程,而客户端需要处理动态更新,因此服务端的逻辑其实是更加简化的,不会出现promise不同导致的死循环问题。
总结
- Suspense的客户端原理主要是
throw promise
和try catch
。使用时必须注意子组件中的promise必须每次渲染为同一个。 - 建议使用
use
,lazy
等API,或者直接使用react-query
等三方库的api来对接Suspense。 - Nextjs服务端内部原理较为复杂,其中Nextjs的服务端渲染只会执行一次,无需担心死循环问题。
- 服务端的Suspense原理上和客户端不用,不过使用起来反而更加方便,无需保证子组件内部的promise保持稳定。