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。以下是具体实现原理:

  1. 编译时的静态分析 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>)
    }
    
  1. 运行时的协作机制 当渲染引擎执行到异步组件时,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)发送到客户端。
  1. 关键源码逻辑(简化版) 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 promisetry catch。使用时必须注意子组件中的promise必须每次渲染为同一个。
  • 建议使用uselazy等API,或者直接使用react-query等三方库的api来对接Suspense。
  • Nextjs服务端内部原理较为复杂,其中Nextjs的服务端渲染只会执行一次,无需担心死循环问题。
  • 服务端的Suspense原理上和客户端不用,不过使用起来反而更加方便,无需保证子组件内部的promise保持稳定。