第十一章. 导航核心:集成 React Router v7 与企业级架构


第十一章. 导航核心:集成 React Router v7 与企业级架构

到目前为止,Prorise-Admin 还只是一个“单页”的 React 应用。在本章中,我们将为其安装“神经系统”——react-router-dom v7。

本章的目标不仅仅是“添加几个页面”,而是要深入理解 React Router v7 数据驱动 的设计哲学,并从一开始就搭建一个具备 高性能(路由懒加载)和 高可维护性sections 拆分架构)的企业级路由骨架。

11.1. 奠基:从静态应用到数据路由 SPA

11.1.1. 痛点:为什么需要客户端路由?

目前,main.tsx 只是一个静态渲染 <MyApp /> 的入口。它无法区分 http://localhost:5173/http://localhost:5173/login。无论 URL 如何变化,用户看到的都是同一个页面。这不是一个“应用”,只是一个“组件”。

传统多页应用 (MPA) 的做法是:每次 URL 变化(例如点击链接),浏览器都会向服务器发起一个 全新的页面请求,服务器返回一个全新的 HTML 文档。这会导致页面“白屏”、状态丢失、体验割裂。

单页应用 (SPA) 的解决方案是:

  1. 只在首次访问时加载一个 HTML“壳”。
  2. 此后,所有 URL 变化都由 客户端 JavaScript(即 React Router)接管。
  3. React Router 会 拦截 导航事件,阻止 浏览器发起新请求。
  4. 它在 客户端 匹配 URL,动态地卸载旧组件、挂载新组件。

这带来了流畅的、无刷新的“应用级”体验。

11.1.2. 为什么选择 createBrowserRouter

React Router v5 及更早版本中,路由是通过 组件 来定义的,例如 <BrowserRouter><Route path="/" component={Home} /></BrowserRouter>

React Router v6/v7 带来了 核心变革:从“组件即路由”转向“配置即路由”。

我们选择 v7 的核心 API createBrowserRouter,因为它是 2025 年的最佳实践。它创建的不是一个组件,而是一个 数据路由引擎。一个路由对象 ({ path: ... }) 现在可以集中定义:

  1. Component:渲染什么组件。
  2. loader:(数据驱动核心) 在渲染前 加载什么数据
  3. action:(数据驱动核心) 如何 处理此路由上的表单提交(我们将在第 14 章的登录表单中深入使用)。
  4. errorElement:组件、loaderaction 在执行出错时,显示什么降级 UI。

这种“配置即路由”的模式,使路由配置更集中、更声明式,并且从一开始就具备了处理数据和错误的能力。

11.1.3. (编码) 安装依赖

我们开始本章的第一个编码任务:安装 react-router-dom

1
pnpm add react-router-dom

11.1.4. (编码) 创建根路由 index.tsx

我们的所有路由配置都将存放在 src/routes/ 目录下。创建 index.tsx 文件,这是路由系统的 入口

文件路径: src/routes/index.tsx

1. 导入核心 API:

1
2
3
4
import { createBrowserRouter } from 'react-router-dom';

// 导入我们的根组件
import MyApp from '@/MyApp';

2. 创建路由配置数组:

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
// 这是一个临时的测试页面,稍后会删除
function WelcomePage() {
return <h1>欢迎来到 Prorise-Admin</h1>;
}

// 路由配置数组
const routes = [
{
path: '/',
Component: MyApp, // 1. 根路径 '/' 渲染 <MyApp />
// 2. 根路由的错误边界
errorElement: <div>应用根组件加载失败</div>,

// 3. 定义子路由
children: [
{
index: true, // 4. 'index: true' 表示这是默认子路由
element: <WelcomePage />,
},
// ... 其他子路由将在这里添加
],
},
];

// 5. 创建并导出 router 实例
export const router = createBrowserRouter(routes);

思考

  • Component: MyApp:我们将 <MyApp />(第六章 ThemeProvider 的封装)作为 根布局组件
  • children: 这是嵌套路由的核心。react-router-dom v7 会将 children 中的 element(如 <WelcomePage />)渲染到父组件 (MyApp) 内部的一个 <Outlet /> 组件中。
  • index: true:这指定了当 URL 匹配到父路径 (/) 时,默认应该渲染哪个子路由。

11.1.5. (编码) 重构 main.tsx 应用入口

现在,router 实例已经创建,它成为了应用的新“大脑”。我们必须修改 src/main.tsx,让 React 渲染这个 router,而不是直接渲染 <MyApp />

文件路径: src/main.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import ReactDOM from 'react-dom/client';
// 1. 不再导入 MyApp
// import MyApp from './MyApp';
import './index.css'; // 导入全局样式 (包含CSS桥接层)

// 2. 导入 React Router v7 的核心组件和我们的 router 实例
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* 3. 不再渲染 <MyApp 也不再渲染主题适配器 />
<MyApp />
*/}

{/* 4. 渲染 RouterProvider,并将 router 实例作为 prop 传入 */}
<RouterProvider router={router} />
</React.StrictMode>,
);

架构解析

  • main.tsx 的职责main.tsx 现在的唯一职责就是:启动 React,并将根 router 实例交给 <RouterProvider />
  • <RouterProvider />:这是 react-router-dom v7 的 根组件。它会接管应用的渲染,根据当前的 URL,从 router 配置中找到匹配的路由对象,并渲染对应的 Component

验证:保存所有文件并运行 pnpm dev

  1. 访问 http://localhost:5173/
  2. 你会看到 <WelcomePage /> 的内容(“欢迎来到 Prorise-Admin”)被渲染了。
  3. 打开 React DevTools,你会看到组件树是 <RouterProvider> -> <MyApp> -> <WelcomePage />
  4. 这证明我们的路由系统工作了:router 匹配了 /,渲染了根组件 <MyApp />,然后又匹配了 index: true,将 <WelcomePage /> 渲染到了 <MyApp /><Outlet />(我们下一节添加)中。

任务 11.1 已完成。
我们成功地将 Prorise-Admin 从一个静态组件升级为了一个由 react-router-dom v7 驱动的、真正的 SPA 应用。


11.2. 性能优化:路由级代码分割与 Suspense 集成

在 11.1 节中,项目成功集成了 react-router-dom v7,将静态的 React 应用转变为具备基本导航能力的单页应用(SPA)。然而,当前的实现方式隐藏着一个 严重的性能隐患,必须在项目早期就予以解决,否则随着应用的增长,将导致灾难性的用户体验。

11.2.1 性能瓶颈:静态导入与初始包体积膨胀

让我们回顾 11.1 节的核心代码片段:

文件路径: src/routes/index.tsx (11.1 版本)

1
2
3
4
5
6
7
8
9
10
11
12
import { createBrowserRouter } from 'react-router-dom';
import MyApp from '@/MyApp'; // <-- 静态导入
function WelcomePage() { /* ... */ } // <-- 组件直接定义或静态导入

const routes = [
{
path: '/',
Component: MyApp,
children: [ { index: true, element: <WelcomePage /> } ],
},
];
export const router = createBrowserRouter(routes);

问题在哪里?

这里的 import MyApp from '@/MyApp'; 和直接使用的 <WelcomePage /> 都属于 静态导入。对于现代 JavaScript 打包工具(如 Vite 或 Webpack)而言,静态导入意味着:“这个模块(及其所有依赖)在应用启动时就必须可用。”

因此,当 Vite 构建生产版本时,它会进行依赖分析,将 MyApp.tsxWelcomePage.tsx 以及它们递归导入的 所有 代码——包括庞大的 UI 库(如 Ant Design)、图标库、日期处理库、图表库等等——全部打包进一个或少数几个 初始 JavaScript 文件(通常称为 main.jsvendor.js)。

后果是什么?

  • 对于小型应用(几个页面):这通常不是问题,初始包体积可能只有几百 KB。
  • 对于企业级后台(数十甚至上百个页面):这将导致初始 main.js 文件 极其臃肿,体积轻易达到 数 MB 甚至十几 MB

当用户首次访问应用时,浏览器必须 完整下载、解析并执行 这个巨大的 main.js 文件,然后才能渲染出第一个有意义的界面(First Meaningful Paint, FMP)并响应用户交互(Time to Interactive, TTI)。

用户的直观感受就是:点击链接后长时间的 白屏,或者页面出来了但按钮点击没反应。这对于需要高效率操作的企业级后台应用来说,是 完全不可接受的。随着应用功能的迭代,这个问题只会越来越严重,最终成为项目性能的主要瓶颈。

11.2.2 核心解决方案:路由级代码分割

要解决初始包体积膨胀的问题,必须实施 代码分割。代码分割的核心思想是:“不要一次性加载所有代码,只在需要时加载对应的代码。”

对于 SPA 应用,最有效、最常用的代码分割策略就是 路由级代码分割。这意味着:

  • 只有当用户访问 /dashboard 路由时,才去下载 DashboardPage.js 及其相关依赖的代码块 (chunk)。
  • 只有当用户访问 /system/users 路由时,才去下载 UserManagementPage.js 及其相关依赖的代码块。

这样,应用的初始加载体积可以被 显著减小,只包含核心框架、根布局和当前访问路由所需的最小代码集。其他页面的代码将在用户导航到对应路由时 按需、并行 地加载。

11.2.3 实现机制:React.lazy 与动态 import()

React 官方提供了 React.lazy() 函数,专门用于实现组件级别的代码分割。它与 JavaScript 的 动态 import() 语法(注意是函数调用 import() 而不是顶部的 import ... from ... 语句)协同工作。

转换过程

  1. 静态导入 (打包进主文件):

    1
    import WelcomePage from '@/pages/Welcome';
  2. 动态导入 (触发代码分割):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { lazy } from 'react';

    // 1. 调用 lazy() 函数
    const LazyWelcomePage = lazy(
    // 2. 传入一个函数,该函数调用动态 import()
    () => import('@/pages/Welcome')
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    // 这个 import() 返回一个 Promise,
    // 该 Promise 会在浏览器成功下载并解析 '@ /pages/Welcome' 模块后 resolve
    );

LazyWelcomePage 现在是一个特殊的 “可挂起 (Suspendable)” 组件。当 React Router 尝试渲染它时:

  • React 检测到这是一个 lazy 组件。
  • 暂停 (Suspend) 当前的渲染流程。
  • 触发 内部的 import('@/pages/Welcome') 调用。
  • 浏览器 开始异步下载 由 Vite 自动分割出来的 Welcome.js 代码块 (chunk)。
  • 下载并解析完成后,import() 返回的 Promise 被 resolve。
  • React 恢复 渲染,将真正的 <WelcomePage /> 组件渲染到 DOM 中。

11.2.4 处理加载状态:React.Suspense 与占位符 UI

React.lazy 触发的异步加载期间(“暂停”状态),用户界面不能显示为空白或卡顿。必须提供一个 加载指示器 (Loading Indicator)占位符 UI (Placeholder UI)

React 提供了 <Suspense> 组件来解决这个问题。Suspense 是一个 边界 (Boundary) 组件,它可以捕获其子树中任何组件(包括 lazy 组件)抛出的“暂停”信号。

工作流程

  1. 使用 <Suspense> 组件包裹可能触发暂停的组件。
  2. <Suspense> 提供一个 fallback prop,其值是一个 React 元素(例如,一个加载动画)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, { Suspense, lazy } from 'react';

const LazyWelcomePage = lazy(() => import('@/pages/Welcome'));

// 一个简单的加载指示器组件
function LoadingIndicator() {
return <div>页面加载中...</div>;
}

function App() {
return (
// 当 LazyWelcomePage 触发暂停时...
<Suspense fallback={<LoadingIndicator />}>
{/* ... Suspense 会渲染 fallback UI */}
<LazyWelcomePage />
</Suspense>
);
}

LazyWelcomePage 加载完成并恢复渲染时,Suspense 会自动用 <WelcomePage /> 替换掉 fallback UI。

架构策略:对于路由懒加载,最佳实践是将 <Suspense> 放置在路由树中尽可能高的位置,通常是在渲染子路由的 <Outlet /> 组件周围。这样,一个 <Suspense> 就可以处理 所有 子路由的加载状态。

11.2.5 (编码) 创建路由加载占位符 RouteLoading.tsx

现在,开始实现 Suspense 需要的 fallback UI。我们将实现一个 简洁、职责单一 的加载组件。它不关心加载进度或 URL 变化,只负责“显示加载状态”,状态管理完全交给 React Suspense

1. 创建文件:
src/components/loading/ 目录下创建 route-loading.tsx 文件。

1
touch src/components/loading/route-loading.tsx

2. 导入依赖:
为了提供清晰的视觉反馈,可以使用项目已集成的 Ant Design UI 库中的 <Spin /> 组件。

1
2
// src/components/loading/route-loading.tsx
import { Spin } from 'antd';

3. 实现组件:
目标是在屏幕中央显示一个旋转的加载动画。

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
// src/components/loading/route-loading.tsx
import { Spin } from 'antd';

/**
* 全局路由切换时的加载状态指示器。
* 设计为作为 React.Suspense 的 fallback 属性值使用。
* 它本身不包含任何状态或逻辑,仅负责 UI 展示。
*/
function RouteLoading() {
// 思考:为什么选择全屏居中?
// 因为路由切换通常意味着整个页面内容的替换,
// 一个覆盖全屏的居中加载指示器能提供最明确的“正在加载”反馈。
return (
// 使用 Flexbox 布局实现全屏垂直水平居中
<div
className="flex h-screen w-screen items-center justify-center"
aria-label="页面加载中" // 添加 aria-label 提升可访问性
role="status" // 明确语义角色
>
{/* 渲染 Antd 的 Spin 组件。
'large' 尺寸提供足够的视觉可见性。
Spin 组件内部通常已处理好 a11y 属性。
*/}
<Spin size="large" />
</div>
);
}

export default RouteLoading;

设计哲学剖析

  • 简洁性与职责单一:这个 RouteLoading 组件极其简单。它 包含 useState, useEffect, MutationObserver, setInterval 或任何与路由状态相关的逻辑。它仅仅是一个纯粹的 UI 展示组件。
  • 关注点分离 (SoC):它将“何时显示/隐藏加载状态”的复杂逻辑完全委托给了 React 的 Suspense 机制。Suspense 负责感知 lazy 组件的加载状态,RouteLoading 只负责在被要求渲染时,画出那个加载动画。
  • 可维护性:这种解耦使得 RouteLoading 非常容易维护和替换。如果将来需要更换加载动画样式,只需修改这一个文件,而无需触及任何路由或状态管理逻辑。
  • 性能:由于其简单性,它自身的性能开销极低。

11.2.6 (编码) 重构根组件 MyApp.tsx 以集成 Suspense<Outlet>

RouteLoading 组件已准备就绪。下一步是修改根路由组件 MyApp.tsx,将其集成进去。MyApp.tsx 需要完成两件事:

  1. 引入 React.Suspense 并使用 RouteLoading 作为 fallback
  2. 引入 react-router-dom<Outlet /> 组件,指定子路由应该渲染的位置。

文件路径: src/MyApp.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
// 1. 导入 Suspense (来自 React) 和 Outlet (来自 React Router)
import { Suspense } from "react";
import { Outlet } from "react-router-dom";

// 2. 导入刚刚创建的加载占位符组件
import RouteLoading from "@/components/loading/route-loading";

// 3.导入项目已有的上下文提供者
import { AntdAdapter } from "@/theme/adapter/antd.adapter";
import { ThemeProvider } from "@/theme/theme-provider";

/**
* 应用的根组件,同时也作为根路由 '/' 的布局组件。
* 它负责提供全局上下文 (主题, Antd 配置)
* 并定义子路由的渲染位置 (<Outlet />) 及其加载状态 (<Suspense />)。
*/
function MyApp() {
// 思考:为什么 Suspense 放在这里?
// 将 Suspense 放在尽可能靠近路由变化的地方(即 Outlet 周围)
// 可以确保所有懒加载的子路由都能共享同一个 fallback UI,
// 简化了配置,并提供了统一的加载体验。
return (
// 全局上下文提供者 (来自之前章节)
<ThemeProvider adapters={[AntdAdapter]}>
<Suspense fallback={<RouteLoading />}>
<Outlet />
</Suspense>
</ThemeProvider>
);
}
export default MyApp;

架构职责明确

  • MyApp.tsx 现在清晰地承担了 根布局全局上下文提供者 的角色。
  • 它通过 <Outlet /> 定义了子路由的“坑位”。
  • 它通过 <Suspense> 定义了所有子路由加载时的 统一视觉反馈

11.2.7 重构路由配置 index.tsx 以应用 React.lazy

万事俱备,只欠东风。最后一步是将 src/routes/index.tsx 中的路由组件改为使用 React.lazy 进行动态导入。

1. 创建占位页面组件:
为了让 lazy 函数能工作,需要先创建它要加载的文件。

1
2
mkdir -p src/pages
touch src/pages/Welcome.tsx

文件路径: src/pages/Welcome.tsx (临时内容)

1
2
3
4
5
6
7
8
export default function WelcomePage() {
return (
<div className="p-4">
<h1 className="text-2xl font-bold">欢迎来到 Prorise-Admin (懒加载实现)</h1>
<p>这个页面是通过 React.lazy 和 Suspense 实现代码分割的。</p>
</div>
);
}

2. 修改路由配置文件:

文件路径: src/routes/index.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
import { lazy } from "react";
import { createBrowserRouter } from "react-router-dom";

import MyApp from "@/MyApp";

// 使用 React.lazy 动态导入页面组件以实现代码分割
const LazyWelcomePage = lazy(() => import("@/pages/Welcome"));

const routes = [
{
path: "/",
// MyApp 作为根布局组件,直接导入而非懒加载
// 这确保了全局上下文(主题、Suspense 等)能立即可用
Component: MyApp,
errorElement: <div>应用根组件加载失败</div>,
children: [
{
index: true,
// 子路由页面使用懒加载,减少初始包体积
element: <LazyWelcomePage />,
},
],
},
];

export const router = createBrowserRouter(routes);
  • 加载流程
    1. 用户访问 /
    2. RouterProvider 匹配到根路由,需要渲染 LazyMyApp
    3. React 暂停,触发 import('@/MyApp'),下载 MyApp.chunk.js
      • (此时,由于 RouterProvider 外部没有 Suspense,用户可能会看到短暂白屏或浏览器默认加载状态,这是正常的,根组件加载无法避免)
    4. MyApp 加载完成,开始渲染。
    5. MyApp 渲染到 <Suspense fallback={<RouteLoading />}> <Outlet /> </Suspense>
    6. <Outlet /> 需要渲染 LazyWelcomePage (因为 index: true)。
    7. React 再次暂停,触发 import('@/pages/Welcome'),下载 Welcome.chunk.js
    8. 在下载 Welcome.chunk.js 期间,MyApp 内部的 Suspense 捕获 到暂停,渲染 <RouteLoading />
    9. WelcomePage 加载完成,Suspense 自动切换,渲染 <WelcomePage />

11.2.8 验证实现效果

  1. 确保 src/pages/Welcome.tsx 已创建。
  2. 运行 pnpm dev
  3. 打开浏览器开发者工具 (F12),切换到“网络 (Network)”选项卡,勾选“禁用缓存 (Disable cache)”。
  4. 访问 http://localhost:5173/
  5. 观察网络请求
    • 除了 main.tsx, @vite/client 等核心文件。
    • 会看到浏览器 异步请求 了类似 _MyApp.js_Welcome.js (文件名可能带有 hash) 的两个 JavaScript 文件。这证明代码分割成功!
  6. 观察界面
    • 在页面内容(“欢迎…”)出现之前,会 短暂地看到 RouteLoading 组件(Antd Spin)在屏幕中央旋转。这证明 Suspensefallback 工作正常!
  7. 再次刷新 (保持禁用缓存):会重复看到加载指示器和网络请求,因为每次都强制重新下载。
  8. 取消勾选“禁用缓存”后刷新:加载指示器几乎不可见,页面瞬时出现。网络请求状态码变为 304 (Not Modified) 或 200 (from disk cache/memory cache),证明浏览器缓存生效。

任务 11.2 已完成!
项目现在具备了企业级的路由懒加载能力。所有通过 React.lazy 加载的页面组件都会自动进行代码分割,并通过根布局中的 SuspenseRouteLoading 提供统一、流畅的加载过渡体验。这为后续添加更多页面奠定了坚实的性能基础。


11.3. 错误处理:基础 ErrorBoundary 实现

在 11.1 和 11.2 节中,项目集成了 createBrowserRouter 并实现了路由懒加载。但目前的实现还缺少一个关键环节:错误处理

11.3.1. 分析:路由错误的潜在风险

当前的路由系统存在风险:

  1. 懒加载失败:如果 React.lazyimport('@/MyApp')import('@/pages/Welcome') 时遇到网络错误(例如,对应的 chunk 文件加载失败),应用可能会崩溃或显示一个不友好的原生错误。
  2. 渲染错误:如果 MyAppWelcomePage 组件在渲染过程中抛出 JavaScript 错误,同样可能导致应用白屏。
  3. Loader/Action 错误 (未来):当后续章节为路由添加 loader (数据加载) 或 action (表单处理) 函数后,这些函数执行出错时,也需要有机制来捕获并展示错误信息。

react-router-dom v7 的 createBrowserRouter 通过 errorElement 配置项,提供了一个内置的、声明式的解决方案来处理这些路由相关的错误。

errorElement 的作用是:当路由本身或其子路由在 渲染加载数据 (loader)执行操作 (action) 时发生错误,React Router 会 捕获 这个错误,并 渲染 指定的 errorElement 组件,而不是让整个应用崩溃。

因此,必须在路由配置中(尤其是在根路由层级)提供一个 errorElement

11.3.2. (编码) 创建基础错误边界组件目录与文件

将把路由相关的辅助组件(如错误边界、认证守卫等)统一放在 src/routes/components/ 目录下。

1
2
mkdir -p src/routes/components
touch src/routes/components/error-boundary.tsx

11.3.3. (编码与精讲) 实现基础版 ErrorBoundary

现在,将实现 error-boundary.tsx。这个 基础版本 的目标是:

  • 使用 React Router 提供的钩子获取错误信息。
  • 显示最基本的错误提示(状态码、消息), 依赖任何复杂的 UI 组件或主题变量。

1. 导入依赖:
需要从 react-router-dom 导入 useRouteError(用于获取错误对象)和 isRouteErrorResponse(用于判断错误类型)。

1
2
// src/routes/components/error-boundary.tsx
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';

2. 实现组件逻辑:
组件的核心是调用 useRouteError,然后根据错误类型渲染不同的信息。

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
import { Button, Result } from "antd";
import type { ResultStatusType } from "antd/es/result";
import {
isRouteErrorResponse,
useNavigate,
useRouteError,
} from "react-router-dom";

export default function ErrorBoundary() {
// 1. useRouteError() 获取路由层捕获到的错误对象
const error = useRouteError();
const navigate = useNavigate();
console.error("路由错误:", error); // 在控制台打印详细错误,便于调试
let status = 500;
let statusText = "Internal Server Error";
let message = "发生未知错误";
// 2. isRouteErrorResponse() 判断是否是 Router 抛出的 Response 错误
// (例如 loader 中 throw new Response("Not Found", { status: 404 }))
if (isRouteErrorResponse(error)) {
status = error.status;
statusText = error.statusText;
// error.data 通常包含 loader/action 返回的错误信息
message = error.data?.message || error.statusText;
}
// 3. 判断是否是标准的 JavaScript Error 对象
else if (error instanceof Error) {
statusText = error.name;
message = error.message;
}

return (
<Result
status={status as ResultStatusType}
title={statusText as ResultStatusType}
subTitle={message}
extra={
<Button type="primary" onClick={() => navigate("/")}>
Back Home
</Button>
}
/>
);
}

思考过程与设计决策

  • useRouteError: 这是获取路由错误的 唯一 途径。它只能在 errorElement 指定的组件中使用。
  • 错误类型判断: 区分 isRouteErrorResponseinstanceof Error 很重要,因为它们携带错误信息的方式不同 (error.data vs error.message)。
  • 返回首页链接: 提供一个基本的导航出口。

11.3.4. (编码) 将 ErrorBoundary 配置到根路由

现在,将这个基础版的 ErrorBoundary 应用到 src/routes/index.tsx 的根路由配置中。

文件路径: src/routes/index.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
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { lazy } from 'react';

// 导入根布局 (保持懒加载)
const LazyMyApp = lazy(() => import('@/MyApp'));
// 导入 WelcomePage (保持懒加载)
const LazyWelcomePage = lazy(() => import('@/pages/Welcome'));
// (未来将导入 NotFoundPage)
// const LazyNotFoundPage = lazy(() => import('@/pages/sys/error/Page404'));

// 1. 导入刚刚创建的基础 ErrorBoundary
import ErrorBoundary from './components/error-boundary';

// 路由配置数组 (仍然是 11.2 的结构)
const routes = [
{
path: '/',
Component: LazyMyApp,
// 2. 将 errorElement 指向导入的 ErrorBoundary 组件
errorElement: <ErrorBoundary />,
children: [
{
index: true,
element: <LazyWelcomePage />,
},
],
},
];

// 创建并导出 router 实例
export const router = createBrowserRouter(routes);

集成完成:现在,如果在加载 LazyMyApp 或其任何子路由(如 LazyWelcomePage)时发生错误,或者这些组件在渲染时抛出错误,React Router 会捕获它,并渲染我们刚刚创建的基础 ErrorBoundary 组件,而不是白屏。

任务 11.3 已完成!
项目现在具备了 基础的路由级错误处理能力。虽然 UI 比较简陋,但核心机制已经建立。我们将在后续章节(预计第十二章或之后,待 UI 组件和主题完善)回过头来增强 这个 ErrorBoundary,使其达到企业级框架的视觉和功能水平。


11.4. 可维护性:sections 路由架构

在 11.2 和 11.3 节中,项目成功实现了路由懒加载和基础错误处理,解决了 性能健壮性 的初步问题。然而,当前的路由配置文件 (src/routes/index.tsx) 存在一个巨大的 可维护性 隐患。

11.4.1. 识别可维护性瓶颈:单一配置文件的“上帝模式”

回顾 11.3 节结束时的 src/routes/index.tsx 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/routes/index.tsx (11.3 版本)
// ... imports ...
const routes = [
{
path: '/',
Component: LazyMyApp,
errorElement: <ErrorBoundary />,
children: [
{ index: true, element: <LazyWelcomePage /> },
// ...未来会在这里添加 login, register, dashboard, users, roles, 404...
],
},
];
export const router = createBrowserRouter(routes);

可以预见,随着应用功能的增加,这个 routes 数组将 线性增长。一个真实的企业级后台可能包含几十甚至上百个路由配置,涉及不同的功能模块(认证、仪表盘、系统管理、用户中心、错误处理…)和复杂的嵌套关系。

将所有路由配置集中在 index.tsx一个文件 中,会带来严重的问题:

  1. 文件冗长,难以导航:当文件达到数百甚至上千行时,查找和修改特定路由配置会变得非常耗时且容易出错。
  2. 可读性差:深层嵌套的路由对象会使代码难以理解和推理。
  3. 团队协作冲突:不同功能团队(例如,“认证”团队和“系统管理”团队)需要频繁修改同一个文件,这将导致大量的 Git 合并冲突,降低开发效率。
  4. 违反单一职责原则 (SRP)index.tsx 文件承担了过多的职责,成为了一个无所不包的“上帝文件 (God File)”,难以维护和测试。

11.4.2. 引入架构解决方案:sections 路由拆分

为了解决单一配置文件的可维护性瓶颈,必须引入“关注点分离 (Separation of Concerns)”的设计原则。

大多数项目采用的 src/routes/sections 模式是实践这一原则的优秀架构。

核心思想

  • 根路由文件 (index.tsx) 不再负责 定义具体的页面路由。
  • index.tsx 的职责转变为:
    • 定义应用的 根布局LazyMyApp)和 根错误边界ErrorBoundary)。
    • 组装 (Assemble) 来自不同功能模块(称为 “Sections”)的、独立的路由配置数组。
  • 每一个独立的功能域(例如,认证、仪表盘、公共页面)都在 src/routes/sections/ 目录下拥有 自己的、独立的 路由配置文件,每个文件导出一个 RouteObject[] 数组。

这种架构就像一个模块化的系统,index.tsx 是主板,而各个 sections/*.tsx 文件是可插拔的功能卡。

11.4.3. (编码) 创建 sections 目录结构

首先,在 src/routes/ 目录下创建 sections/ 子目录。

1
mkdir -p src/routes/sections

接下来,根据企业级后台的常见功能域,创建对应的路由配置文件。初期,文件可以只包含骨架:

  1. main.tsx: 用于定义 非功能性公共 的路由,如 404 页面、500 错误页面,或通用入口/占位页面(如 WelcomePage)。
  2. auth.tsx: 专门负责 认证 相关的路由,如 /login, /register, /forgot-password 等。
  3. dashboard.tsx: 负责 核心后台功能 的路由,通常包含仪表盘布局和所有受保护的功能页面。
1
2
3
touch src/routes/sections/main.tsx
touch src/routes/sections/auth.tsx
touch src/routes/sections/dashboard.tsx

11.4.4. (编码) 实现 main.tsx (公共路由)

将之前放在 index.tsx 中的 WelcomePage 路由配置,以及新增一个 404 页面路由,迁移main.tsx 中。

1. 创建 404 页面组件:

1
2
mkdir -p src/pages/sys/error
touch src/pages/sys/error/Page404.tsx

文件路径: src/pages/sys/error/Page404.tsx (基础实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Button, Result } from "antd";
import { useNavigate } from "react-router-dom";

export default function Page404() {
const navigate = useNavigate();
return (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Button type="primary" onClick={() => navigate("/")}>
Back Home
</Button>
}
/>
);
}

2. 实现 main.tsx 路由配置:

文件路径: src/routes/sections/main.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { lazy } from 'react';
import { type RouteObject } from 'react-router-dom';

// 1. 动态导入本 section 相关的页面组件
const LazyWelcomePage = lazy(() => import('@/pages/Welcome'));
const LazyNotFoundPage = lazy(() => import('@/pages/sys/error/Page404'));

// 2. 定义并导出 mainRoutes 数组
export const mainRoutes: RouteObject[] = [
{
// index: true 表示这是父路由 ('/') 下的默认子路由
index: true,
element: <LazyWelcomePage />,
},
{
// path: '*' 是 React Router 用于匹配所有未匹配路径的特殊语法
// 它必须放在路由配置的最末端,以确保优先匹配其他具体路径
path: '*',
element: <LazyNotFoundPage />,
},
// 未来可以添加 /500, /maintenance 等其他公共页面路由
];

思考过程main.tsx 现在清晰地承担了定义“公共路由”的职责。WelcomePage (首页占位) 和 NotFoundPage (404) 都属于公共范畴。

11.4.5. (编码) 实现 auth.tsxdashboard.tsx (占位符)

这两个文件目前还没有实际的页面可以配置,因此暂时导出空数组,但文件的存在本身就定义了清晰的架构扩展点。

文件路径: src/routes/sections/auth.tsx

1
2
3
4
5
6
import { type RouteObject } from 'react-router-dom';

// 认证相关的路由(如 /login, /register)将在第 14 章添加
export const authRoutes: RouteObject[] = [
// 预留位置
];

文件路径: src/routes/sections/dashboard.tsx

1
2
3
4
5
6
import { type RouteObject } from 'react-router-dom';

// 仪表盘相关的路由(如 /dashboard, /system/users)将在后续章节添加
export const dashboardRoutes: RouteObject[] = [
// 预留位置
];

11.4.6. (编码) 重构 index.tsx 为“路由组装器”

最关键的一步:重构 src/routes/index.tsx,移除具体的页面路由定义,转变为只负责 导入组装 各个 sections 的路由数组。

文件路径: src/routes/index.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
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { lazy } from 'react';

// 1. 导入各个 sections 文件导出的路由数组
import { mainRoutes } from './sections/main';
import { authRoutes } from './sections/auth';
import { dashboardRoutes } from './sections/dashboard';

// 2. 导入根布局组件 (保持懒加载)
const LazyMyApp = lazy(() => import('@/MyApp'));
// 3. 导入基础错误边界组件
import ErrorBoundary from './components/error-boundary';

// 4. 定义根路由对象,包含布局、错误处理和空的 children
const rootRoute: RouteObject = {
path: '/',
Component: LazyMyApp,
errorElement: <ErrorBoundary />, // 使用 11.3 创建的基础错误边界
children: [
// 5. (核心) 使用 JavaScript 展开运算符 (...) 组装所有子路由
// 这个 children 数组现在完全由导入的 sections 动态构成
// 注意顺序:auth 和 dashboard 路由优先匹配,
// mainRoutes (包含 index 和 404) 放在最后作为默认和回退
...authRoutes,
...dashboardRoutes,
// ... 未来可以添加更多 sections, e.g., ...userProfileRoutes
...mainRoutes,
],
};

// 6. 创建并导出 router 实例
export const router = createBrowserRouter([
rootRoute,
// 如果有完全独立的顶级路由 (例如不使用 MyApp 布局的 /landing 页面),
// 可以在这里添加更多顶级 RouteObject
]);

架构优势总结

  • 高度内聚:与“认证”相关的所有路由配置都内聚在 auth.tsx 中。
  • 低耦合:修改 auth.tsx 完全不影响 dashboard.tsxindex.tsx (除非修改导出的变量名)。
  • 可读性强index.tsx 现在非常简洁,清晰地展示了应用的顶层结构和包含哪些功能模块。
  • 易于扩展:添加新功能模块(如“设置中心”)只需:
    1. 创建 sections/settings.tsx 并导出 settingsRoutes
    2. index.tsximport 并在 children 中展开 ...settingsRoutes
  • 团队协作友好:不同团队可以安全地并行开发各自负责的 section 文件,合并冲突的风险降至最低。

11.4.7 验证 sections 架构

  1. 确保已创建 src/pages/sys/error/Page404.tsx 文件。
  2. 运行 pnpm dev
  3. 访问 http://localhost:5173/:应该看到 WelcomePage (来自 mainRoutesindex: true)。
  4. 访问 http://localhost:5173/some-random-path:应该看到 NotFoundPage (来自 mainRoutespath: '*')。
  5. 检查网络请求:懒加载依然有效,访问 / 时会加载 Welcome.chunk.js,访问 /random 时会加载 Page404.chunk.js

任务 11.4 已完成!
项目现在拥有了一个 高度可维护、可扩展的企业级路由架构sections 模式为后续的功能迭代和团队协作奠定了坚实的基础。


11.5. 开发者体验:封装路由钩子与 Logo 修正

在 11.4 节中,项目成功建立了 sections 路由架构,解决了可维护性的核心问题。本节将聚焦于提升开发者体验 (DX),通过封装自定义路由钩子,提供更简洁、更符合人体工程学的 API,并修正之前章节遗留的一个小问题。

11.5.1. 修正 Logo 组件:添加导航功能

目前 src/components/logo/Logo.tsx 还是一个纯展示组件(根元素是 <span>),无法点击。现在路由系统已就绪,需要为其添加返回首页的导航功能。

1. (编码) 导入 Link 组件:
打开 src/components/logo/Logo.tsx,导入 react-router-dom<Link> 组件。

1
2
3
4
// src/components/logo/Logo.tsx
import { cn } from "@/utils/cn";
import { Link } from 'react-router-dom'; // <-- 导入 Link
import { Icon } from "../icon/Icon"; // 假设 Icon 在 ../icon/Icon

说明:选用 <Link> 而非 <NavLink>,因为 Logo 通常不需要激活状态样式。

2. (编码) 替换根元素:
将返回的根元素从 <span> 修改为 <Link to="/">

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/components/logo/Logo.tsx
interface Props {
size?: number | string;
className?: string;
}

function Logo({ size = 50, className }: Props) {
return (
// 将根元素修改为 Link,指向首页 '/'
<Link to="/" className={cn(className)}>
<Icon
icon="local:ic-logo-badge"
size={size}
color="var(--colors-palette-primary-default)"
/>
</Link>
);
}

export default Logo;

结果Logo 组件现在具备了完整的导航功能。

11.5.2. 识别 DX 痛点:分散的原生路由钩子

react-router-dom v7 提供了强大的钩子 (Hooks) 来访问路由状态和执行导航操作:

  • useNavigate(): 用于编程式导航 (Maps('/path'), Maps(-1)).
  • useLocation(): 用于获取当前 URL 信息 (location.pathname, location.search).
  • useParams(): 用于获取动态路由参数 (/users/:userId).
  • useSearchParams(): 用于读取和修改 URL 查询参数 (?q=keyword).

react-router-dom v7 提供了 useNavigate, useLocation, useParams, useSearchParams 等钩子,功能强大但 API 分散。在组件中,可能需要同时导入并调用多个,显得较为繁琐。

1
2
3
4
5
6
7
8
9
10
// 示例:组件内可能需要多个导入
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';

function ExampleComponent() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
// ... 使用这些钩子 ...
}

11.5.3. 解决方案:封装聚合的自定义路由钩子

为了提升开发效率和代码一致性,将创建一系列自定义钩子,封装原生钩子,提供更简洁、聚合的 API。这些自定义钩子将统一放在 src/routes/hooks/ 目录下。

同时,在封装过程中,将应用 useMemo 最佳实践,确保钩子返回值的引用稳定性,这对于避免不必要的重渲染和适配未来 React 的编译优化非常重要。

11.5.4. (编码) 创建 hooks 目录与 useRouter 钩子

1. 创建目录:

1
mkdir -p src/routes/hooks

2. 创建 use-router.ts 文件并实现:
封装 useNavigate,提供 push, replace, back 方法。

文件路径: src/routes/hooks/use-router.ts

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
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";

interface NavigationOptions {
/** 是否替换当前历史记录而非新增 */
replace?: boolean;
/** 传递给目标页面的状态数据,可在目标页面通过 useLocation().state 获取 */
state?: unknown;
}

interface ReplaceOptions {
/** 传递给目标页面的状态数据,可在目标页面通过 useLocation().state 获取 */
state?: unknown;
}

/**
* 提供便捷的编程式导航方法。
* 返回的对象及其方法通过 useMemo 保证引用稳定。
*/
export function useRouter() {
const navigate = useNavigate();

const router = useMemo(
() => ({
/** 导航到新页面 (入栈) */
push: (href: string, options?: NavigationOptions) =>
navigate(href, options),
/** 导航到新页面并替换当前历史记录 */
replace: (href: string, options?: ReplaceOptions) =>
navigate(href, { ...options, replace: true }),
/** 返回上一页 */
back: () => navigate(-1),
}),
[navigate],
);

return router;
}

说明:useMemo 能确保当组件重渲染时,从 useRouter 获取的 router 对象不会跟着重新生成,始终是同一个对象。

这对那些依赖 router 对象的 React.memo 子组件或 useEffect 来说至关重要,可以避免它们因对象引用改变而触发不必要的重新渲染或重复执行。

11.5.5. (编码) 创建 usePathname, useParams, useSearchParams 钩子 (Memoized)

继续创建其他钩子,并确保应用 useMemo

1. 创建 use-pathname.ts

文件路径: src/routes/hooks/use-pathname.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';

/**
* 获取当前 URL 的路径名 (pathname)。
* 返回值通过 useMemo 保证引用稳定。
*/
export function usePathname() {
const { pathname } = useLocation();

// 使用 useMemo 确保返回的字符串引用稳定
return useMemo(() => pathname, [pathname]);
}

2. 创建 use-params.ts

文件路径: src/routes/hooks/use-params.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useMemo } from 'react';
import { useParams as _useParams } from 'react-router-dom'; // 导入原始钩子并重命名

/**
* 获取当前路由的动态参数对象。
* 返回值通过 useMemo 保证引用稳定。
*/
export function useParams() {
const params = _useParams(); // 调用原始钩子

// 使用 useMemo 确保返回的 params 对象引用稳定
return useMemo(() => params, [params]);
}

3. 创建 use-search-params.ts (返回 Memoized 元组):

文件路径: src/routes/hooks/use-search-params.ts

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
import { useMemo } from "react";
import { useSearchParams as _useSearchParams } from "react-router-dom"; // 导入原始钩子

/**
* 定义返回类型,确保与原始钩子签名一致
*
* ReturnType<T> 是 TypeScript 内置的工具类型,用于提取函数类型 T 的返回值类型
*
* useSearchParams 返回一个元组: [URLSearchParams, SetURLSearchParams函数]
* [1] 是 TypeScript 的索引访问类型语法,用于获取元组中索引为 1 的元素类型
* 即获取 SetURLSearchParams 函数的类型
*/
type UseSearchParamsReturnType = [
URLSearchParams,
ReturnType<typeof _useSearchParams>[1],
];

/**
* 获取并操作 URL 查询参数。
* 返回一个包含 memoized URLSearchParams 实例和设置函数的元组。
* 返回的元组本身也通过 useMemo 保证引用稳定。
*/
export function useSearchParams(): UseSearchParamsReturnType {
const [searchParams, setSearchParams] = _useSearchParams();

// Memoize searchParams 对象
const memoizedSearchParams = useMemo(() => searchParams, [searchParams]);

// setSearchParams 函数通常是稳定的,但为了确保返回的元组引用稳定,
// 将整个元组包裹在 useMemo 中。
return useMemo(
() => [memoizedSearchParams, setSearchParams],
[memoizedSearchParams, setSearchParams],
);
}

说明:对于 useSearchParams,选择返回与原生钩子一致的 [URLSearchParams, Function] 元组,并通过 useMemo 保证 URLSearchParams 实例和元组本身的引用稳定性。

11.5.6. (编码) 创建 hooks 的入口文件 index.ts

创建一个 “barrel” 文件 (index.ts),统一导出所有自定义钩子,方便开发者导入。

1
touch src/routes/hooks/index.ts

文件路径: src/routes/hooks/index.ts

1
2
3
4
export * from './use-router';
export * from './use-pathname';
export * from './use-params';
export * from './use-search-params';
  1. 这个文件是一个“打包出口”,它将所有分散在不同文件中的自定义钩子(use-router, use-params 等)集中到了一起。
  2. 这样做可以让其他开发者在导入时,只需要从 ./hooks 这一个路径导入所有需要的钩子,而不用去关心每个钩子的具体文件名。
  3. 最终,这让导入语句更简洁,也让项目的代码结构更清晰、更易于维护。

11.5.7. 提升开发者体验

现在,开发者可以更方便地访问路由功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 只需从一处导入所有需要的钩子
import { useRouter, usePathname, useParams, useSearchParams } from '@/routes/hooks';

function ExampleComponent() {
const router = useRouter(); // 获取导航方法
const pathname = usePathname(); // 获取路径
const params = useParams(); // 获取路由参数
const [searchParams, setSearchParams] = useSearchParams(); // 获取查询参数

// 使用 router.push(...), router.back() 等进行导航
// 读取 pathname, params, searchParams
// 使用 setSearchParams(...) 更新查询参数
}

通过封装,代码变得更简洁,导入来源更统一,并且通过 useMemo 的应用,为性能和未来优化奠定了基础。


11.6. 本章小结与代码入库

在本章中,项目完成了从一个静态 React 组件到功能完备的单页应用 (SPA) 导航核心的奠基。通过集成 react-router-dom v7 并实施企业级的架构模式,为后续的功能开发铺平了道路。

核心进展回顾

  1. 路由引擎集成 (11.1):引入了 react-router-dom v7,使用 createBrowserRouter 定义了第一个数据路由,并通过 <RouterProvider> 替换了 main.tsx 的渲染入口,确立了路由驱动的应用架构。
  2. 性能优化 (11.2):识别了静态导入的性能瓶颈,通过 React.lazy 实现了路由级的代码分割。创建了 RouteLoading 组件,并结合 <Suspense><Outlet /> 在根布局 (MyApp.tsx) 中提供了全局、统一的页面加载过渡体验。
  3. 基础错误处理 (11.3):分析了路由错误的潜在风险,实现了基础版的 ErrorBoundary 组件,使用 useRouteError 处理错误,并将其配置到根路由的 errorElement,提升了应用的健壮性。
  4. 可维护性架构 (11.4):为了解决单一配置文件带来的维护性问题,成功实施了 sections 路由拆分架构。创建了 src/routes/sections/ 目录及 main.tsx, auth.tsx, dashboard.tsx 配置文件,并将根路由 index.tsx 重构为职责清晰的“路由组装器”。
  5. 开发者体验 (11.5):修正了 Logo 组件,为其添加了 <Link> 导航功能。封装了一系列自定义路由钩子 (useRouter, usePathname, useParams, useSearchParams),应用了 useMemo 最佳实践,提供了更简洁、引用稳定的 API。

通过本章的工作,Prorise-Admin 现在拥有了一个现代化的、高性能且高度可维护的路由系统基础。


代码入库

将本章的所有成果提交到版本控制系统。

1. 检查代码状态

1
git status

(应包含 package.json, pnpm-lock.yaml, src/main.tsx, src/MyApp.tsx, src/routes/index.tsx, src/routes/components/error-boundary.tsx, src/routes/sections/*, src/routes/hooks/*, src/components/loading/route-loading.tsx, src/components/logo/Logo.tsx, src/pages/Welcome.tsx, src/pages/sys/error/Page404.tsx 等文件的变更。)

2. 暂存所有变更

1
git add .

3. 执行提交

1
git commit -m "feat(routes): integrate react-router v7 with sections architecture"

第十一章已圆满完成! 项目的导航核心已经建立。