Suspense、懒加载与错误边界
问题:模块还没到与模块执行失败,谁来处理
lazy(() => import('./Report.jsx')) 把组件代码变成按需加载资源。模块 Promise 未完成时,组件会暂停这次渲染。首次显示的边界可以显示 fallback;已有内容在 Transition 更新中遇到等待时,可以继续保留旧内容。Promise 拒绝或组件渲染抛错时,错误交给 Error Boundary。等待、保留旧内容和失败是三个不同状态。
先手动控制完成,观察两种更新
网络很快时占位可能一闪而过,不能据此判断规则。下面使用 lazy 支持的模块 Promise,手动按按钮将它兑现为 { default: Component }。Promise 与 lazy 组件都在模块顶层创建,各组独立且身份稳定;这不是把任意 Effect 请求当成 Suspense 数据源。
完整替换 src/App.jsx,刷新后先打开两组,再分别完成加载:
import { lazy, Suspense, useState, useTransition } from 'react'
function Report() { return <p>报表已经就绪</p> }
function createModuleGate() {
let release
const promise = new Promise(resolve => {
release = () => resolve({ default: Report })
})
return { View: lazy(() => promise), release }
}
const ordinary = createModuleGate()
const transition = createModuleGate()
function Experiment({ label, resource, deferred }) {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const View = resource.View
function show() {
if (deferred) startTransition(() => setOpen(true))
else setOpen(true)
}
return <section aria-label={label}>
<h2>{label}</h2>
<button onClick={show}>打开报表</button>
<button onClick={resource.release}>完成加载</button>
<p>{isPending ? '切换中' : '没有过渡等待'}</p>
<Suspense fallback={<p>报表占位</p>}>
{open ? <View /> : <p>已显示的首页</p>}
</Suspense>
</section>
}
export default function App() {
return <main>
<Experiment label="普通更新" resource={ordinary} deferred={false} />
<Experiment label="过渡更新" resource={transition} deferred={true} />
</main>
}普通组打开后,已有首页被 fallback 替换;Transition 组打开后显示“切换中”,首页继续可见。两组都直到“完成加载”后才能显示报表,但等待期间展示的内容不同。isPending 描述过渡,普通更新没有这个 pending,并不表示它没有在等待模块。
按完一次完成按钮,Promise 就永久兑现,lazy 也缓存加载结果;再次点击不会重现首次等待。需要重做实验请刷新。真实网络重试同样不能只清掉错误 state,就假设同一个失败 lazy 资源会重新请求。
如果要让全新区域在 Transition 中也显示自己的 fallback,可以在新页面内新建嵌套 Suspense,边界从未显示过内容,就没有旧内容需要保留。不要给既有边界随意换 key 来“强制占位”,那同时重置了子树身份和状态。
完整多文件实验
这里的class、派生错误state和resetKey协议已在类组件错误边界与恢复展开;先完成该前置,再观察它如何承接lazy拒绝。
把手动兑现模块换回真实动态 import。下面保留 Transition 导航,因此首次进入报表通常是“首页仍可见+切换提示”,不能要求外层 fallback 一定出现。将 navigate 中的 startTransition 改为普通 setScreen,可以对照前一个实验。
src/App.jsx:
import { Component, Suspense, lazy, useState, useTransition } from 'react'
const Report = lazy(() => import('./Report.jsx'))
class ErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidUpdate(previousProps) {
if (previousProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
return <p role="alert">报表失败:{this.state.error.message}</p>
}
return this.props.children
}
}
export default function App() {
const [screen, setScreen] = useState('home')
const [broken, setBroken] = useState(false)
const [isPending, startTransition] = useTransition()
function navigate(next) {
startTransition(() => setScreen(next))
}
return (
<main>
<h1>分析台</h1>
<button onClick={() => navigate('home')}>首页</button>
<button onClick={() => navigate('report')}>报表</button>
<label>
<input
type="checkbox"
checked={broken}
onChange={(event) => setBroken(event.target.checked)}
/>
模拟报表错误
</label>
{isPending && <span>正在切换…</span>}
<ErrorBoundary resetKey={`${screen}-${broken}`}>
<Suspense fallback={<p>正在下载报表代码…</p>}>
{screen === 'home'
? <p>欢迎回来</p>
: <Report broken={broken} />}
</Suspense>
</ErrorBoundary>
</main>
)
}src/Report.jsx:
export default function Report({ broken }) {
if (broken) throw new Error('invalid report')
return <p>本月完成率:82%</p>
}首次点击“报表”会请求独立模块;加载期间这个已显示的 Suspense 在 Transition 中保留首页。勾选错误后,模块已经加载,不再等待,组件直接抛错,因此 Error Boundary 显示错误。改变 resetKey 让本教学边界清除旧错误;生产应用应根据重试、导航或业务身份明确设计复位。
这里首次出现类组件,只为定义错误边界:Component 提供 React 类组件基类;state 是该实例的状态;render 返回 JSX;静态 getDerivedStateFromError 在后代渲染失败时返回错误状态;componentDidUpdate 在提交更新后检查复位条件。边界不能捕获自己 render 中的错误,也不能代替事件或 Promise 的错误处理。其余业务组件仍可使用函数与 Hook,不必为了这个边界重写为类。
Suspense 真正会等待什么
稳定版 React 能识别的来源包括:支持 Suspense 的框架数据源、用 use 读取的缓存 Promise,以及 lazy 加载的组件代码。普通 Effect 中的 fetch 在提交之后才开始,React 渲染期间看不到一个“正在等待”的资源,不会自动激活 Suspense。
不要在组件渲染时随手创建新 Promise 再交给 use:每次渲染的新身份会导致重复等待;实际数据缓存与失效通常由支持 Suspense 的框架处理。本节用 lazy,是因为它在纯 Vite 客户端中有清晰、稳定的资源协议。
边界的放置
- 大边界让整块 UI 一起揭示,简单但可能隐藏已可用内容。
- 嵌套边界可让页面骨架先显示、慢区域后显示,但太碎会造成闪烁和布局跳动。
- Transition 能在更新导致既有内容 suspend 时保留已显示内容;新边界仍可立即显示自己的 fallback。
- Error Boundary 不捕获事件处理函数或任意异步回调里的错误;这些路径需要自己的错误 state/Promise 处理。
练习:契约任务
新增 Settings.jsx 并懒加载:
- 首页立即可用,用普通更新打开设置;首次模块未就绪时显示
正在下载设置…。用正文的手动模块闸门验证等待,不依赖网络必须足够慢。 - Settings 接收
mode;mode === 'broken'时抛出settings unavailable。 - 错误显示在设置区域,切回首页再打开正常模式可以恢复。
- 不使用 Effect 假装触发 Suspense,不使用 canary API 或第三方包。
完成后对照答案 05-02。
下一篇
接着学习延迟值:不切换两个 state 的所有权,也能让昂贵消费方暂时读取旧值。