路由、表单与组件架构 / Routing, Forms, and Component Architecture
📅 创建时间:2026-07-28 🏷️ 标签:#React #ReactRouter #ReactHookForm #Zod #ErrorBoundary #Suspense #ComponentArchitecture 📚 前置知识:[[02-hooks-state-and-effects]]
📋 本章目标
- 掌握 React Router v6.4+ 数据路由模式:createBrowserRouter、loader/action、嵌套路由与 Outlet
- 理解 URL 作为应用状态的核心理念,正确使用 useParams 与 useSearchParams
- 比较 React Hook Form、Formik、TanStack Form 的设计差异和适用场景
- 理解受控与非受控表单的性能差异,掌握 React Hook Form 的 register/watch/handleSubmit/formState
- 集成 Zod 进行声明式 Schema 验证,区分客户端验证与服务端验证的职责边界
- 设计 Feature-based 文件夹架构,理解与 Atomic Design、Colocation 的取舍
- 实现 ErrorBoundary 与 Suspense 的嵌套边界策略,控制错误隔离与加载粒度
- 构建持久化布局与嵌套 Outlet 模式,管理面包屑与导航状态
第1部分:React Router v6/v7 数据路由深度
1.1 URL 是应用状态
中大型 React 应用的复杂度通常不来自 JSX,而来自导航状态、表单状态、异步边界和跨功能依赖。以下信息必须进入 URL:
- 当前资源 ID 和子页面
- 搜索词、筛选条件、排序字段和分页页码
- 可分享、可收藏、应支持浏览器前进/后退的视图状态
┌─────────────────────────────────────────────────────────────┐
│ URL 承载的状态类型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ /projects/42/issues?status=open&sort=updated&page=2 │
│ ──────── ── ────── ────────────────────────────────── │
│ │ │ │ │ │
│ │ │ │ └── 搜索/筛选/分页参数 │
│ │ │ └── 子资源路由段 │
│ │ └── 资源 ID(路径参数) │
│ └── 功能模块路由段 │
│ │
│ ❌ 不要放进组件 State 或全局 Store: │
│ → 刷新丢失、无法分享、浏览器历史行为失真 │
│ │
│ ✅ 放入 URL: │
│ → 可收藏、可分享、前进后退自然、SSR 友好 │
│ │
└─────────────────────────────────────────────────────────────┘不要把这类状态只放进组件或 Store,否则刷新、分享和浏览器历史行为会失真。
1.2 createBrowserRouter vs createHashRouter
React Router v6.4 引入了数据路由(Data Router)概念,将路由定义与数据获取、数据变更统一在一个配置对象中:
// ✅ v6.4+ 推荐: createBrowserRouter(数据路由)
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <RouteError />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'projects/:projectId',
loader: projectLoader,
element: <ProjectLayout />,
children: [
{ index: true, element: <ProjectOverview /> },
{ path: 'settings', element: <ProjectSettings /> },
],
},
],
},
])
function App() {
return <RouterProvider router={router} />
}两种路由模式的选择:
| 模式 | 底层机制 | 适用场景 |
|---|---|---|
createBrowserRouter | History API (pushState) | 标准 Web 应用,需要服务端支持所有路径回退到 index.html |
createHashRouter | hashchange 事件 | 无法配置服务端回退的静态部署(如某些 CDN),Electron 应用 |
┌─────────────────────────────────────────────────────────────┐
│ Browser Router vs Hash Router 架构对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Browser Router: │
│ ┌─────────┐ GET /projects/42 ┌───────────────┐ │
│ │ 浏览器 │ ───────────────────→ │ Web Server │ │
│ │ │ ←─────────────────── │ 需要回退配置 │ │
│ └─────────┘ index.html (200) └───────────────┘ │
│ 路径: /projects/42 → 服务端需要知道返回 index.html │
│ │
│ Hash Router: │
│ ┌─────────┐ GET /#/projects/42 ┌───────────────┐ │
│ │ 浏览器 │ ───────────────────→ │ 任意静态服务 │ │
│ │ │ ←─────────────────── │ 无需配置 │ │
│ └─────────┘ index.html (200) └───────────────┘ │
│ 路径: /#/projects/42 → hash 段不发送到服务端 │
│ │
└─────────────────────────────────────────────────────────────┘1.3 Loader 与 Action 模式
v6.4 的核心创新在于将数据获取和变更声明在路由配置中,而非分散在组件的 useEffect 里:
// 路由定义中的 loader —— 在渲染前获取数据
async function projectLoader({ params }: LoaderFunctionArgs) {
const project = await fetchProject(params.projectId!)
if (!project) {
throw new Response('未找到项目', { status: 404 })
}
return defer({
project: fetchProject(params.projectId!),
members: fetchMembers(params.projectId!),
})
}
// 路由定义中的 action —— 处理表单提交和数据变更
async function updateProjectAction({ params, request }: ActionFunctionArgs) {
const formData = await request.formData()
const name = formData.get('name') as string
const errors = validateProject({ name })
if (errors) return json({ errors }, { status: 422 })
await updateProject(params.projectId!, { name })
return redirect(`/projects/${params.projectId}`)
}
// 组件中使用 loader 数据
function ProjectPage() {
const { project } = useLoaderData() as { project: Project }
const actionData = useActionData<{ errors?: Record<string, string> }>()
return (
<div>
<h1>{project.name}</h1>
{actionData?.errors && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
)
}loader 的关键特性:
- 渲染前执行:数据就绪后才渲染组件(避免 loading → content 闪烁)
- 自动重新验证:URL 参数变化时自动重新调用 loader
- 并行加载:嵌套路由的 loader 并行执行,不形成瀑布
defer+<Await>:关键数据阻塞渲染,非关键数据推迟加载
1.4 嵌套路由与 Outlet
<Outlet /> 是嵌套路由的占位符——父路由的 element 中放置 Outlet,子路由的 element 就会渲染在那个位置:
// 父布局组件
function ProjectLayout() {
const navigation = useNavigation()
const isNavigating = navigation.state === 'loading'
return (
<div className="project-layout">
<ProjectSidebar />
<main className={isNavigating ? 'loading' : ''}>
<Outlet /> {/* ← 子路由渲染在此 */}
</main>
</div>
)
}
// 路由配置
const router = createBrowserRouter([
{
path: 'projects/:projectId',
element: <ProjectLayout />, // 包含 <Outlet />
children: [
{ index: true, element: <ProjectOverview /> },
{ path: 'issues', element: <IssueList /> },
{ path: 'issues/:issueId', element: <IssueDetail /> },
],
},
])1.5 路由参数与查询参数
import { useParams, useSearchParams } from 'react-router-dom'
function IssueList() {
// 路径参数 —— /projects/:projectId/issues
const { projectId } = useParams<{ projectId: string }>()
// 查询参数 —— ?status=open&sort=updated&page=2
const [searchParams, setSearchParams] = useSearchParams()
const status = searchParams.get('status') ?? 'open'
const page = Number(searchParams.get('page') ?? '1')
// 更新查询参数(保留其他参数,不触发完整导航)
const setStatus = (newStatus: string) => {
setSearchParams(prev => {
prev.set('status', newStatus)
prev.set('page', '1') // 筛选变化时重置分页
return prev
})
}
return (/* ... */)
}1.6 导航守卫(Loader 中的 Redirect)
客户端守卫不能替代服务端授权——它只能改善交互体验,真正的数据访问仍必须在服务端校验:
// 认证守卫 loader
async function authGuard({ request }: LoaderFunctionArgs) {
const session = await getSession() // 从 cookie/token 读取
if (!session) {
const params = new URLSearchParams()
params.set('from', new URL(request.url).pathname)
throw redirect(`/login?${params}`) // 抛出 redirect 信号 → 重定向
}
return { user: session.user }
}
// 角色守卫 loader(组合使用)
async function adminGuard({ request }: LoaderFunctionArgs) {
const { user } = await authGuard({ request } as LoaderFunctionArgs)
if (user.role !== 'admin') {
throw new Response('Forbidden', { status: 403 })
}
return { user }
}
// 路由中使用
{
path: 'admin',
loader: adminGuard,
element: <AdminLayout />,
children: [/* ... */],
}┌─────────────────────────────────────────────────────────────┐
│ Loader 导航守卫执行流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户访问 /admin/dashboard │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ adminGuard │──→ getSession() │
│ │ loader │ │ │
│ └──────┬──────┘ ├── 无 session → throw redirect(/login) │
│ │ │ → 浏览器跳转到登录页 │
│ │ └── 有 session → 继续 │
│ ▼ │
│ ┌─────────────┐ │
│ │ role check │──→ user.role !== 'admin' │
│ │ │ → throw Response(403) │
│ │ │ → errorElement 渲染 403 页面 │
│ └──────┬──────┘ │
│ │ role === 'admin' │
│ ▼ │
│ ┌─────────────┐ │
│ │ AdminLayout │ ← 正常渲染 │
│ └─────────────┘ │
│ │
│ ⚠️ 客户端守卫 ≠ 服务端授权 │
│ 服务端必须独立校验每个 API 请求的身份和权限 │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:表单方案深度对比
2.1 表单的三层状态模型
每个表单至少包含三层状态,分开管理才能避免状态混乱:
┌─────────────────────────────────────────────────────────────┐
│ 表单三层状态模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: 输入草稿 (Draft) │
│ ├── 用户正在编辑的字段值 │
│ ├── 脏字段标记 (dirtyFields) │
│ └── 来源: useState / React Hook Form register │
│ │
│ Layer 2: 校验结果 (Validation) │
│ ├── 字段级错误 (fieldErrors) │
│ ├── 表单级错误 (formErrors) │
│ └── 来源: Zod parse / 自定义校验函数 │
│ │
│ Layer 3: 提交状态 (Submission) │
│ ├── 空闲 (idle) / 提交中 (submitting) │
│ ├── 成功 (success) / 失败 (error) │
│ └── 来源: useActionState / isSubmitting / actionData │
│ │
│ 反模式: 用一个 useState({}) 管理全部三层 → 不可维护 │
│ │
└─────────────────────────────────────────────────────────────┘2.2 React Hook Form vs Formik vs TanStack Form
┌─────────────────────────────────────────────────────────────┐
│ 三大表单库架构对比 │
├───────────────┬───────────────┬───────────────┬─────────────┤
│ 特性 │ React Hook │ Formik │ TanStack │
│ │ Form │ │ Form │
├───────────────┼───────────────┼───────────────┼─────────────┤
│ 渲染模式 │ 非受控 (ref) │ 受控 (state) │ 混合 (可选) │
│ 重渲染范围 │ 仅错误/提交 │ 全表单 │ 字段级订阅 │
│ 性能模型 │ 隔离重渲染 │ 整体重渲染 │ 细粒度订阅 │
│ 校验集成 │ 内置 + 三方 │ 内置 │ 适配器模式 │
│ Bundle 大小 │ ~9KB │ ~12KB │ ~15KB │
│ TypeScript │ 优秀 │ 一般 │ 极优秀 │
│ Headless │ 是 │ 否 (提供组件) │ 是 │
│ 最佳场景 │ 通用表单 │ 简单/快速原型 │ 复杂+跨框架 │
└───────────────┴───────────────┴───────────────┴─────────────┘React Hook Form 的核心设计哲学:表单输入值直接由 DOM 管理(非受控),通过 ref 注册字段,只在需要时才读取值。这让输入操作不会触发 React 重渲染——只有校验错误和提交状态变化才会更新 UI。
import { useForm } from 'react-hook-form'
type ProfileFormData = {
name: string
email: string
bio: string
}
function ProfileForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, dirtyFields },
} = useForm<ProfileFormData>({
defaultValues: { name: '', email: '', bio: '' },
})
const bioValue = watch('bio') // 选择性监听(仅在需要时触发重渲染)
async function onSubmit(data: ProfileFormData) {
await updateProfile(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
姓名
<input {...register('name', { required: '姓名为必填' })} />
</label>
{errors.name && <span role="alert">{errors.name.message}</span>}
<label>
邮箱
<input
{...register('email', {
required: '邮箱为必填',
pattern: { value: /^\S+@\S+$/i, message: '邮箱格式不正确' },
})}
/>
</label>
{errors.email && <span role="alert">{errors.email.message}</span>}
<label>
简介
<textarea {...register('bio')} />
</label>
<p>已输入 {bioValue.length} 字</p>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '保存中...' : '保存'}
</button>
</form>
)
}2.3 Formik 对比:受控模式的代价
Formik 采用受控模式——每次按键都通过 setFieldValue 更新 React state,触发整个表单及其所有子组件重渲染:
// Formik 受控模式 —— 每次按键触发全表单重渲染
import { Formik, Form, Field } from 'formik'
function ProfileForm() {
return (
<Formik
initialValues={{ name: '', email: '' }}
onSubmit={async (values) => { await updateProfile(values) }}
>
{({ isSubmitting }) => (
<Form>
<Field name="name" />
<Field name="email" />
<button type="submit" disabled={isSubmitting}>保存</button>
</Form>
)}
</Formik>
)
}受控 vs 非受控 的性能差异:
| 维度 | 受控 (Formik) | 非受控 (RHF) |
|---|---|---|
| 每次按键 | 触发 React state 更新 + 重渲染 | 仅更新 DOM(无 React 重渲染) |
| 校验执行 | 每次按键(默认)或 onBlur | 可配置:onSubmit / onBlur / onChange |
| 大型表单 | 重渲染开销线性增长 | 重渲染开销 O(1)(仅提交/错误) |
| 字段间联动 | state 天然同步(好读) | 需 watch / getValues |
2.4 TanStack Form:类型优先的选择
TanStack Form 是 TanStack 生态的新成员,核心卖点是编译期完全类型安全的表单——字段路径、校验结果、提交数据全部有完整类型推导:
import { useForm } from '@tanstack/react-form'
function ProfileForm() {
const form = useForm({
defaultValues: { name: '', email: '', tags: [] as string[] },
onSubmit: async ({ value }) => {
await updateProfile(value)
},
})
return (
<form
onSubmit={e => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field name="name"
validators={{ onChange: val => !val ? '必填' : undefined }}
>
{field => (
<>
<input
name={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map(e => (
<span key={e}>{e}</span>
))}
</>
)}
</form.Field>
</form>
)
}选择建议:
- React Hook Form:绝大多数 React 应用的默认选择
- Formik:已有项目使用 Formik 且表单规模小,无需迁移
- TanStack Form:TypeScript 重度用户,需要跨框架能力(React/Solid/Vue),或表单字段极度动态
第3部分:Zod 验证集成
3.1 Schema 先行:从 Zod 推导类型
Zod 的核心价值是单一真实来源——一个 Schema 同时是运行时验证器和 TypeScript 类型:
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
// 1. 定义 Schema —— 运行时验证 + TypeScript 类型
const profileSchema = z.object({
name: z
.string()
.min(1, '姓名不能为空')
.max(50, '姓名最多50个字符'),
email: z
.string()
.min(1, '邮箱不能为空')
.email('邮箱格式不正确'),
age: z
.number({ invalid_type_error: '请输入数字' })
.int('年龄必须为整数')
.min(0, '年龄不能为负')
.max(150, '请输入有效年龄'),
role: z.enum(['admin', 'editor', 'viewer'], {
errorMap: () => ({ message: '请选择有效角色' }),
}),
})
// 2. 从 Schema 推导类型(无需重复定义)
type ProfileFormData = z.infer<typeof profileSchema>
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema), // ← 一行集成
mode: 'onBlur', // 渐进式验证:失焦时校验
})
async function onSubmit(data: ProfileFormData) {
// data 类型完全由 Zod Schema 推导,无需额外注解
await updateProfile(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
姓名
<input {...register('name')} />
</label>
{errors.name && <span role="alert">{errors.name.message}</span>}
<label>
邮箱
<input type="email" {...register('email')} />
</label>
{errors.email && <span role="alert">{errors.email.message}</span>}
<label>
年龄
<input type="number" {...register('age', { valueAsNumber: true })} />
</label>
{errors.age && <span role="alert">{errors.age.message}</span>}
<label>
角色
<select {...register('role')}>
<option value="admin">管理员</option>
<option value="editor">编辑者</option>
<option value="viewer">观察者</option>
</select>
</label>
{errors.role && <span role="alert">{errors.role.message}</span>}
<button type="submit" disabled={isSubmitting}>保存</button>
</form>
)
}3.2 Zod 高级模式
// 条件校验:refine / superRefine
const passwordSchema = z
.object({
password: z.string().min(8, '密码至少8位'),
confirmPassword: z.string(),
})
.refine(data => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'], // 错误关联到 confirmPassword 字段
})
// 可选字段的精确处理
const searchSchema = z.object({
query: z.string().optional().default(''),
status: z.enum(['open', 'closed', 'all']).default('all'),
page: z.coerce.number().int().min(1).default(1),
// z.coerce.number() 将 URL 中的字符串 '1' 转为数字 1
})
// 可区分联合 (discriminated union)
const eventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('input'), value: z.string() }),
z.object({ type: z.literal('submit'), formId: z.string() }),
])3.3 客户端验证 vs 服务端验证分工
┌─────────────────────────────────────────────────────────────┐
│ 客户端验证 vs 服务端验证 分工 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 客户端验证(Zod / React Hook Form) │
│ ├── 目的: 即时反馈,改善 UX │
│ ├── 内容: 格式校验、必填检查、长度限制、枚举值 │
│ ├── 时机: onBlur(失焦校验)/ onSubmit(提交校验) │
│ └── 性质: 不可信 —— 可以被绕过 │
│ │
│ 服务端验证(必须执行) │
│ ├── 目的: 数据安全与完整性 │
│ ├── 内容: 所有校验规则 + 业务规则 │
│ ├── 时机: 每个 API 请求 / Server Action │
│ └── 性质: 唯一可信的验证源 │
│ │
│ 规则: 客户端校验是 UX 优化,服务端校验是安全底线 │
│ 即使客户端已通过校验,服务端也必须重新验证所有输入 │
│ │
│ 理想模式: 前后端共享一个 Zod Schema 文件 │
│ ├── Monorepo: 抽到 shared/schemas/ │
│ └── 同一 Schema 在前后端分别执行 │
│ │
└─────────────────────────────────────────────────────────────┘3.4 渐进式验证策略
const { register, formState: { errors }, trigger } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur', // 默认: 失焦时校验当前字段
reValidateMode: 'onChange', // 出错后: 输入时重新校验(实时清除错误)
})
// 手动触发特定字段校验
await trigger('email')
// 手动触发全表单校验(不提交)
const isValid = await trigger()| mode 值 | 首次校验时机 | 适用场景 |
|---|---|---|
onSubmit | 提交时 | 简单表单,减少打扰 |
onBlur | 字段失焦时 | 推荐——在即时反馈和打扰间平衡 |
onChange | 每次输入 | 搜索框、实时预览 |
all | 失焦 + 输入 | 需要极致即时反馈 |
第4部分:文件/文件夹架构
4.1 Feature-based vs Atomic Design vs Colocation
┌─────────────────────────────────────────────────────────────┐
│ 三种文件夹架构策略对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Feature-based (按业务能力) │
│ ───────────────────────── │
│ 边界: 业务领域 │
│ ├── features/auth/ {api, components, hooks, types} │
│ ├── features/projects/ {api, components, hooks, types} │
│ └── features/billing/ {api, components, hooks, types} │
│ 优势: 团队按功能拆分、改动隔离、删除干净 │
│ 劣势: 跨功能复用组件需要明确 shared 层 │
│ │
│ Atomic Design (按抽象层级) │
│ ───────────────────────── │
│ 边界: 组件复杂度 │
│ ├── atoms/ Button, Input, Label │
│ ├── molecules/ SearchBar, FormField │
│ ├── organisms/ Header, Sidebar, DataTable │
│ └── templates/ DashboardLayout, SettingsLayout │
│ 优势: 视觉一致性强制、设计系统友好 │
│ 劣势: 改动一个业务功能可能跨越多个层级 │
│ │
│ Colocation (按路由共置) │
│ ───────────────────────── │
│ 边界: 路由页面 │
│ ├── routes/projects/$id/ │
│ │ ├── page.tsx, loader.ts, components/, utils.ts │
│ 优势: 删除路由时一键删除所有相关代码 │
│ 劣势: 跨路由复用需要明确提取 │
│ │
│ 推荐: Feature-based + 路由内 Colocation │
│ 在 features/ 下按业务组织,每个功能内部路由就近放置代码 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 推荐目录结构
src/
├── app/ # 应用装配层
│ ├── router.tsx # 路由配置(数据路由定义)
│ ├── providers.tsx # Provider 组合(QueryClient, Theme等)
│ └── main.tsx # 入口:createRoot + RouterProvider
│
├── features/ # 业务功能(按领域拆分)
│ ├── auth/
│ │ ├── api/ # API 调用函数
│ │ │ └── auth.api.ts
│ │ ├── components/ # 功能专属组件
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignUpForm.tsx
│ │ ├── hooks/ # 功能专属 Hook
│ │ │ └── useAuth.ts
│ │ ├── routes/ # 路由页面组件 + loader/action
│ │ │ ├── LoginPage.tsx
│ │ │ └── login.loader.ts
│ │ ├── model/ # 类型、常量、Zod Schema
│ │ │ ├── auth.types.ts
│ │ │ └── auth.schema.ts
│ │ └── index.ts # 公共导出(仅导出给其他 feature 用的)
│ │
│ ├── projects/
│ │ ├── api/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── routes/
│ │ ├── model/
│ │ └── index.ts
│ │
│ └── billing/
│ ├── api/
│ ├── components/
│ ├── hooks/
│ ├── routes/
│ ├── model/
│ └── index.ts
│
├── shared/ # 跨领域共享
│ ├── ui/ # 通用 UI 组件(Button, Modal, Table)
│ ├── lib/ # 工具函数(formatDate, cn, fetcher)
│ ├── hooks/ # 通用 Hook(useDebounce, useMediaQuery)
│ ├── types/ # 全局类型定义
│ └── schemas/ # 共享的 Zod Schema(前后端共用)
│
└── main.tsx4.3 Feature 模块的封装边界
// features/projects/index.ts —— 每个 feature 控制自己的公共 API
export { ProjectList } from './components/ProjectList'
export { useProjects } from './hooks/useProjects'
export type { Project } from './model/project.types'
// ⚠️ 不要导出内部实现细节(api 调用函数、内部组件等)
// 其他 feature 引用时:
import { ProjectList, type Project } from '@/features/projects'
// ❌ 深路径导入(破坏了 feature 的封装边界):
import { ProjectList } from '@/features/projects/components/ProjectList'shared 只放真正跨领域且语义稳定的能力。不要因为两个组件暂时看起来相似,就立即抽象为拥有几十个 Props 的通用组件——重复优于错误的抽象。
第5部分:错误边界与 Suspense 边界
5.1 ErrorBoundary 组件设计
Error Boundary 是 React 中少数必须用类组件实现的特性之一——它依赖 componentDidCatch 生命周期方法,目前没有对应的 Hook:
import { Component, type ReactNode, type ErrorInfo } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
fallback: ReactNode | ((props: { error: Error; reset: () => void }) => ReactNode)
onError?: (error: Error, info: ErrorInfo) => void
}
type ErrorBoundaryState = {
error: Error | null
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info)
// 可在此上报到错误监控服务
console.error('[ErrorBoundary]', error, info.componentStack)
}
reset = () => {
this.setState({ error: null })
}
render() {
if (this.state.error) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback({ error: this.state.error, reset: this.reset })
}
return this.props.fallback
}
return this.props.children
}
}5.2 嵌套错误边界的粒度策略
┌─────────────────────────────────────────────────────────────┐
│ 嵌套错误边界策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <AppLayout> │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ <ErrorBoundary fallback={<SidebarError />}> │ │ │
│ │ │ <Sidebar /> │ │ │
│ │ │ </ErrorBoundary> │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ <ErrorBoundary fallback={<ContentError />}> │ │ │
│ │ │ <Suspense fallback={<ContentSkeleton />}> │ │ │
│ │ │ <Outlet /> ← 每个路由自然成为边界 │ │ │
│ │ │ </Suspense> │ │ │
│ │ │ </ErrorBoundary> │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ </AppLayout> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 策略: │
│ ├── 顶层 ErrorBoundary: 捕获未预料的崩溃,显示"出错了" │
│ ├── 侧边栏 ErrorBoundary: 侧边栏崩溃不影响主内容区 │
│ ├── 路由 ErrorBoundary: 页面崩溃不影响导航和布局 │
│ └── 小组件 ErrorBoundary: 可选内容崩溃不阻塞核心功能 │
│ │
│ 粒度原则: 一个 ErrorBoundary 的覆盖范围应该是"它崩溃了 │
│ 用户还能继续使用其他功能"的边界 │
│ │
└─────────────────────────────────────────────────────────────┘5.3 Suspense 用于代码拆分
import { Suspense, lazy } from 'react'
// 路由级代码拆分 —— 按需加载页面组件
const ProjectDetailPage = lazy(() => import('./routes/ProjectDetailPage'))
const SettingsPage = lazy(() => import('./routes/SettingsPage'))
const AnalyticsPage = lazy(() => import('./routes/AnalyticsPage'))
const router = createBrowserRouter([
{
path: 'projects/:projectId',
element: <ProjectLayout />,
children: [
{
index: true,
element: (
<Suspense fallback={<PageSkeleton />}>
<ProjectDetailPage />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<PageSkeleton />}>
<SettingsPage />
</Suspense>
),
},
{
path: 'analytics',
element: (
<Suspense fallback={<PageSkeleton />}>
<AnalyticsPage />
</Suspense>
),
},
],
},
])5.4 Suspense 用于数据获取(React 19 use())
// 结合 React 19 的 use() —— 在条件/循环中读取 Promise
function ProjectDetail({ projectId }: { projectId: string }) {
const project = use(fetchProject(projectId)) // 抛出 Promise → Suspense 捕获
return (
<article>
<h1>{project.name}</h1>
<p>{project.description}</p>
</article>
)
}
// 父组件包裹 Suspense
function ProjectPage() {
return (
<Suspense fallback={<ProjectSkeleton />}>
<ProjectDetail projectId="42" />
</Suspense>
)
}5.5 错误边界 + Suspense 的组合策略
function createRouteBoundary(
component: ReactNode,
options: {
loading?: ReactNode
error?: ReactNode | ((props: { error: Error; reset: () => void }) => ReactNode)
} = {}
) {
return (
<ErrorBoundary fallback={options.error ?? <DefaultRouteError />}>
<Suspense fallback={options.loading ?? <PageSkeleton />}>
{component}
</Suspense>
</ErrorBoundary>
)
}
// 使用
const router = createBrowserRouter([
{
path: 'projects/:projectId',
element: createRouteBoundary(<ProjectDetailPage />),
},
])第6部分:布局组件与 Outlet 模式
6.1 持久化布局
持久化布局在路由切换时保持挂载——Sidebar、Header 不会因为页面切换而卸载/重新挂载:
function AppLayout() {
return (
<div className="app-layout">
<AppHeader />
<div className="app-body">
<AppSidebar />
<main className="app-content">
<Suspense fallback={<PageSkeleton />}>
<Outlet /> {/* 只有此处的内容随路由切换 */}
</Suspense>
</main>
</div>
</div>
)
}
// 路由配置 —— AppLayout 作为根布局
const router = createBrowserRouter([
{
element: <AppLayout />, // 持久化:所有页面共享
errorElement: <RouteError />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/projects', element: <ProjectListPage /> },
{ path: '/settings', element: <SettingsPage /> },
],
},
])6.2 嵌套布局
// 一级布局:AppLayout(Header + Sidebar)
function AppLayout() {
return (
<div className="app">
<AppHeader />
<div className="app-main">
<AppSidebar />
<Outlet /> {/* → 二级布局或页面 */}
</div>
</div>
)
}
// 二级布局:ProjectLayout(项目内导航 + 内容区)
function ProjectLayout() {
const { projectId } = useParams()
const navigation = useNavigation()
const isNavigating = navigation.state === 'loading'
return (
<div className="project">
<ProjectTabs projectId={projectId!} />
<div className={isNavigating ? 'loading' : ''}>
<ErrorBoundary fallback={<div>页面加载失败</div>}>
<Suspense fallback={<div>加载中...</div>}>
<Outlet /> {/* → 项目子页面 */}
</Suspense>
</ErrorBoundary>
</div>
</div>
)
}
// 三级布局:Issue 详情(无 Outlet,叶子页面)
function IssueDetailPage() {
const { issueId } = useParams()
const issue = useLoaderData() as Issue
return <IssueDetail issue={issue} />
}
// 路由配置
const router = createBrowserRouter([
{
element: <AppLayout />, // L1
children: [
{
path: 'projects/:projectId',
element: <ProjectLayout />, // L2
loader: projectLoader,
children: [
{ index: true, element: <ProjectOverview /> },
{ path: 'issues/:issueId', element: <IssueDetailPage /> }, // L3(叶)
],
},
],
},
])┌─────────────────────────────────────────────────────────────┐
│ 嵌套布局与 Outlet 渲染层级 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ AppLayout (持久化) │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ AppHeader │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ ┌──────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ │ │ <Outlet /> │ │
│ │ │ Sidebar │ │ ┌─────────────────────────────┐ │ │
│ │ │ │ │ │ ProjectLayout │ │ │
│ │ │ (持久) │ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ │ │ │ ProjectTabs │ │ │ │
│ │ │ │ │ │ └───────────────────────┘ │ │ │
│ │ │ │ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ │ │ │ <Outlet /> │ │ │ │
│ │ │ │ │ │ │ → IssueDetailPage │ │ │ │
│ │ │ │ │ │ └───────────────────────┘ │ │ │
│ │ │ │ │ └─────────────────────────────┘ │ │
│ │ └──────────┘ └─────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 路由切换 /projects/1/overview → /projects/1/issues/42 │
│ ├── AppLayout: 不卸载(Header + Sidebar 保持) │
│ ├── ProjectLayout: 不卸载(Tabs 保持) │
│ └── Outlet 内容: 从 ProjectOverview → IssueDetailPage │
│ │
└─────────────────────────────────────────────────────────────┘6.3 面包屑与导航状态
import { useMatches, Link } from 'react-router-dom'
// 在路由配置中定义 breadcrumb handle
const router = createBrowserRouter([
{
element: <AppLayout />,
handle: { breadcrumb: () => '首页' },
children: [
{
path: 'projects',
handle: { breadcrumb: () => '项目列表' },
element: <ProjectListPage />,
},
{
path: 'projects/:projectId',
handle: { breadcrumb: (data: { project: Project }) => data.project.name },
loader: projectLoader,
element: <ProjectLayout />,
children: [
{
path: 'issues/:issueId',
handle: {
breadcrumb: (data: { issue: Issue }) => `#${data.issue.id} ${data.issue.title}`,
},
loader: issueLoader,
element: <IssueDetailPage />,
},
],
},
],
},
])
// 面包屑组件
function Breadcrumbs() {
const matches = useMatches()
const crumbs = matches
.filter(match => typeof match.handle?.breadcrumb === 'function')
.map(match => ({
pathname: match.pathname,
label: match.handle.breadcrumb(match.data),
}))
return (
<nav aria-label="面包屑导航">
<ol>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<li key={crumb.pathname}>
{isLast ? (
<span aria-current="page">{crumb.label}</span>
) : (
<Link to={crumb.pathname}>{crumb.label}</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}6.4 导航状态指示
import { useNavigation, NavLink } from 'react-router-dom'
function AppHeader() {
const navigation = useNavigation()
const isGlobalLoading = navigation.state === 'loading'
return (
<header>
{/* 全局加载指示器 */}
{isGlobalLoading && <ProgressBar />}
<nav>
<NavLink
to="/projects"
className={({ isActive, isPending }) =>
isPending ? 'pending' : isActive ? 'active' : ''
}
>
项目
</NavLink>
<NavLink
to="/reports"
className={({ isActive, isPending }) =>
isPending ? 'pending' : isActive ? 'active' : ''
}
>
报告
</NavLink>
</nav>
</header>
)
}useNavigation().state 的值:
| 状态 | 含义 |
|---|---|
idle | 无导航进行中 |
loading | 导航正在加载数据(loader 执行中) |
submitting | 表单正在提交(action 执行中) |
核心总结
┌─────────────────────────────────────────────────────────────┐
│ 路由、表单与组件架构 心智模型总结 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 🗺️ 路由层 (React Router v6.4+) │
│ ├── createBrowserRouter + loader/action 数据路由 │
│ ├── 嵌套 Outlet → 布局自然分层 │
│ ├── loader 中 redirect → 导航守卫 │
│ └── URL 承载可分享状态,不放进 Store 或 useState │
│ │
│ 📝 表单层 (React Hook Form + Zod) │
│ ├── 三层模型:草稿 / 校验 / 提交 → 各归其位 │
│ ├── 非受控 register → 隔离重渲染,性能 O(1) │
│ ├── Zod Schema → 运行时校验 + 类型推导,双赢 │
│ └── 客户端校验 = UX 优化,服务端校验 = 安全底线 │
│ │
│ 📁 架构层 (Feature-based + Colocation) │
│ ├── features/<domain>/ → 业务边界清晰 │
│ ├── shared/ → 只放真正跨领域的 │
│ └── index.ts 封装 → 控制 Feature 的公共 API │
│ │
│ 🛡️ 边界层 (ErrorBoundary + Suspense) │
│ ├── ErrorBoundary: 嵌套隔离 → 局部崩溃不影响全局 │
│ ├── Suspense: lazy() 代码拆分 + use() 数据获取 │
│ └── 组合: ErrorBoundary > Suspense > Outlet │
│ │
│ 📐 布局层 (Outlet + 持久化) │
│ ├── 持久化布局:路由切换不卸载 Header/Sidebar │
│ ├── 嵌套布局:AppLayout → ProjectLayout → 页面 │
│ └── breadcrumb + NavLink → 导航上下文 │
│ │
└─────────────────────────────────────────────────────────────┘核心原则:
- URL 是应用状态的一等公民 —— 可分享、可后退的状态必须进 URL
- loader 在渲染前获取数据 —— 消除 useEffect + useState + loading 三件套
- 非受控表单性能优于受控 —— React Hook Form 用 ref 管理输入,避免无效重渲染
- Schema 先行 —— Zod 同时提供运行时校验和编译期类型,消除类型定义重复
- Feature-based 文件夹架构 —— 按业务能力切分,而非按技术角色切分
- 错误边界分粒度嵌套 —— 隔离故障影响面,一个 widget 崩溃不应阻塞整个页面
- 布局持久化是用户体验的基础 —— Sidebar 和 Header 不应随页面切换闪烁
章节测试
测试1:数据路由
将以下传统路由写法改写为 v6.4+ 数据路由模式(createBrowserRouter + loader):
<Routes>
<Route path="/projects/:id" element={<ProjectPage />} />
</Routes>
function ProjectPage() {
const { id } = useParams()
const [project, setProject] = useState<Project | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProject(id!).then(setProject).finally(() => setLoading(false))
}, [id])
if (loading) return <div>加载中...</div>
return <div>{project?.name}</div>
}测试2:导航守卫
如何在 loader 中实现"未登录用户重定向到登录页,登录后回到原页面"?
测试3:表单三层状态
以下代码把三层状态混在一个 useState 中。请拆分为三层分离的方案:
const [form, setForm] = useState({
name: '', email: '', // 输入草稿
errors: {} as Record<string, string>, // 校验
isSubmitting: false, // 提交状态
})测试4:Zod Schema 设计
编写一个 Zod Schema,要求:
title必填,1-100 字符priority为 'low' | 'medium' | 'high',默认 'medium'tags为字符串数组,每个元素至少 1 个字符,且至少包含 1 个 tagdueDate为可选的 ISO 日期字符串
测试5:错误边界粒度
一个页面包含 Sidebar、主内容区和底部 Widget 面板。如果 Widget 面板崩溃,如何设计 ErrorBoundary 使得 Sidebar 和主内容区不受影响?
测试6:嵌套布局
画出一个三级嵌套布局的路由配置草稿:AppLayout(Header+Sidebar)→ 功能模块布局(Tabs)→ 功能详情页。说明哪些组件在路由切换时保持挂载。
测试7:文件夹架构
需要新增一个"消息通知 (notifications)"功能模块。请按照 Feature-based 架构,列出应该创建的所有文件和目录。
参考答案
测试1答案
const router = createBrowserRouter([
{
path: '/projects/:id',
loader: async ({ params }) => {
const project = await fetchProject(params.id!)
if (!project) throw new Response('未找到', { status: 404 })
return { project }
},
element: <ProjectPage />,
errorElement: <RouteError />,
},
])
function ProjectPage() {
const { project } = useLoaderData() as { project: Project }
return <div>{project.name}</div>
}测试2答案
async function authGuard({ request }: LoaderFunctionArgs) {
const session = await getSession()
if (!session) {
const params = new URLSearchParams()
params.set('redirect', new URL(request.url).pathname)
throw redirect(`/login?${params}`)
}
return { user: session.user }
}
// 登录成功后读取 redirect 参数跳转回原页面
// await login(credentials)
// navigate(searchParams.get('redirect') ?? '/')测试3答案
使用 React Hook Form 分离三层:
- Layer 1 (草稿):
register('name')/register('email')—— DOM 管理,不触发 React 重渲染 - Layer 2 (校验):
formState.errors—— 由 zodResolver 自动填充,仅在错误变化时更新 - Layer 3 (提交):
formState.isSubmitting—— 由 handleSubmit 控制,仅在 idle→submitting→idle 时更新
测试4答案
const taskSchema = z.object({
title: z.string().min(1, '标题必填').max(100, '标题最多100字符'),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
tags: z.array(z.string().min(1, '标签不能为空')).min(1, '至少添加1个标签'),
dueDate: z.string().datetime().optional(),
})测试5答案
为 Widget 面板单独包裹一个 ErrorBoundary,让它只捕获 Widget 子树内的错误:
<div className="page">
<Sidebar />
<main>
<Outlet />
</main>
<ErrorBoundary fallback={<div>Widget 暂时不可用</div>}>
<WidgetPanel />
</ErrorBoundary>
</div>Sidebar 和主内容区在 Widget 的 ErrorBoundary 外部,所以 Widget 崩溃时它们不受影响,各自独立渲染。
测试6答案
const router = createBrowserRouter([
{
element: <AppLayout />, // L1: Header + Sidebar(持久化)
children: [
{
path: 'reports',
element: <ReportsLayout />, // L2: 报告 Tabs(持久化)
children: [
{ path: ':reportId', element: <ReportDetail /> }, // L3: 详情页(切换)
],
},
],
},
])- 切换
reportId时:AppLayout 和 ReportsLayout 保持挂载,只有ReportDetail(Outlet 内容)重新渲染 - 从
/reports/1跳转到/settings:AppLayout 保持挂载,ReportsLayout 卸载
测试7答案
features/notifications/
├── api/
│ └── notifications.api.ts # 获取/标记已读/删除通知的 API
├── components/
│ ├── NotificationList.tsx # 通知列表
│ └── NotificationItem.tsx # 单个通知条目
├── hooks/
│ └── useNotifications.ts # 通知数据获取和轮询逻辑
├── routes/
│ └── NotificationsPage.tsx # 通知页面路由组件
├── model/
│ ├── notification.types.ts # Notification 类型定义
│ └── notification.schema.ts # Zod Schema(如通知表单)
└── index.ts # 公共导出相关笔记
- [[01-react-components-and-rendering]] - React 组件模型和渲染机制
- [[02-hooks-state-and-effects]] - Hooks 深度剖析:状态、副作用与并发模式
- [[04-redux-zustand-state-management]] - Redux Toolkit、Zustand 与状态边界
- [[05-nextjs-app-router-and-rendering]] - Next.js App Router 与渲染策略
下一步学习
- [ ] 阅读 04 - Redux Toolkit、Zustand 与状态边界
- [ ] 阅读 05 - Next.js App Router 与渲染策略
- [ ] 阅读 React Router 官方文档 Data Routers
- [ ] 阅读 React Hook Form 官方文档 Advanced Usage
- [ ] 实践:将一个现有 SPA 的 useEffect 数据获取改写为 loader 模式
- [ ] 实践:为项目引入 ErrorBoundary 嵌套策略,画出错误影响边界图
学习状态:🟡 开始学习