Skip to content

Suspense、懒加载与错误边界 ​

问题:模块还没到与模块执行失败,谁来处理 ​

lazy(() => import('./Report.jsx')) 把组件代码变成按需加载资源。模块 Promise 未完成时,组件会暂停这次渲染。首次显示的边界可以显示 fallback;已有内容在 Transition 更新中遇到等待时,可以继续保留旧内容。Promise 拒绝或组件渲染抛错时,错误交给 Error Boundary。等待、保留旧内容和失败是三个不同状态。

先手动控制完成,观察两种更新 ​

网络很快时占位可能一闪而过,不能据此判断规则。下面使用 lazy 支持的模块 Promise,手动按按钮将它兑现为 { default: Component }。Promise 与 lazy 组件都在模块顶层创建,各组独立且身份稳定;这不是把任意 Effect 请求当成 Suspense 数据源。

完整替换 src/App.jsx,刷新后先打开两组,再分别完成加载:

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:

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:

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 并懒加载:

  1. 首页立即可用,用普通更新打开设置;首次模块未就绪时显示 正在下载设置…。用正文的手动模块闸门验证等待,不依赖网络必须足够慢。
  2. Settings 接收 mode;mode === 'broken' 时抛出 settings unavailable。
  3. 错误显示在设置区域,切回首页再打开正常模式可以恢复。
  4. 不使用 Effect 假装触发 Suspense,不使用 canary API 或第三方包。

完成后对照答案 05-02。

下一篇 ​

接着学习延迟值:不切换两个 state 的所有权,也能让昂贵消费方暂时读取旧值。

用心记录每一步成长