Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

React 生态 / React Ecosystem

1. React 生态知识体系 / React Ecosystem Knowledge System

2. React 组件渲染、协调与数据流 / React Components, Rendering, and Data Flow

3. Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency

4. 路由、表单与组件架构 / Routing, Forms, and Component Architecture

5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand

6. Next.js App Router:路由、渲染与工程边界 / Next.js App Router, Routing, Rendering, and Engineering Boundaries

7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations

8. TanStack Query 与服务端状态管理 / TanStack Query and Server State Management

9. React 测试、性能与生产工程 / React Testing, Performance, and Production Engineering

本页目录

路由、表单与组件架构 / 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 友好                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

不要把这类状态只放进组件或 Store,否则刷新、分享和浏览器历史行为会失真。

1.2 createBrowserRouter vs createHashRouter ​

React Router v6.4 引入了数据路由(Data Router)概念,将路由定义与数据获取、数据变更统一在一个配置对象中:

tsx
// ✅ 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} />
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

两种路由模式的选择:

模式底层机制适用场景
createBrowserRouterHistory API (pushState)标准 Web 应用,需要服务端支持所有路径回退到 index.html
createHashRouterhashchange 事件无法配置服务端回退的静态部署(如某些 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

1.3 Loader 与 Action 模式 ​

v6.4 的核心创新在于将数据获取和变更声明在路由配置中,而非分散在组件的 useEffect 里:

tsx
// 路由定义中的 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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

loader 的关键特性:

  • 渲染前执行:数据就绪后才渲染组件(避免 loading → content 闪烁)
  • 自动重新验证:URL 参数变化时自动重新调用 loader
  • 并行加载:嵌套路由的 loader 并行执行,不形成瀑布
  • defer + <Await>:关键数据阻塞渲染,非关键数据推迟加载

1.4 嵌套路由与 Outlet ​

<Outlet /> 是嵌套路由的占位符——父路由的 element 中放置 Outlet,子路由的 element 就会渲染在那个位置:

tsx
// 父布局组件
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

1.5 路由参数与查询参数 ​

tsx
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

1.6 导航守卫(Loader 中的 Redirect) ​

客户端守卫不能替代服务端授权——它只能改善交互体验,真正的数据访问仍必须在服务端校验:

tsx
// 认证守卫 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: [/* ... */],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────────────────┐
│                 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 请求的身份和权限                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

第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({}) 管理全部三层 → 不可维护         │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

2.2 React Hook Form vs Formik vs TanStack Form ​

┌─────────────────────────────────────────────────────────────┐
│            三大表单库架构对比                                  │
├───────────────┬───────────────┬───────────────┬─────────────┤
│   特性         │ React Hook    │    Formik     │  TanStack   │
│               │    Form       │               │    Form     │
├───────────────┼───────────────┼───────────────┼─────────────┤
│ 渲染模式      │ 非受控 (ref)  │ 受控 (state)  │ 混合 (可选) │
│ 重渲染范围    │ 仅错误/提交   │ 全表单        │ 字段级订阅  │
│ 性能模型      │ 隔离重渲染    │ 整体重渲染    │ 细粒度订阅  │
│ 校验集成      │ 内置 + 三方   │ 内置          │ 适配器模式  │
│ Bundle 大小   │ ~9KB          │ ~12KB         │ ~15KB       │
│ TypeScript    │ 优秀          │ 一般          │ 极优秀      │
│ Headless      │ 是            │ 否 (提供组件) │ 是          │
│ 最佳场景      │ 通用表单      │ 简单/快速原型 │ 复杂+跨框架 │
└───────────────┴───────────────┴───────────────┴─────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

React Hook Form 的核心设计哲学:表单输入值直接由 DOM 管理(非受控),通过 ref 注册字段,只在需要时才读取值。这让输入操作不会触发 React 重渲染——只有校验错误和提交状态变化才会更新 UI。

tsx
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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

2.3 Formik 对比:受控模式的代价 ​

Formik 采用受控模式——每次按键都通过 setFieldValue 更新 React state,触发整个表单及其所有子组件重渲染:

tsx
// 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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

受控 vs 非受控 的性能差异:

维度受控 (Formik)非受控 (RHF)
每次按键触发 React state 更新 + 重渲染仅更新 DOM(无 React 重渲染)
校验执行每次按键(默认)或 onBlur可配置:onSubmit / onBlur / onChange
大型表单重渲染开销线性增长重渲染开销 O(1)(仅提交/错误)
字段间联动state 天然同步(好读)需 watch / getValues

2.4 TanStack Form:类型优先的选择 ​

TanStack Form 是 TanStack 生态的新成员,核心卖点是编译期完全类型安全的表单——字段路径、校验结果、提交数据全部有完整类型推导:

tsx
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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

选择建议:

  • React Hook Form:绝大多数 React 应用的默认选择
  • Formik:已有项目使用 Formik 且表单规模小,无需迁移
  • TanStack Form:TypeScript 重度用户,需要跨框架能力(React/Solid/Vue),或表单字段极度动态

第3部分:Zod 验证集成 ​

3.1 Schema 先行:从 Zod 推导类型 ​

Zod 的核心价值是单一真实来源——一个 Schema 同时是运行时验证器和 TypeScript 类型:

tsx
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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

3.2 Zod 高级模式 ​

tsx
// 条件校验: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() }),
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

3.3 客户端验证 vs 服务端验证分工 ​

┌─────────────────────────────────────────────────────────────┐
│              客户端验证 vs 服务端验证 分工                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  客户端验证(Zod / React Hook Form)                         │
│  ├── 目的: 即时反馈,改善 UX                                │
│  ├── 内容: 格式校验、必填检查、长度限制、枚举值              │
│  ├── 时机: onBlur(失焦校验)/ onSubmit(提交校验)          │
│  └── 性质: 不可信 —— 可以被绕过                             │
│                                                             │
│  服务端验证(必须执行)                                      │
│  ├── 目的: 数据安全与完整性                                 │
│  ├── 内容: 所有校验规则 + 业务规则                          │
│  ├── 时机: 每个 API 请求 / Server Action                    │
│  └── 性质: 唯一可信的验证源                                 │
│                                                             │
│  规则: 客户端校验是 UX 优化,服务端校验是安全底线            │
│  即使客户端已通过校验,服务端也必须重新验证所有输入          │
│                                                             │
│  理想模式: 前后端共享一个 Zod Schema 文件                    │
│  ├── Monorepo: 抽到 shared/schemas/                         │
│  └── 同一 Schema 在前后端分别执行                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

3.4 渐进式验证策略 ​

tsx
const { register, formState: { errors }, trigger } = useForm<FormData>({
  resolver: zodResolver(schema),
  mode: 'onBlur',           // 默认: 失焦时校验当前字段
  reValidateMode: 'onChange', // 出错后: 输入时重新校验(实时清除错误)
})

// 手动触发特定字段校验
await trigger('email')

// 手动触发全表单校验(不提交)
const isValid = await trigger()
1
2
3
4
5
6
7
8
9
10
11
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/ 下按业务组织,每个功能内部路由就近放置代码     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

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.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

4.3 Feature 模块的封装边界 ​

tsx
// 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'
1
2
3
4
5
6
7
8
9
10
11

shared 只放真正跨领域且语义稳定的能力。不要因为两个组件暂时看起来相似,就立即抽象为拥有几十个 Props 的通用组件——重复优于错误的抽象。


第5部分:错误边界与 Suspense 边界 ​

5.1 ErrorBoundary 组件设计 ​

Error Boundary 是 React 中少数必须用类组件实现的特性之一——它依赖 componentDidCatch 生命周期方法,目前没有对应的 Hook:

tsx
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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

5.2 嵌套错误边界的粒度策略 ​

┌─────────────────────────────────────────────────────────────┐
│              嵌套错误边界策略                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  <AppLayout>                                        │   │
│  │  ┌───────────────────────────────────────────────┐ │   │
│  │  │  <ErrorBoundary fallback={<SidebarError />}>  │ │   │
│  │  │    <Sidebar />                                │ │   │
│  │  │  </ErrorBoundary>                             │ │   │
│  │  └───────────────────────────────────────────────┘ │   │
│  │  ┌───────────────────────────────────────────────┐ │   │
│  │  │  <ErrorBoundary fallback={<ContentError />}>  │ │   │
│  │  │    <Suspense fallback={<ContentSkeleton />}>   │ │   │
│  │  │      <Outlet />    ← 每个路由自然成为边界     │ │   │
│  │  │    </Suspense>                                 │ │   │
│  │  │  </ErrorBoundary>                              │ │   │
│  │  └───────────────────────────────────────────────┘ │   │
│  │  </AppLayout>                                      │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  策略:                                                       │
│  ├── 顶层 ErrorBoundary: 捕获未预料的崩溃,显示"出错了"     │
│  ├── 侧边栏 ErrorBoundary: 侧边栏崩溃不影响主内容区          │
│  ├── 路由 ErrorBoundary: 页面崩溃不影响导航和布局            │
│  └── 小组件 ErrorBoundary: 可选内容崩溃不阻塞核心功能        │
│                                                             │
│  粒度原则: 一个 ErrorBoundary 的覆盖范围应该是"它崩溃了      │
│  用户还能继续使用其他功能"的边界                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

5.3 Suspense 用于代码拆分 ​

tsx
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>
        ),
      },
    ],
  },
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

5.4 Suspense 用于数据获取(React 19 use()) ​

tsx
// 结合 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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

5.5 错误边界 + Suspense 的组合策略 ​

tsx
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 />),
  },
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

第6部分:布局组件与 Outlet 模式 ​

6.1 持久化布局 ​

持久化布局在路由切换时保持挂载——Sidebar、Header 不会因为页面切换而卸载/重新挂载:

tsx
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 /> },
    ],
  },
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

6.2 嵌套布局 ​

tsx
// 一级布局: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(叶)
        ],
      },
    ],
  },
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
┌─────────────────────────────────────────────────────────────┐
│              嵌套布局与 Outlet 渲染层级                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │  AppLayout (持久化)                                  │  │
│  │  ┌──────────────────────────────────────────────┐   │  │
│  │  │  AppHeader                                   │   │  │
│  │  └──────────────────────────────────────────────┘   │  │
│  │  ┌──────────┐  ┌─────────────────────────────────┐  │  │
│  │  │          │  │  <Outlet />                        │  │
│  │  │ Sidebar  │  │  ┌─────────────────────────────┐ │  │
│  │  │          │  │  │  ProjectLayout               │ │  │
│  │  │  (持久)  │  │  │  ┌───────────────────────┐  │ │  │
│  │  │          │  │  │  │  ProjectTabs          │  │ │  │
│  │  │          │  │  │  └───────────────────────┘  │ │  │
│  │  │          │  │  │  ┌───────────────────────┐  │ │  │
│  │  │          │  │  │  │  <Outlet />           │  │ │  │
│  │  │          │  │  │  │  → IssueDetailPage    │  │ │  │
│  │  │          │  │  │  └───────────────────────┘  │ │  │
│  │  │          │  │  └─────────────────────────────┘ │  │
│  │  └──────────┘  └─────────────────────────────────┘  │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
│  路由切换 /projects/1/overview → /projects/1/issues/42      │
│  ├── AppLayout: 不卸载(Header + Sidebar 保持)             │
│  ├── ProjectLayout: 不卸载(Tabs 保持)                     │
│  └── Outlet 内容: 从 ProjectOverview → IssueDetailPage      │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

6.3 面包屑与导航状态 ​

tsx
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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

6.4 导航状态指示 ​

tsx
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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

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  → 导航上下文                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

核心原则:

  1. URL 是应用状态的一等公民 —— 可分享、可后退的状态必须进 URL
  2. loader 在渲染前获取数据 —— 消除 useEffect + useState + loading 三件套
  3. 非受控表单性能优于受控 —— React Hook Form 用 ref 管理输入,避免无效重渲染
  4. Schema 先行 —— Zod 同时提供运行时校验和编译期类型,消除类型定义重复
  5. Feature-based 文件夹架构 —— 按业务能力切分,而非按技术角色切分
  6. 错误边界分粒度嵌套 —— 隔离故障影响面,一个 widget 崩溃不应阻塞整个页面
  7. 布局持久化是用户体验的基础 —— Sidebar 和 Header 不应随页面切换闪烁

章节测试 ​

测试1:数据路由 ​

将以下传统路由写法改写为 v6.4+ 数据路由模式(createBrowserRouter + loader):

tsx
<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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

测试2:导航守卫 ​

如何在 loader 中实现"未登录用户重定向到登录页,登录后回到原页面"?

测试3:表单三层状态 ​

以下代码把三层状态混在一个 useState 中。请拆分为三层分离的方案:

tsx
const [form, setForm] = useState({
  name: '', email: '',          // 输入草稿
  errors: {} as Record<string, string>,  // 校验
  isSubmitting: false,          // 提交状态
})
1
2
3
4
5

测试4:Zod Schema 设计 ​

编写一个 Zod Schema,要求:

  • title 必填,1-100 字符
  • priority 为 'low' | 'medium' | 'high',默认 'medium'
  • tags 为字符串数组,每个元素至少 1 个字符,且至少包含 1 个 tag
  • dueDate 为可选的 ISO 日期字符串

测试5:错误边界粒度 ​

一个页面包含 Sidebar、主内容区和底部 Widget 面板。如果 Widget 面板崩溃,如何设计 ErrorBoundary 使得 Sidebar 和主内容区不受影响?

测试6:嵌套布局 ​

画出一个三级嵌套布局的路由配置草稿:AppLayout(Header+Sidebar)→ 功能模块布局(Tabs)→ 功能详情页。说明哪些组件在路由切换时保持挂载。

测试7:文件夹架构 ​

需要新增一个"消息通知 (notifications)"功能模块。请按照 Feature-based 架构,列出应该创建的所有文件和目录。


参考答案 ​

测试1答案 ​

tsx
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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

测试2答案 ​

tsx
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') ?? '/')
1
2
3
4
5
6
7
8
9
10
11
12
13

测试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答案 ​

tsx
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(),
})
1
2
3
4
5
6

测试5答案 ​

为 Widget 面板单独包裹一个 ErrorBoundary,让它只捕获 Widget 子树内的错误:

tsx
<div className="page">
  <Sidebar />
  <main>
    <Outlet />
  </main>
  <ErrorBoundary fallback={<div>Widget 暂时不可用</div>}>
    <WidgetPanel />
  </ErrorBoundary>
</div>
1
2
3
4
5
6
7
8
9

Sidebar 和主内容区在 Widget 的 ErrorBoundary 外部,所以 Widget 崩溃时它们不受影响,各自独立渲染。

测试6答案 ​

tsx
const router = createBrowserRouter([
  {
    element: <AppLayout />,       // L1: Header + Sidebar(持久化)
    children: [
      {
        path: 'reports',
        element: <ReportsLayout />, // L2: 报告 Tabs(持久化)
        children: [
          { path: ':reportId', element: <ReportDetail /> },  // L3: 详情页(切换)
        ],
      },
    ],
  },
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 切换 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                        # 公共导出
1
2
3
4
5
6
7
8
9
10
11
12
13
14

相关笔记 ​

  • [[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 嵌套策略,画出错误影响边界图

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇3. Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency
下一篇5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand

持续记录,持续成长

Copyright © Tidenflow