Lesson 16:Phase 2 总结 — ErrorBoundary、Suspense 与并发渲染特性
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:90~120 分钟(首次学习)
- 先修要求:完成 Lesson 15,保留 Data Router 与 QueryClientProvider 的主线配置
- 学习产出:添加可重试错误边界和Suspense查询,保留Data Router实现页面懒加载
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:掌握 React 的错误处理机制、客户端 Suspense 的正确使用姿势,理解并发渲染特性的核心思想,并回顾 Phase 2 的完整架构。
📦 本节产出:一个带有错误兜底、加载状态和按需加载的任务应用,以及可独立验证的并发更新示例。
一、ErrorBoundary — 组件级异常防护墙
1.1 问题:一个组件崩了,整个页面白屏
如果某个组件在渲染中抛出了一个 JavaScript 错误(比如读取了一个 undefined 对象的属性),React 在默认情况下会卸载整个组件树——用户看到一片白屏。
1.2 实现 ErrorBoundary
手写 Error Boundary 目前使用 class 生命周期,因为 getDerivedStateFromError 和 componentDidCatch 没有直接对应的 Hook。也可以使用已有的错误边界库,不必把业务组件改为 class:
// src/components/ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
onReset?: () => void
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
// 当子组件抛出错误时,这个静态方法被调用
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
// 错误详情上报(可发送到 Sentry 等监控平台)
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary 捕获到错误:', error, errorInfo)
// 未来可以在这里集成 Sentry.captureException(error)
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-6xl mb-4">😵</div>
<h2 className="text-xl font-bold text-gray-800 mb-2">页面出了点问题</h2>
<p className="text-gray-500 mb-6 max-w-md">
当前内容无法显示,请重试;详细错误可在开发控制台查看。
</p>
<button
onClick={() => {
this.props.onReset?.()
this.setState({ hasError: false, error: null })
}}
className="bg-indigo-600 text-white px-6 py-2 rounded-xl hover:bg-indigo-700"
>
🔄 重试
</button>
</div>
)
}
return this.props.children
}
}1.3 使用方式
在现有 RootLayout 中保留导航、主题 effect 与 Toaster,只替换 main 内的 <Outlet />,不要新建第二个 Router。QueryErrorResetBoundary 与 Query 的错误状态协作;改变路径时也重建边界,避免上个页面的错误残留。
// RootLayout.tsx 增补导入:
import { Suspense } from 'react'
import { useLocation } from 'react-router'
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { TaskListSkeleton } from '@/components/Skeleton'
// 在 RootLayout 顶层、return 之前:
const { pathname } = useLocation()
// 替换 main 内的 Outlet(Skeleton 在下文创建):
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary key={pathname} onReset={reset}>
<Suspense fallback={<TaskListSkeleton />}>
<Outlet />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>Error Boundary 处理后代渲染等阶段的错误;事件处理器、普通异步回调、服务端渲染以及边界自身抛错不在它的常规捕获范围。事件和请求处理器仍需 try/catch。React 19 Transition Action 中抛出的错误可交给错误边界。路由 loader/action 的异常另由 React Router 的 errorElement 处理。边界粒度按需要保留的界面决定,不必每层都加。
二、Suspense — 优雅的加载状态管理
2.1 用 Suspense 组织加载边界
在之前的课程中,我们处理加载状态是这样的:
// 普通 useQuery:在组件内处理状态(合法方案)
function Board() {
const { data, isPending, isError } = useQuery({ ... })
if (isPending) return <Skeleton />
if (isError) return <Error />
return <TaskList data={data} />
}问题是:如果一个页面有 5 个异步组件,你要写 5 组 isPending 检查。 Suspense 让你可以把 loading 状态"声明式"地提升到任意层级:
import { Suspense } from 'react'
// ✅ 父级统一声明 Loading 态
function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<CardSkeleton />}>
<RecentTasks /> {/* 内部须使用支持 Suspense 的数据源或 lazy */}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ProjectStats /> {/* 这个可以独立加载 */}
</Suspense>
</div>
)
}2.2 Suspense 的工作原理
IMPORTANT
上图是机制示意,不建议自行维护“抛 Promise”的数据缓存。Suspense 只识别支持它的读取方式,如 lazy、框架数据源或 useSuspenseQuery;它不会识别 effect 中的 fetch,也不会替普通 useQuery 自动接管加载状态。实际错误仍交给 Error Boundary。
2.3 骨架屏设计
骨架屏 (Skeleton) 可以预留内容尺寸、减少布局跳动;简单操作也可使用文字加载提示:
// src/components/Skeleton.tsx
export function CardSkeleton() {
return (
<div className="bg-white rounded-xl border p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-3 bg-gray-200 rounded w-full mb-2" />
<div className="h-3 bg-gray-200 rounded w-5/6 mb-2" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
</div>
)
}
export function TaskListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border p-4 animate-pulse flex items-center gap-3">
<div className="w-5 h-5 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded flex-1" />
</div>
))}
</div>
)
}2.4 将主线看板切换为 Suspense 查询
下面替换 Board.tsx,继续使用相同的项目和任务 key。useSuspenseQueries 将两个独立查询一起启动,避免在同一组件里连续调用两个 Suspense 查询导致串行等待。主线保留 Lesson 15 的任务状态 Hook 与 Lesson 12 的搜索组件。
// src/pages/projects/Board.tsx
import { useParams } from 'react-router'
import { useSuspenseQueries } from '@tanstack/react-query'
import { fetchProjects } from '@/api/projectRequests'
import { fetchTasks } from '@/api/taskRequests'
import TaskItem from '@/components/TaskItem'
import TaskSearch from '@/components/TaskSearch'
function BoardContent({ projectId }: { projectId: string }) {
const [projectsQuery, tasksQuery] = useSuspenseQueries({
queries: [
{ queryKey: ['projects'], queryFn: ({ signal }) => fetchProjects(signal) },
{ queryKey: ['tasks', projectId], queryFn: ({ signal }) => fetchTasks(projectId, signal) },
],
})
const project = projectsQuery.data.find(item => item.id === projectId)
if (!project) return <p>找不到这个项目</p>
return (
<section>
<h1 className="mb-4 text-2xl">{project.name}</h1>
<TaskSearch projectId={projectId} />
{tasksQuery.data.length === 0 && <p>暂无任务</p>}
<ul>{tasksQuery.data.map(task => <li key={task.id}><TaskItem task={task} /></li>)}</ul>
</section>
)
}
export default function Board() {
const { id } = useParams()
return id ? <BoardContent projectId={id} /> : <p>未选择项目</p>
}首次没有数据时显示外层 Skeleton;失败且无可用数据时由错误边界接住,重试按钮会重置查询错误。已有缓存的后台刷新失败通常保留旧数据,可另加提示。TanStack Query v5 的 Suspense hooks 不支持与普通查询完全相同的取消语义,不能据此保证所有在途请求都会停止。参见 Suspense 查询与错误重置。
三、🧠 深度专题:并发渲染特性(Concurrent Rendering)
3.1 什么是并发渲染?
在 React 18 之前,渲染是同步且不可中断的。一旦 React 开始渲染一棵大型组件树(比如 1000 个列表项),它会一路渲染到底,即使用户在这期间点击了按钮——按钮的响应也要等渲染完才能处理。
并发渲染允许 React 在可让出控制权的渲染工作之间暂停、重启或放弃低优先级工作,先处理更紧急的更新。它不是多线程,也不能打断一个正在执行的长 JavaScript 函数或同步 DOM commit。
3.2 useTransition — 标记低优先级更新
useTransition 告诉 React:"这个状态更新不紧急,可以被用户交互中断。"
startTransition 的回调会立即执行,不能把其中的 filter() 变成后台任务。下面只把筛选词的状态更新标为非紧急,再由独立列表组件在渲染时计算:
import { memo, useMemo, useState, useTransition, type ChangeEvent } from 'react'
const Results = memo(function Results({ items, query }: { items: string[]; query: string }) {
const filtered = useMemo(() => items.filter(item =>
item.toLowerCase().includes(query.toLowerCase()),
), [items, query])
return <ul>{filtered.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul>
})
function SearchableList({ items }: { items: string[] }) {
const [query, setQuery] = useState('')
const [filterQuery, setFilterQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setQuery(value) // 控制输入的状态必须及时更新
startTransition(() => setFilterQuery(value))
}
return (
<section>
<input aria-label="筛选列表" value={query} onChange={handleChange} />
<div className={isPending ? 'opacity-50' : ''}>
<Results items={items} query={filterQuery} />
</div>
</section>
)
}memo 让紧急输入更新时仍用旧筛选词的列表可以跳过计算,传入的 items 引用也需保持稳定。单次 filter() 仍不可中断;特别重的计算可考虑预处理、虚拟列表或 Worker。React 19 支持异步 Action,但 await 后的状态更新若需标为 Transition,仍按官方要求再次包进 startTransition。useTransition。
3.3 useDeferredValue — 推迟非紧急视图更新
如果你只是想给一个"值"降低优先级,不需要手动管理 startTransition:
import { useDeferredValue } from 'react'
function FilteredList({ query, items }: { query: string; items: string[] }) {
// deferredQuery 会"延迟"更新——React 会先处理高优先级的事
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery // 当前显示的是不是过时的?
return (
<div className={isStale ? 'opacity-50' : ''}>
<Results items={items} query={deferredQuery} />
</div>
)
}上例复用 3.2 的 Results。useDeferredValue 没有固定等待时长,不等同于防抖,也不会减少网络请求次数;请求频率仍由 Lesson 12 的 debounce 等策略控制。
四、React.lazy — 按需加载组件代码
在 Phase 2 的 Vite + React SPA 项目中,静态导入的页面代码会进入初始依赖图。最终是否拆出共享 chunk 由构建器决定,但静态导入不会自动成为按路由访问才加载的边界。
React.lazy 配合 Suspense(上面刚学的!)可以实现按需加载:
4.1 路由级代码分割
继续修改 main.tsx 的 Data Router,不要改回 <Routes> 或创建第二套路由。用以下声明替换 Board 与 Settings 的静态 import,其他导入和路由对象保留:
import { lazy } from 'react'
const Board = lazy(() => import('./pages/projects/Board'))
const Settings = lazy(() => import('./pages/Settings'))已有路由中的 <Board />、<Settings /> 无需更改;它们的加载等待由 1.3 中包裹 Outlet 的 Suspense 处理。lazy 目标必须默认导出组件,并在模块顶层声明 lazy 组件。打包后检查实际 chunk 和 Network 面板,不能预先保证固定大小或“一页一个文件”。
4.2 组件级代码分割
对于首屏用不到的重量级组件(如富文本编辑器、图表库),也可以单独 lazy:
import { lazy, Suspense, useState } from 'react'
// 扩展示例:先创建默认导出的 HeavyChart 组件并安装所选图表库
const HeavyChart = lazy(() => import('./components/HeavyChart'))
function Dashboard() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>📊 显示图表</button>
{showChart && (
<Suspense fallback={<div className="animate-pulse bg-gray-200 h-64 rounded-xl" />}>
<HeavyChart /> {/* 点击按钮时才下载图表库代码 */}
</Suspense>
)}
</div>
)
}TIP
React.lazy 也能与 React 的服务端 Suspense 渲染配合,并非 CSR 专用。Next.js 的 next/dynamic 提供框架集成和额外选项;服务端与客户端组件的边界仍需按框架规则处理。React lazy。
五、代码规范:ESLint 配置
沿用 Lesson 07 创建的 eslint.config.js。create-vite 8 的 React TypeScript 模板已配置 TypeScript、React Hooks 与 React Refresh 的推荐规则,不必重新安装或整份替换配置。
如需明确禁止 any,在现有 files: ['**/*.{ts,tsx}'] 配置对象中合并 rules,保留已有的 extends、languageOptions 等字段:
// eslint.config.js:只展示新增字段,不能用它替换整个文件
rules: {
'@typescript-eslint/no-explicit-any': 'error',
},推荐规则也会检查 Hook 调用和依赖。遇到报错时先检查代码;CLI 生成的组件若同时导出 Hook 与组件,可针对该文件评估 React Refresh 警告,不要为了通过检查把整套规则关掉。
npx eslint src/ # 检查整个 src 目录六、Phase 2 架构总览图
Phase 2 将路由、共享状态、请求、表单与错误处理连接到同一个任务应用:
Phase 2 测验题
回顾一下你在这十节课学到的核心概念:
- 什么是 Props Drilling?我们用什么库解决它?
- Zustand 不加选择器导致什么性能问题?
- 为什么要划分服务端状态和客户端状态?
onMutate如何修改共享缓存?useOptimistic如何维护局部临时 UI?- Zod 和 RHF 结合干什么事?Render Props 模式解决了什么问题?
- ErrorBoundary 捕获的是什么?Suspense 捕获的是什么?
useTransition和useDeferredValue分别适合什么场景?- 为什么部署 React Router 的 SPA 需要配置服务器 fallback 重写?
七、部署准备
npm run build # 模板脚本先运行 tsc -b,再执行 vite build
npm run preview # 本地预览打包结果SPA 路由 404 修复(参见 Lesson 07):
- Vercel/Netlify:为静态 SPA 配置回退重写到
index.html;普通 302 重定向不是同一件事 - Nginx:
try_files $uri $uri/ /index.html;
八、练习
- ErrorBoundary 实战:为项目新增一个
TaskBoardErrorBoundary,故意在任务详情组件抛错,验证 fallback UI 是否生效。 - Suspense 分层加载:把「项目列表」和「任务列表」拆成两个
Suspense边界,比较单一边界 vs 分层边界的体验差异。 - 并发渲染优化:在搜索框中分别实现
useTransition与useDeferredValue方案,记录输入延迟与渲染耗时对比。
九、📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 引入 ErrorBoundary 与 Suspense | 区分「错误兜底」和「加载兜底」两类边界 |
使用 React.lazy 做按需加载 | 建立按需加载边界,检查实际分包结果 |
| 理解并发渲染工具 | useTransition 与 useDeferredValue 适用于不同交互压力 |
| 完成 Phase 2 总结 | 具备进入 Next.js 全栈开发阶段的能力 |
迁移到 Phase 3 前检查清单:
- [ ] 能描述客户端状态与服务端状态的边界
- [ ] 能解释 ErrorBoundary 与 Suspense 的触发条件
- [ ] 能在真实页面中落地懒加载与分层 loading 策略