第七章 现代 React 导航艺术:React Router 完全精通

第七章: 现代 React 导航艺术:React Router v7 完全精通

摘要: 在我们精通了 Vue Router 的集中式配置路由之后,现在我们将目光投向它在 React 世界中的核心对等物:React Router。然而,v7 版本的 React Router 带来了一次关键的思维升级:它不再是单一的路由库,而是一个“多策略”的导航框架。它提出了三种截然不同的工作模式,允许我们根据项目的复杂度和架构控制需求,做出最恰当的选择。本章的核心使命,就是为您这位经验丰富的 Vue 工程师,精准“翻译” React Router 的核心概念,并直接引领您掌握用于构建现代化 SPA 的最佳模式。


7.0. 前言:迎接 React Router v7 的多策略时代

7.0.1. 核心变革:从“配置清单”到“策略选择”

在 Vue 的世界里,我们早已习惯于在一个 router.ts 文件中,通过一个清晰的 routes 数组来定义整个应用的导航地图——这是一种 “配置清单”式 的心智模型,集中且直观。

React Router v7 则提供了一种更为灵活的范式:策略选择。它不再强制唯一的实现方式,而是将路由能力分解为三种可递进的模式,让开发者从一开始就决定自己需要多大程度的“掌控权”或“便利性”。

这是与 Vue Router 基础功能最直接的对等物。

它完全遵循 React“万物皆组件”的哲学,我们使用 <BrowserRouter> 作为顶层容器,并用 <Routes><Route> 这些 JSX 组件来声明式地构建路由结构。

核心 API: <BrowserRouter>
一句话理解: 纯粹的、运行在浏览器端的客户端路由,负责将 URL 匹配到组件。它就是您熟悉的那个“交通警察”。

1
2
3
4
5
6
7
import { BrowserRouter } from "react-router";

ReactDOM.createRoot(root).render(
<BrowserRouter>
<App />
</BrowserRouter>,
);

这是 React Router 对现代 SPA 数据解决方案给出的答案,也是我们本章的绝对核心。

它通过 createBrowserRouter API 将路由配置从 React 的渲染流程中分离出来,从而解锁了一系列强大的数据处理能力,比如在导航前加载数据的 loader 和处理表单提交的 action

核心 API: createBrowserRouter, RouterProvider
一句话理解: 在“交通警察”的基础上,增加了“数据调度员”的角色,让路由层参与到数据的获取与变更中。

1
2
3
4
5
6
7
8
9
10
11
12
import {
createBrowserRouter,
RouterProvider,
} from "react-router";

const router = createBrowserRouter([
/* route objects */
]);

ReactDOM.createRoot(root).render(
<RouterProvider router={router} />
);

这是 React Router 的终极形态,但已超出了本章 SPA 的范畴。

它通过 Vite 插件将“数据模式”进一步封装,提供了类似 Next.js 或 Remix 的全栈开发体验,包含文件路由、代码分割、多种渲染策略(SSR/SSG)等。

说明: 由于您后续将专门学习 Next.js,我们将 跳过框架模式 的深入探讨,以确保我们的学习路径聚焦于构建最高效的 SPA。

7.0.2. 我们应该选择哪种模式?

面对“选择的暴政”,我们为您提供一条“武断”但绝对正确的路线图:

  • 对于迁移旧项目或只想用最纯粹路由功能的场景声明式模式 是您最熟悉的路径,它能 1:1 “翻译”您在 Vue Router 中的大部分经验。
  • 对于所有 2025 年启动的、有实际数据交互的全新 SPA 项目数据模式 是唯一推荐的最佳实践。它所提供的 loaderaction 范式,能够从架构层面根除 useEffect 数据获取带来的种种弊病,是构建健壮、体验一流应用的基石。

7.0.3. 本章学习地图:从“转译”到“超越”

我们深知您追求的是最高效的知识迁移。因此,本章将遵循一条精心设计的路径:

  1. 基础转译: 我们将从 声明式模式 开始,快速带您过一遍基础的路由定义、导航、参数获取等功能,帮您将在 Vue Router 中已有的知识快速对等到 React Router 中。
  2. 核心进阶: 接着,我们将把全部重心投入到 数据模式。这部分内容将超越简单的“翻译”,向您展示 React Router 在数据流处理上的现代化思考,这套 loader/action 机制将是您 React 工具箱中最锋利的武器之一。
  3. 实战巩固: 最后,我们将结合两大模式,深入探讨嵌套路由、路由守卫、性能优化等高级实战技巧。

准备好了吗?让我们开始这场从 Vue 到 React 的高效路由“转译”之旅。


7.1. 基础构建:搭建你的第一个路由系统

在上一节中,我们明确了 React Router v7 的核心策略。现在,我们将从您最熟悉的领域开始——声明式模式。这个模式下的路由构建方式,可以看作是您在 Vue 中使用 <router-link><router-view> 经验的直接“翻译”。我们的目标是,快速地用 React 的“方言”,搭建起一个基础的、功能完备的单页应用。

7.1.1. 安装与环境设置

前置说明: 我们默认您已经通过 pnpm create vite 创建了一个 React + TypeScript 项目,并遵循了现代化的工程规范。

首先,我们将 react-router-dom 添加到项目中。根据您的偏好,我们统一使用 pnpm

1
pnpm add react-router-dom

安装完成后,最关键的一步,是在你的应用入口文件(通常是 src/main.tsx)中,用 BrowserRouter 组件包裹你的根组件 <App />

文件路径: src/main.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

// 1. 从 react-router-dom 导入 BrowserRouter
import { BrowserRouter } from 'react-router-dom'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
深度解析
2025-10-01

<BrowserRouter> 包裹 <App /> 的操作,和 Vue 里的 app.use(router) 有什么异同?

思想上是完全一致的。它们都是为了将路由实例的能力“注入”到整个应用中。

不同点在于实现方式。Vue 通过插件机制 (app.use) 将路由能力混入到所有组件实例中。而 React 则利用其核心的 Context API,<BrowserRouter> 本质上是一个 Context Provider,它将路由信息(如当前 location、导航函数等)放到了一个全局可访问的上下文中,任何被它包裹的子组件都可以通过 Hooks (如 useNavigate) 从这个上下文中获取所需的功能。

明白了,所以 React 的方式更符合其“显式”和“组合”的哲学。

7.1.2. 创建你的第一个路由表

声明式模式 中,我们使用 JSX 来定义路由。这与 Vue 的配置式方法形成了鲜明对比,但逻辑上是等价的。

  • <Routes>: 相当于 Vue Router 配置中的 routes: [...] 数组,它是一个容器,包裹了所有的路由规则。
  • <Route>: 相当于数组中的每一个路由对象 { path, component }。它通过 path 属性匹配 URL,通过 element 属性指定要渲染的组件。

让我们在 App.tsx 中定义几条最基础的路由。

文件路径: src/App.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
// 1. 导入路由组件和我们的页面组件
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'
import Products from './pages/Products'

function App() {
return (
<>
{/* 在这里可以放置全局共享的导航栏等组件 */}
<nav>{/* ...导航链接... */}</nav>

{/* 2. Routes 组件包裹所有路由规则 */}
<Routes>
{/* 3. 每条 Route 规则定义一个页面 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
</Routes>
</>
)
}

export default App

为了让示例能跑起来,我们需要创建对应的页面文件。

文件路径: src/pages/Home.tsx

1
2
3
4
const Home = () => {
return <h2>首页</h2>;
};
export default Home;

文件路径: src/pages/About.tsx

1
2
3
4
const About = () => {
return <h2>关于我们</h2>;
};
export default About;

文件路径: src/pages/Products.tsx

1
2
3
4
const Products = () => {
return <h2>产品列表</h2>;
};
export default Products;

现在我们有了页面,但如何跳转呢?答案是使用 <Link><NavLink> 组件。

知识转译: 这两个组件共同扮演了 Vue 中 <router-link> 的角色。

  • <Link>: 最基础的导航组件。它会被渲染成一个 <a> 标签,但它会拦截默认的页面刷新行为,改为在客户端进行路由跳转。它的核心属性是 to,用于指定目标路径。
  • <NavLink>: 是 <Link> 的一个特殊版本,它“知道”自己所指向的路由是否处于“激活”状态。当 URL 与其 to 属性匹配时,它会自动添加一个 active 类。这对于导航菜单的高亮显示非常有用。

让我们在 App.tsx 中添加一个真正的导航栏。

文件路径: src/App.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
// 1. 导入路由组件和我们的页面组件
import { Routes, Route, NavLink } from "react-router-dom";
import Home from "./pages/Home";
import About from "./pages/About";
import Products from "./pages/Products";

function App() {
return (
<>
{/* 在这里可以放置全局共享的导航栏等组件 */}
<nav className="flex gap-4 p-4">
{/* 使用 NavLink 替代普通的 a 标签 */}
<NavLink to="/">首页</NavLink>
<NavLink to="/products">产品</NavLink>
<NavLink to="/about">关于我们</NavLink>
</nav>

{/* 2. Routes 组件包裹所有路由规则 */}
<main className="p-4">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
</Routes>
</main>
</>
);
}

export default App;

为了让激活状态可见,我们需要添加一些简单的 CSS。

文件路径: src/index.css

1
2
3
4
5
6
/* 当 NavLink 激活时,React Router 会自动为其添加 .active 类 */
nav a.active {
color: crimson;
font-weight: bold;
text-decoration: underline;
}

NavLink 还允许我们通过函数形式的 classNamestyle 属性,更精细地控制激活状态的样式,这与 Vue Router 3+ 的 v-slot API 思路非常相似。

1
2
3
4
5
6
7
8
<NavLink
to="/messages"
className={({ isActive, isPending }) => // isPending 仅在数据模式下可用
isActive ? "text-red-500 font-bold" : "text-blue-500"
}
>
Messages
</NavLink>

7.1.4. 视图渲染出口:<Outlet /> 的核心作用

知识转译: Outlet 组件完全等同于 Vue 中的 <router-view>

它的作用是作为子路由的渲染占位符。当我们在后续章节学习嵌套路由时,<Outlet /> 的重要性将完全展现出来。现在,我们可以利用它来重构我们的应用,创建一个共享的布局(Layout)组件。

7.1.5. 实战演练:使用 Layout 组件重构应用

一个专业的应用,通常页面结构是共享的,比如都有相同的页头和页脚。让我们遵循最佳实践,从一开始就建立良好的代码组织。

第一步:创建项目结构

1
2
3
4
5
6
7
8
9
# src/
├── components/
│ └── AppLayout.tsx # <-- 我们的共享布局组件
├── pages/
│ ├── About.tsx
│ ├── Home.tsx
│ └── Products.tsx
├── App.tsx
└── main.tsx

第二步:创建 AppLayout 组件

这个组件包含了共享的导航栏,以及用于渲染具体页面内容的 <Outlet />

文件路径: src/components/AppLayout.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
import { NavLink, Outlet } from "react-router-dom";

const AppLayout = () => {
return (
<div>
<header>
<nav className="flex gap-4 p-4 border-b">
<NavLink to="/">首页</NavLink>
<NavLink to="/products">产品</NavLink>
<NavLink to="/about">关于我们</NavLink>
</nav>
</header>
<main className="p-4">
{/* 关键:这里会根据当前的 URL,渲染匹配到的子路由组件 */}
<Outlet />
</main>
<footer className="p-4 border-t mt-4">
<p>© 2025 Prorise. All rights reserved.</p>
</footer>
</div>
);
};

export default AppLayout;

第三步:在 App.tsx 中使用新布局

我们将 App.tsx 的路由配置,改造为嵌套路由结构。

文件路径: src/App.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
import { Routes, Route } from 'react-router-dom'

// 导入布局和页面
import AppLayout from './components/AppLayout'
import Home from './pages/Home'
import About from './pages/About'
import Products from './pages/Products'

function App() {
return (
<Routes>
{/* 创建一个父路由,路径为根路径 "/"。
它的 element 是我们的共享布局 AppLayout。
*/}
<Route path="/" element={<AppLayout />}>
{/* 这里是嵌套的子路由。
它们的 path 是相对于父路由的。
当 URL 匹配时,它们对应的 element 会被渲染到父路由 AppLayout 的 <Outlet /> 中。
"index" 属性表示这是父路由下的默认子路由。
*/}
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="products" element={<Products />} />
</Route>
</Routes>
)
}

export default App

本节小结

我们已经成功地使用 声明式模式 搭建起了一个结构清晰、可维护的 SPA 基础。通过与 Vue Router 的类比,我们快速建立了核心概念的映射:

Vue Router 概念React Router 对等物核心作用
app.use(router)<BrowserRouter>启用全应用路由能力
routes: [...]<Routes>路由规则的容器
{ path, component }<Route path element>定义单条路由规则
<router-link><Link> / <NavLink>声明式、客户端导航
active-class.active (由 <NavLink> 自动添加)激活状态样式
<router-view><Outlet />子路由渲染出口

现在,我们的应用有了坚实的“骨架”。


7.2. 路由参数与编程式控制

7.1 节中,我们初步体验了 JSX 路由。现在,我们将进行一次重要的重构,以引入一种更专业、更贴近 Vue 开发者习惯的 中心化路由管理 模式。这将为我们后续学习 数据模式 打下坚实的基础。

我们将创建一个专门的文件来管理所有路由规则,并简化所有组件的逻辑,剔除加载、重试等干扰因素,只保留核心的路由交互和必要的边界情况处理(如“未找到 ID”)。

为了让后续示例能够顺利运行,请确保您的项目中已安装并配置好 Ant Design。

1. 安装依赖

在您的项目根目录下执行:

1
pnpm add antd

2. 在入口文件 main.tsx 中配置全局提供者
这是最关键的一步。我们需要在 7.1 节创建的 main.tsx 文件中,<BrowserRouter> 的外层,使用 Ant Design 的 <ConfigProvider><AntdApp> 组件进行包裹。

文件路径: src/main.tsx (基于 7.1 节进行修改)

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 React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { BrowserRouter } from 'react-router-dom';

// 1. 从 antd 导入所需组件和语言包
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* 2. 使用 ConfigProvider 包裹所有内容,以提供全局配置 */}
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#00b96b',
},
}}
>
{/* 3. 使用 AntdApp 包裹,以便在组件中通过 hook 调用 message, notification 等静态方法 */}
<AntdApp>
{/* 4. 我们的 BrowserRouter 放在最内层 */}
<BrowserRouter>
<App />
</BrowserRouter>
</AntdApp>
</ConfigProvider>
</React.StrictMode>
);

完成以上配置后,您的应用就已经具备了 Ant Design 的全局能力,并且后续代码中可以直接使用 message.success 等 API。

7.2.1. 最佳实践:创建中心化路由配置文件

知识转译: 我们现在要做的,就是创建 React 世界里的 router.ts

我们将使用 createBrowserRouter 这个官方推荐的 API。它接收一个路由对象数组,其结构与 Vue Router 的 routes 数组非常相似。

第一步:创建 router/index.tsx 文件

文件路径: src/router/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
37
38
39
import { createBrowserRouter } from 'react-router-dom';

// 导入所有布局和页面组件
import AppLayout from '../components/AppLayout';
import Home from '../pages/Home';
import About from '../pages/About';
import Products from '../pages/Products';
import ProductDetail from '../pages/ProductDetail';

// 使用 createBrowserRouter 创建路由实例
const router = createBrowserRouter([
{
// 父路由,使用 AppLayout 作为布局
path: '/',
element: <AppLayout />,
// children 数组定义了所有嵌套在该布局下的子路由
children: [
{
index: true, // index: true 表示这是默认子路由
element: <Home />,
},
{
path: 'about',
element: <About />,
},
{
path: 'products',
element: <Products />,
},
{
// 核心:在这里定义动态路由
path: 'products/:productId',
element: <ProductDetail />,
},
],
},
]);

export default router;

第二步:改造 main.tsxApp.tsx

现在,入口文件 main.tsx 不再使用 <BrowserRouter>,而是使用 <RouterProvider> 来“提供”我们刚刚创建的路由实例。

文件路径: src/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
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider } from 'react-router-dom';

import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
// 1. 导入我们在 router/index.tsx 中创建的路由配置实例
import router from './router';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#00b96b',
},
}}
>
<AntdApp>
{/* 2. 使用 RouterProvider 组件并传入 router 实例
这是 React Router v6.4+ 推荐的路由配置方式
RouterProvider 会根据当前 URL 自动渲染匹配的路由组件
相比传统的 BrowserRouter + Routes 方式,它支持更多高级特性如数据加载、错误边界等
*/}
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
</React.StrictMode>
);

因为所有路由逻辑都已移出,App.tsx 现在不再需要,我们可以直接删除它,或者将其改造成其他用途。项目的入口和路由渲染完全由 main.tsxrouter/index.tsx 控制。

7.2.2. 读取动态参数:useParams()

知识转译: useParams() Hook 是 Vue 中 route.params 对象的直接对等物,他专门用于获取类似于 Product/: id 这样的路径 id 用于操作每一个不同的页面数据

现在路由已配置好,我们来编写 ProductDetail 组件,它将使用 useParams 来获取 URL 中的 productId

文件路径: src/pages/ProductDetail.tsx (新建/修改)

img

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 { useParams } from "react-router-dom";
import { Card, Descriptions, Alert } from "antd";

// 1.模拟产品数据,通过Record即可定义一个键类型上应该会有怎样的属性
const mockProducts: Record<
string,
{ name: string; category: string; price: string }
> = {
"1": { name: "高性能笔记本电脑", category: "电子产品", price: "¥8999" },
"2": { name: "人体工学办公椅", category: "家具", price: "¥2499" },
};

const ProductDetail = () => {
const { productId } = useParams<{ productId: string }>(); // 直接解构并提供类型提示

// 2.直接访问,使用 Record 类型定义后不需要类型断言
const product = productId ? mockProducts[productId] : undefined;

if (!product) {
return <Alert message="产品不存在" type="error" />;
}

return (
<Card title={`产品详情:${product.name}`}>
<Descriptions bordered>
{/* 我们可以在模板对象中消费productid,他会精确的匹配到URL中的路径数据 */}
<Descriptions.Item label="产品ID">{productId}</Descriptions.Item>
<Descriptions.Item label="名称">{product.name}</Descriptions.Item>
<Descriptions.Item label="分类">{product.category}</Descriptions.Item>
<Descriptions.Item label="价格">{product.price}</Descriptions.Item>
</Descriptions>
</Card>
);
};

export default ProductDetail;

7.2.3. 处理查询字符串:useSearchParams()

知识转译: useSearchParams() 不仅等同于 Vue 的 route.query (用于读取),它还提供了一个 setter 函数,这让它更像一个与 URL 查询字符串同步的 useState

我们来为 Products 列表页增加排序功能。

文件路径: src/pages/Products.tsx (修改)

img

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
import { Link, useSearchParams } from "react-router-dom";
import { List, Radio, Card, type RadioChangeEvent } from "antd";

const allProducts = [
{ id: "1", name: "高性能笔记本电脑", price: 8999 },
{ id: "2", name: "人体工学办公椅", price: 2499 },
{ id: "3", name: "机械键盘", price: 799 },
];

const Products = () => {
// 1. 调用 useSearchParams 获取 searchParams 和 setSearchParams
const [searchParams, setSearchParams] = useSearchParams();
// 2. 读取 'sort' 参数,若不存在则默认为 'default'
const sortOrder = searchParams.get("sort") || "default";

// 3. 根据排序参数处理数据
const sortedProducts = [...allProducts].sort((a, b) => {
if (sortOrder === "default") return 0;
return sortOrder === "price-asc" ? a.price - b.price : b.price - a.price;
});

const handleSortChange = (e: RadioChangeEvent) => {
const newSortOrder = e.target.value;
// 4. 调用 setSearchParams 更新 URL 查询字符串,这会触发页面刷新
setSearchParams({ sort: newSortOrder });
};

return (
<Card title="产品列表">
<div className="mb-4">
<Radio.Group onChange={handleSortChange} value={sortOrder}>
<Radio.Button value="default">默认排序</Radio.Button>
<Radio.Button value="price-asc">价格升序</Radio.Button>
<Radio.Button value="price-desc">价格降序</Radio.Button>
</Radio.Group>
</div>

<List
bordered
dataSource={sortedProducts}
renderItem={(item) => (
<List.Item>
{/* 5.列表项链接到我们刚刚创建的动态路由 */}
<Link to={`/products/${item.id}`}>{item.name}</Link> - ¥{item.price}
</List.Item>
)}
/>
</Card>
);
};

export default Products;

7.2.4. 命令式导航:useNavigate()

知识转译: useNavigate() Hook 是 Vue 中 router.push()router.replace() 等编程式导航 API 的集合体。

场景: 在产品详情页 ProductDetail.tsx 添加一个“返回列表”和“后退”的按钮。

文件路径: src/pages/ProductDetail.tsx (修改)

img

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 { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Alert, Button, Space } from 'antd';

// ... mockProducts 定义 ...

const ProductDetail = () => {
const navigate = useNavigate(); // 获取 navigate 函数
const { productId } = useParams<{ productId: string }>();
const product = productId ? mockProducts[productId] : undefined;

if (!product) {
// ...
}

return (
<Card
title={`产品详情:${product.name}`}
extra={
<Space>
<Button onClick={() => navigate('/products')}>返回列表</Button>
<Button onClick={() => navigate(-1)}>后退</Button>
</Space>
}
>
<Descriptions bordered>
<Descriptions.Item label="产品ID">{productId}</Descriptions.Item>
<Descriptions.Item label="名称">{product.name}</Descriptions.Item>
<Descriptions.Item label="分类">{product.category}</Descriptions.Item>
<Descriptions.Item label="价格">{product.price}</Descriptions.Item>
</Descriptions>
</Card>
);
};

export default ProductDetail;

本节小结

通过本次重构,我们不仅掌握了处理动态路由的核心 Hooks,更重要的是,我们建立了一套更专业、可维护的中心化路由管理方案。

需求场景Vue Router 方案React Router 对等物 (v7 推荐)
路由配置router.ts 内的 routes 数组router/index.tsx 内的 createBrowserRouter
读取 URL 动态段route.paramsuseParams()
读取/修改查询参数route.query / router.pushuseSearchParams()
编程式跳转/后退router.push / router.backuseNavigate()

这套新结构不仅让您感觉更亲切,也为我们下一阶段学习更强大的 数据模式 铺平了道路。


7.3. 布局与 UI 结构化:嵌套路由

7.2 节中,我们通过创建中心化的路由配置文件,极大地提升了项目的可维护性。您可能已经注意到,在 router/index.tsx 中,我们已经使用了 children 属性,将所有页面都嵌套在了 AppLayout 之下。这其实就是嵌套路由的初步应用。

本节,我们将深入探讨这一强大功能,通过构建一个更真实、多层级的“用户中心”场景,来彻底掌握如何使用嵌套路由构建复杂且优雅的应用布局。

知识转译: React Router 中路由对象的 children 属性,与 Vue Router 路由配置中的 children 数组,其思想和功用是完全一致的。它们都是用来描述一个路由内嵌于另一个路由之中的父子关系。

7.3.1. 场景分析:构建多层嵌套的用户中心

想象一下,我们的应用需要一个 /dashboard 路径,作为所有用户相关页面的入口。这个“用户中心”本身有自己独特的布局,比如一个侧边栏导航,包含“个人资料”、“我的订单”等链接。同时,整个用户中心又需要复用全局的页头和页脚(即我们之前创建的 AppLayout)。

这就构成了一个典型的二级嵌套结构:

  1. 第一层嵌套: DashboardLayout (用户中心布局) 渲染在 AppLayout<Outlet /> 中。
  2. 第二层嵌套: Profile (个人资料) 或 Orders (我的订单) 页面,渲染在 DashboardLayout<Outlet /> 中。

image-20251002154821717

7.3.2. 实现方案:children 属性与 <Outlet /> 的组合

让我们通过编码来实现这个场景。

第一步:创建用户中心相关的组件

我们需要一个新的布局组件和两个新的页面组件。

文件路径: src/pages/dashboard/DashboardLayout.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
import { Outlet, NavLink } from 'react-router-dom';
import { Layout, Menu } from 'antd';

const { Sider, Content } = Layout;

// 模拟侧边栏导航项
const menuItems = [
{ key: '/dashboard', label: <NavLink to="/dashboard">主页</NavLink> },
{ key: '/dashboard/orders', label: <NavLink to="/dashboard/orders">我的订单</NavLink> },
];

const DashboardLayout = () => {
return (
<Layout style={{ minHeight: 'calc(100vh - 134px)' /* 减去全局头尾大致高度 */ }}>
<Sider width={200}>
<Menu
mode="inline"
defaultSelectedKeys={['/dashboard']}
style={{ height: '100%', borderRight: 0 }}
items={menuItems}
/>
</Sider>
<Layout style={{ padding: '24px' }}>
<Content>
{/* 关键:这是用户中心内部的 Outlet,用于渲染 Profile, Orders 等子页面 */}
<Outlet />
</Content>
</Layout>
</Layout>
);
};

export default DashboardLayout;

文件路径: src/pages/dashboard/Orders.tsx (新建文件)

1
2
3
4
5
6
7
8
9
10
11
import { Card } from 'antd';

const Orders = () => {
return (
<Card title="我的订单">
<p>这里是您的订单列表。</p>
</Card>
);
};

export default Orders;

第二步:更新中心化路由配置

现在,我们修改 router/index.tsx,将新的路由规则添加进去。

文件路径: src/router/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
// ... 其他 imports
import DashboardLayout from '../pages/dashboard/DashboardLayout';
import Orders from '../pages/dashboard/Orders';

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ... Home, About, Products 路由保持不变
// 新增:用户中心的路由配置
{
path: 'dashboard',
element: <DashboardLayout />, // 使用新的布局
children: [
// 这里定义了 /dashboard 的子路由
{
path: 'orders', // -> 匹配 /dashboard/orders
element: <Orders />,
},
],
},
],
},
]);

export default router;

7.3.3. 索引路由 (index Route):父路由下的默认子页面

完成了上面的配置后,我们访问 /dashboard/orders 页面可以正常显示。但如果我们只访问父路径 /dashboard,会发现右侧内容区是空白的。这是因为没有任何子路由匹配 /dashboard 这个确切的路径。

我们需要一个“默认子路由”。在 React Router 中,这通过 index 属性来实现。

image-20251002155453435

知识转译: index: true 与 Vue Router 中 { path: '', component: ... } 的作用完全相同,都是为了定义父路由下的默认显示内容,我们可以采取 index: true 也可以采取空 path,两种方法都可以,但我们更为推荐更具有语义化的 index: true

第一步:创建仪表盘主页组件

文件路径: src/pages/dashboard/DashboardHome.tsx (新建文件)

1
2
3
4
5
6
7
8
9
10
11
import { Card } from 'antd';

const DashboardHome = () => {
return (
<Card title="欢迎回到您的用户中心">
<p>请在左侧选择一个菜单项以继续。</p>
</Card>
);
};

export default DashboardHome;

第二步:在路由配置中添加索引路由

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 其他 imports
import DashboardHome from '../pages/dashboard/DashboardHome';

// ...
{
path: 'dashboard',
element: <DashboardLayout />,
children: [
// 新增:索引路由
{
index: true, // 当 URL 为 /dashboard 时,渲染此组件
element: <DashboardHome />,
},
{
path: 'orders',
element: <Orders />,
},
],
},
// ...

现在,再次访问 /dashboardDashboardHome 组件就会被正确地渲染到 DashboardLayout<Outlet /> 中。

7.3.4. “未找到”页面:使用 path="*"

最后,我们需要处理用户访问不存在的路径时的场景。一个健壮的应用应该向用户展示一个清晰的 “404 Not Found” 页面,而不是一个空白页。

这通过一个特殊的“捕获所有” (catch-all) 路由来实现,其路径为 *

image-20251002155350076

知识转译: path: "*" 的概念和用法与 Vue Router 中的 path: "/:pathMatch(.*)*" 几乎一致。

第一步:创建 NotFound 页面组件

我们可以利用 Ant Design 的 <Result> 组件来快速创建一个美观的 404 页面。

文件路径: src/pages/NotFound.tsx (新建文件)

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

const NotFound = () => {
const navigate = useNavigate();
return (
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在。"
extra={
<Button type="primary" onClick={() => navigate('/')}>
返回首页
</Button>
}
/>
);
};

export default NotFound;

第二步:在路由配置中添加捕获所有路由

重要: 捕获所有路由 (path: "*") 必须放在路由配置数组的 最末尾。路由匹配是自上而下进行的,如果把它放在前面,它会拦截所有路径,导致其他路由失效。

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 其他 imports
import NotFound from '../pages/NotFound';

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ... 所有正常页面的路由配置
],
},
// 在所有主要路由规则之后,添加一个顶级的捕获所有路由
// 注意:它不应该嵌套在 AppLayout 内,因为它是一个独立的错误页
{
path: '*',
element: <NotFound />,
},
]);

export default router;

现在,访问任何未定义的路径,例如 /this/path/does/not/exist,应用都会优雅地展示我们创建的 404 页面。


本节小结

我们通过一个实际的用户中心案例,深入掌握了嵌套路由的全部核心概念,构建出了一个结构清晰、可扩展的应用骨架。

  • 多层嵌套: 通过在路由对象中嵌套 children 数组,并结合各级布局中的 <Outlet /> 组件,可以实现任意深度的布局嵌套。
  • 索引路由: 使用 index: true 属性,为父路由指定一个默认显示的子页面。
  • 404 页面: 在路由配置的末尾添加 { path: "*", ... } 规则,可以捕获所有未匹配的 URL,提供友好的用户反馈。

7.4. 【核心】数据流革命 (上):使用 loader 读取数据

我们即将进入 React Router 最具变革性的领域:数据加载。为了让您真正体会到 loader 带来的颠覆性优势,我们将遵循一个两步走的实战路径:

  1. 亲历痛点: 首先,我们将使用您最熟悉的 useEffect 模式,从一个真实的公共 API (dummyjson.com) 获取数据,完整地构建一个产品详情页。
  2. 见证奇迹: 然后,我们将引入 loader,用它彻底重构刚才的页面,亲眼见证代码是如何变得极致简洁和强大。

7.4.1. 亲历痛点:使用 useEffect 构建数据获取组件

场景: 我们需要在产品详情页 (/products/:productId) 中,根据 URL 的 id,从 https://dummyjson.com/products/:id 接口获取并展示产品数据。

第一步:在路由中注册一个“无 loader”的路由

我们暂时只定义路径和组件,不添加任何 loader

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
// 确保已导入 ProductDetail 组件
import ProductDetail from '../pages/ProductDetail';

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ... 其他路由
{
// 暂时只定义 path 和 element
path: 'products/:productId',
element: <ProductDetail />,
},
],
},
// ...
]);

第二步:使用 useEffect 编写“传统”数据获取组件

现在,我们来编写 ProductDetail 组件。请仔细体会这个过程中我们需要手动处理的每一个细节。

文件路径: src/pages/ProductDetail.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
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
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Descriptions, Spin, Alert } from 'antd';

// 为API返回的数据定义类型
interface Product {
id: number;
title: string;
category: string;
price: number;
description: string;
}

const ProductDetail = () => {
const { productId } = useParams<{ productId: string }>();

// 1. 手动定义三个状态:数据、加载、错误
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
// 2. 在 Effect 中定义并执行异步函数
const fetchProduct = async () => {
// 每次 productId 变化时,重置状态
setLoading(true);
setError(null);
try {
const response = await fetch(`https://dummyjson.com/products/${productId}`);
// 必须手动检查响应是否成功
if (!response.ok) {
throw new Error(`产品信息获取失败,状态码: ${response.status}`);
}
const data = await response.json();
setProduct(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};

if (productId) {
fetchProduct();
}
}, [productId]); // 依赖 productId,当它变化时重新执行

// 3. 根据状态进行条件渲染
if (loading) {
return <div className="text-center p-8"><Spin tip="产品数据加载中..." size="large" /></div>;
}

if (error) {
return <Alert message="加载错误" description={error} type="error" showIcon />;
}

if (!product) {
return <Alert message="未找到产品数据。" type="warning" />;
}

return (
<Card title={product.title}>
<Descriptions bordered layout="vertical">
<Descriptions.Item label="产品ID">{product.id}</Descriptions.Item>
<Descriptions.Item label="分类">{product.category}</Descriptions.Item>
<Descriptions.Item label="价格">${product.price}</Descriptions.Item>
<Descriptions.Item label="描述" span={3}>{product.description}</Descriptions.Item>
</Descriptions>
</Card>
);
};

export default ProductDetail;

小结: 请运行项目并访问 /products/1。功能是完善的,但请回顾我们编写的代码:超过 50 行,手动管理了 3 个状态,编写了 try/catch/finally,并需要时刻注意 useEffect 的依赖项。这就是我们需要解决的“痛点”。

7.4.2. 见证奇迹:使用 loaderuseLoaderData 重构

现在,让我们见证 数据模式 的威力。

第一步:为路由添加 loader 函数

我们回到 router/index.tsx,将刚才在 useEffect 中编写的数据获取逻辑,直接“搬运”到 loader 中。

文件路径: src/router/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 ProductDetail from '../pages/ProductDetail';

// 1. 将数据获取逻辑定义为 loader
export const productDetailLoader = async ({ params }: { params: any }) => {
const { productId } = params;
const response = await fetch(`https://dummyjson.com/products/${productId}`);

// 2. 如果请求失败,可以直接抛出一个 Response
// React Router 的错误处理机制会自动捕获它
if (!response.ok) {
throw new Response("Product Not Found", { status: 404, statusText: "未找到产品" });
}

// 3. 成功后,直接返回解析后的数据
return response.json();
};

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ...
{
path: 'products/:productId',
element: <ProductDetail />,
// 4. 将 loader 关联到路由
loader: productDetailLoader,
},
],
},
// ...
]);

export default router;

第二步:用 useLoaderData 彻底简化组件

现在,ProductDetail 组件的使命变得无比纯粹:它不再需要关心如何、何时获取数据,只需消费 loader 准备好的“现成饭菜”。

文件路径: src/pages/ProductDetail.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
import { useLoaderData } from 'react-router-dom';
import { Card, Descriptions } from 'antd';
import { type productDetailLoader } from '../router'; // 导入 loader 以获取类型

// 使用 TypeScript 的高级类型推断,自动获得 loader 返回的数据类型
type Product = Awaited<ReturnType<typeof productDetailLoader>>;

const ProductDetail = () => {
// 奇迹发生的地方:
// 1. 没有 useState
// 2. 没有 useEffect
// 3. 没有 loading 和 error 状态
// 4. 只需一行代码,数据已在组件渲染前准备就绪
const product = useLoaderData() as Product;

return (
<Card title={product.title}>
<Descriptions bordered layout="vertical">
<Descriptions.Item label="产品ID">{product.id}</Descriptions.Item>
<Descriptions.Item label="分类">{product.category}</Descriptions.Item>
<Descriptions.Item label="价格">${product.price}</Descriptions.Item>
<Descriptions.Item label="描述" span={3}>{product.description}</Descriptions.Item>
</Descriptions>
</Card>
);
};

export default ProductDetail;

对比一下:我们的组件代码从 50+ 行锐减到不足 20 行,所有数据获取的复杂性都被优雅地移除了。这,就是 loader 的力量。

7.4.3. 原理初探:从瀑布流到并行数据获取

loader 带来的不仅是代码的简化,还有性能的提升。

  • useEffect 模式: 浏览器渲染父组件 -> 父组件 useEffect 请求数据 -> 渲染子组件 -> 子组件 useEffect 请求数据… 这是一个串行的 瀑布流
  • loader 模式: 导航发生时,React Router 会分析出目标 URL 将匹配的所有路由(父、子、孙…),然后找到它们各自的 loader 函数,并通过 Promise.all 并行触发 所有这些数据请求。

这意味着,所有层级的数据会同时开始加载,大大缩短了用户的总等待时间。


本节小结

通过一次亲身实践的重构,我们深刻体会到了 loader 模式的颠覆性优势:

  • 关注点分离: 组件回归渲染本质,数据获取逻辑归于路由配置。
  • 代码量锐减: 无需再为每个组件编写 useStateuseEffect 的数据获取样板代码。
  • 体验与性能提升: 数据在渲染前就已就绪,且可并行加载,用户能更快看到完整页面。
  • 内置错误处理: loader 中抛出的错误可以被路由层统一捕获,无需在组件内 try/catch

7.5. 【核心】数据流革命 (下):使用 action 变更数据

知识转译: 如果说 loader 是对 onMounted/useEffect 数据获取的革命,那么 action 就是对传统 @submit.prevent + axios.post + 手动更新状态这一整套“表单提交三部曲”的颠覆。

7.5.1. 亲历痛点:传统 React 表单处理的繁琐

在引入 action 之前,让我们先快速回顾一下在 React 中处理表单提交的“标准流程”,以便更深刻地体会 action 带来的便利。

场景: 我们需要创建一个 /products/new 页面,允许用户添加一个新产品。

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
// 这是一个不使用 action 的"传统"表单组件
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Form, Input, Button, Card, App } from "antd";

const TraditionalNewProduct = () => {
const navigate = useNavigate();
const { message } = App.useApp(); // 使用 useApp hook 获取 message API
const [submitting, setSubmitting] = useState(false);
const [formErrors, setFormErrors] = useState<{ title?: string }>({});

const handleSubmit = async (values: { title: string }) => {
// 1. 手动处理提交状态
setSubmitting(true);
setFormErrors({});

console.log("表单提交的值:", values);

// 2. 客户端表单验证
if (!values.title) {
setFormErrors({ title: "产品标题不能为空" });
setSubmitting(false);
return;
}

// 3. 手动调用 fetch/axios 发送请求
try {
const response = await fetch("https://dummyjson.com/products/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});

if (!response.ok) throw new Error("提交失败");

const newProduct = await response.json();
message.success(`产品 "${newProduct.title}" 创建成功!`);

// 4. 手动处理成功后的跳转
navigate("/products");
} catch (error) {
message.error("创建产品失败,请重试");
} finally {
setSubmitting(false);
}
};

return (
<Card title="创建新产品 (传统方式)">
{/* antd 的 Form 组件帮助我们管理表单状态 */}
{/*
注意:antd 的 Form.Item 需要配合 name 属性才能收集表单数据
Input 组件的 name 属性在这里不起作用
*/}
<Form onFinish={handleSubmit}>
<Form.Item
label="产品标题"
name="title"
validateStatus={formErrors.title ? "error" : ""}
help={formErrors.title}
>
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
创建
</Button>
</Form.Item>
</Form>
</Card>
);
};

export default TraditionalNewProduct;

小结: 上述代码逻辑清晰,但暴露了几个核心痛点:

  • 逻辑耦合: 数据验证、API 请求、状态更新、页面跳转等逻辑全部耦合在组件内部。
  • 状态繁多: 需要手动管理 submittingformErrors 等多个 UI 状态。
  • 代码冗余: 每个需要提交表单的组件,几乎都要重复这套逻辑。

7.5.2. 新范式 action:在路由层处理数据变更

action 函数与 loader 师出同门,它的核心思想是:将处理数据变更(创建、更新、删除)的逻辑,也从组件中提升到路由配置层。

当 React Router 的 <Form> 组件提交时,它不会触发页面刷新,而是会将表单数据打包成一个 Request 对象,发送给匹配路由的 action 函数进行处理。

第一步:在路由中定义 action 函数

文件路径: src/router/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
37
38
39
40
41
42
43
44
45
46
47
48
49
import { createBrowserRouter, redirect } from 'react-router-dom';
// ...
import NewProduct from '../pages/NewProduct'; // 导入我们将要创建的新组件

// 1. 定义 action 函数,它和 loader 一样,在路由配置中
// 它接收一个包含 request 对象的参数
export const createProductAction = async ({ request }: { request: Request }) => {
// 2. request.formData() 可以方便地解析出表单数据
const formData = await request.formData();
const title = formData.get('title') as string;

// 3. 在 action 中执行服务端/API 逻辑 (包括验证)
if (!title || title.trim().length < 3) {
// 如果验证失败,返回一个包含错误信息的对象
// 这个对象可以被组件中的 useActionData() 捕获
return { error: '产品标题必须至少包含3个字符' };
}

// 模拟 API 调用
await fetch('https://dummyjson.com/products/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

// 4. 操作成功后,使用 redirect() 工具函数进行页面跳转
// 这比在组件中使用 useNavigate() 更优雅
return redirect('/products');
};


const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ...
{
path: 'products/new',
element: <NewProduct />,
action: createProductAction, // 5. 将 action 关联到路由
},
// ...
],
},
// ...
]);

export default router;

7.5.3. <Form>useActionData:简化组件交互

现在,我们来创建全新的 NewProduct 组件,看看它在 action 的加持下能变得多简单。

  • <Form>: React Router 提供的 Form 组件,它会自动将提交请求指向当前路由的 action。我们不再需要 onSubmite.preventDefault()
  • useActionData: 这个 Hook 用来获取 action 函数的返回值。如果 action 返回了验证错误,我们就可以通过它拿到错误信息并展示在 UI 上。

文件路径: src/pages/NewProduct.tsx (新建文件)

注意: React Router 和我们使用的 Ant Design 有一个很明显的表单收集的坑,这里一定要关注一下代码里的 name 属性应该加在那个组件上,如果不确定,一定要都加

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 { Form as RouterForm, useActionData } from "react-router-dom";
import { Form as AntdForm, Input, Button, Card } from "antd";

const NewProduct = () => {
const actionData = useActionData() as { error: string } | undefined;

return (
<Card title="创建新产品 (Action 模式)">
{/*
问题所在:React Router 的 Form 组件会直接收集原生表单元素的数据
而 Ant Design 的 Form.Item 只是一个装饰器组件,不是真正的表单元素
Input 组件必须有 name 属性才能被收集
*/}
<RouterForm method="post">
<AntdForm.Item
label="产品标题"
validateStatus={actionData?.error ? "error" : ""}
help={actionData?.error}
// 这个 name RouterForm 无效它只用于 AntdForm 的数据管理
>
{/* !!!!!必须在 Input 上添加 name 属性,RouterForm 才能收集到数据 */}
<Input name="title" placeholder="请输入至少3个字符" />
</AntdForm.Item>
<AntdForm.Item>
{/* 这个 Button 只需要是 type="submit" */}
<Button type="primary" htmlType="submit">
创建
</Button>
</AntdForm.Item>
</RouterForm>
</Card>
);
};

export default NewProduct;

再次对比:这个新组件几乎没有任何自己的“逻辑”。它只负责定义表单结构和展示 action 返回的错误信息。验证、API 调用、成功跳转、提交状态管理……所有这些复杂性都被 action<Form> 优雅地封装了。

深度解析:数据流的闭环
2025-10-02

我注意到 action 成功后,返回产品列表页时,列表数据会自动更新(如果我们真的添加了的话),这是为什么?

问得好!这正是 数据模式 最强大的特性之一:自动数据再验证 (Automatic Revalidation)

当一个 action 成功执行后(即没有抛出错误且返回了 redirect 或其他数据),React Router 会 自动重新调用当前页面上所有 loader 函数,以确保 UI 展示的是最新鲜的数据。

所以,loader (读) -> <Form> (写) -> action (处理) -> redirect (导航) -> 重新调用 loader (更新视图),形成了一个完美的数据流闭环。您再也不需要手动去刷新或更新列表状态了。


7.5.4. 最佳实践:解耦 Loader 与 Action

到目前为止,我们的 loaderaction 函数都直接写在了 router/index.tsx 文件里。当项目简单时,这很直观。但随着路由和业务逻辑的增多,这个文件会迅速变得臃肿不堪,违背了“关注点分离”的原则。

真正的最佳实践是 将数据逻辑 (loader/action) 与其服务的组件放在一起。这被称为“代码共置” (Co-location)。

现在,我们来对项目进行一次重要的专业化重构。

第一步:将 loader 移动到 ProductDetail 页面

文件路径: src/pages/ProductDetail.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
import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'; // 导入 LoaderFunctionArgs 类型
import { Card, Descriptions } from 'antd';

// 1. 将 loader 函数从 router/index.tsx 剪切到这里
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { productId } = params;
const response = await fetch(`https://dummyjson.com/products/${productId}`);
if (!response.ok) {
throw new Response("Product Not Found", { status: 404, statusText: "未找到产品" });
}
return response.json();
};

// 使用 Awaited 和 ReturnType 来自动推断 loader 的返回类型
type Product = Awaited<ReturnType<typeof loader>>;

const ProductDetail = () => {
const product = useLoaderData() as Product;

return (
<Card title={product.title}>
{/* ... Card 内容不变 ... */}
</Card>
);
};

export default ProductDetail;

第二步:将 action 移动到 NewProduct 页面

文件路径: src/pages/NewProduct.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
import { Form as RouterForm, useActionData, redirect, type ActionFunctionArgs } from 'react-router-dom';
import { Form as AntdForm, Input, Button, Card } from 'antd';

// 1. 将 action 函数从 router/index.tsx 剪切到这里
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const title = formData.get('title') as string;

if (!title || title.trim().length < 3) {
return { error: '产品标题必须至少包含3个字符' };
}

await fetch('https://dummyjson.com/products/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

return redirect('/products');
};

const NewProduct = () => {
const actionData = useActionData() as { error: string } | undefined;

return (
<Card title="创建新产品 (Action 模式)">
{/* ... Form 内容不变 ... */}
</Card>
);
};

export default NewProduct;

第三步:清理并更新 router/index.tsx

现在,我们的路由配置文件变得前所未有的清爽。它只负责“配置”和“组装”,不再包含任何具体的业务逻辑实现。

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

// 导入布局和页面
import AppLayout from '../components/AppLayout';
import Home from '../pages/Home';
import Products from '../pages/Products';
import NewProduct, { action as createProductAction } from '../pages/NewProduct'; // 1. 从页面导入 action
import ProductDetail, { loader as productDetailLoader } from '../pages/ProductDetail'; // 2. 从页面导入 loader
// ... 其他 imports

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ...
{
path: 'products/new',
element: <NewProduct />,
action: createProductAction, // 3. 引用导入的 action
},
{
path: 'products/:productId',
element: <ProductDetail />,
loader: productDetailLoader, // 4. 引用导入的 loader
},
// ...
],
},
// ...
]);

export default router;
  • *.tsx (页面组件文件): 同时负责自身的 UI 渲染数据逻辑 (loader/action)。高内聚!
  • router/index.tsx: 只负责 路由结构的定义逻辑的组装。低耦合!这才是专业且可扩展的 React Router 项目结构。

本节小结

我们通过引入 action 模式,并结合“代码共置”的最佳实践,完成了数据流革命的最后一块拼图。现在,我们拥有了一套功能完备、高度解耦且极其强大的数据处理范式。

需求场景传统 React 方案React Router 数据模式 (最佳实践)
数据读取逻辑写在组件 useEffect与组件共置,定义为 loader 函数
数据变更逻辑写在组件 onSubmit与组件共置,定义为 action 函数
路由配置-在中心化的 router/index.tsx 中导入并组装 loaderaction
变更后反馈手动 useState 管理错误useActionData
成功后跳转useNavigateredirect() from action
变更后数据同步手动刷新或更新状态自动再验证

7.6. 提升用户体验的利器

我们已经掌握了 loaderaction 这两大核心,它们构建了应用数据流的“主干道”。然而,一个优秀的应用,不仅要功能正确,更要体验流畅。本节,我们将学习 React Router 提供的三个“UX 神器”,它们能极大地提升用户在数据交互过程中的感知体验。

7.6.1. 感知全局状态:useNavigation 与全局 Loading Bar

痛点: 当用户点击链接或提交表单后,如果 loaderaction 需要执行耗时操作(如复杂的网络请求),页面会有一段时间“毫无反应”。用户不知道是应用卡死了还是正在加载,这种不确定性会带来焦虑。

解决方案: useNavigation Hook。这个 Hook 能让你在应用的任何地方,实时监控全局的路由状态。

核心思想: useNavigation 返回一个 navigation 对象,其 state 属性有三种可能的值:

  • 'idle': 空闲状态,当前无任何导航或数据加载。
  • 'loading': 导航正在进行中,并且下一个页面的 loader 正在被调用。
  • 'submitting': 表单正在提交中,某个路由的 action 正在被调用。

对于实现一个全局加载指示器,我们只需要关心 navigation.state 是否为 'idle'

实战示例: 在我们的主布局 AppLayout 中添加一个全局进度条。

文件路径: src/components/AppLayout.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
import { Outlet, NavLink } from "react-router-dom";
import { Layout, Menu, Spin } from "antd";
import { useNavigation } from "react-router-dom"; // 1. 导入 useNavigation

const { Header, Content, Footer } = Layout;

const AppLayout = () => {
const navigation = useNavigation(); // 2. 调用 hook 获取导航状态
const isLoading = navigation.state !== "idle"; // 3. 判断是否处于加载/提交状态

return (
<Layout>
<Header>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
<Menu.Item key="1">
<NavLink to="/">首页</NavLink>
</Menu.Item>
<Menu.Item key="2">
<NavLink to="/products">产品</NavLink>
</Menu.Item>
<Menu.Item key="3">
<NavLink to="/about">关于我们</NavLink>
</Menu.Item>
<Menu.Item key="4">
<NavLink to="/products/1">产品详情</NavLink>
</Menu.Item>
<Menu.Item key="5">
<NavLink to="/dashboard">用户中心</NavLink>
</Menu.Item>
</Menu>
</Header>
<Content style={{ padding: "24px", minHeight: 280 }}>
{/* 4. 如果正在加载,显示全局 loading 效果 */}
<Spin spinning={isLoading} size="large" tip="加载中...">
{/* 关键:这里会根据当前的 URL,渲染匹配到的子路由组件 */}
<Outlet />
</Spin>
</Content>
<Footer style={{ textAlign: "center" }}>
© 2025 Prorise. All rights reserved.
</Footer>
</Layout>
);
};

export default AppLayout;

现在,当您从首页点击导航到“慢加载页面”时,例如我们之前调用了网络 API 的页面,会立刻在页面中心看到一个加载图标,为用户提供了即时的操作反馈。

img


7.6.2. 页面“微交互”:useFetcher

痛点: 很多时候,我们需要与后端进行数据交互,但并不希望因此改变 URL 或刷新整个页面。典型的例子包括:点赞一篇文章、将商品加入购物车、提交一个订阅邮箱等。使用 <Form> 会触发导航和全局 loader 的再验证,对于这些“微交互”来说,成本太高。

解决方案: useFetcher Hook。它就像一个“迷你版”的 <Form>,可以让你在幕后调用任何路由的 loaderaction,而不会引起任何 URL 变化。

知识转译: useFetcher 可以理解为 React Router 内置的、与数据流(action/loader)深度集成的 axiosfetch 客户端。

实战示例: 在页面任意位置放置一个通讯订阅表单。

第一步:为“资源路由”创建独立的 action 文件

我们的 /subscribe 路由没有对应的页面组件,它只提供一个数据处理的端点。对于这类“资源路由”,最佳实践是为其逻辑创建一个独立的文件,而不是污染全局的 router/index.tsx

文件路径: src/actions/subscribe.ts (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { type ActionFunctionArgs } from 'react-router-dom';

// 将 action 逻辑封装在自己的模块中
export const subscribeAction = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get('email');
console.log(`收到了订阅请求: ${email}`);

// 模拟 API 延迟
await new Promise(r => setTimeout(r, 1000));

// 简单验证
if (!email || !String(email).includes('@')) {
return { ok: false, message: '无效的邮箱地址!' };
}
return { ok: true, message: '订阅成功,感谢!' };
};

第二步:在路由配置中“组装”资源路由

现在,我们的 router/index.tsx 变得非常干净。它只负责导入并“注册”这个 action,而不关心其内部实现。

文件路径: src/router/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
import { createBrowserRouter } from 'react-router-dom';
import { subscribeAction } from '../actions/subscribe'; // 1. 从专用模块导入 action

// ... 其他路由和 loader/action 的导入

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
// ... 所有的页面路由
],
},
// 2. 将资源路由作为顶级路由进行注册
{
path: '/subscribe',
action: subscribeAction, // 3. 关联导入的 action
},
{
path: '*',
element: <NotFound />,
},
]);

export default router;

第三步:创建使用 fetcher 的组件

NewsletterForm 组件的代码 完全不需要改变。它只关心 action="/subscribe" 这个端点,而完全不关心这个端点背后的逻辑是如何组织的。这正是关注点分离带来的好处。

文件路径: src/components/NewsletterForm.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
48
import { useFetcher } from "react-router-dom";
import { Input, Button, Alert } from "antd";

const NewsletterForm = () => {
// useFetcher 使用说明:
// 1. useFetcher 用于在不导航的情况下与服务器交互(提交表单、加载数据等)
// 2. fetcher.state 表示请求状态:idle(空闲)、submitting(提交中)、loading(加载中)
// 3. fetcher.data 包含服务器返回的数据(action 或 loader 的返回值)
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
const actionData = fetcher.data as
| { ok: boolean; message: string }
| undefined;

return (
<div style={{ padding: 24, border: "1px solid #eee", marginTop: 24 }}>
<h3>订阅我们的通讯</h3>
{/* fetcher.Form 的工作方式保持不变 */}
<fetcher.Form method="post" action="/subscribe">
<Input
name="email"
type="email"
placeholder="请输入您的邮箱"
required
/>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitting}
style={{ marginTop: 8 }}
>
{isSubmitting ? "提交中..." : "订阅"}
</Button>
</fetcher.Form>

{actionData && (
<Alert
message={actionData.message}
type={actionData.ok ? "success" : "error"}
showIcon
style={{ marginTop: 16 }}
/>
)}
</div>
);
};

export default NewsletterForm;

img


7.6.3. 滚动行为管理:<ScrollRestoration>

痛点: 在单页应用中,当你从一个很长的列表页(已滚动到页面中间)导航到详情页,然后再点击浏览器的“后退”按钮时,你常常会回到列表页的顶部,而不是你离开时的位置。这不符合原生网页的体验。

解决方案: <ScrollRestoration> 组件。这是一个“即插即用”的组件,只需在应用中渲染一次,它就会自动为你处理滚动位置的保存和恢复。

实战示例: 在主布局中添加滚动恢复功能。

文件路径: src/components/AppLayout.tsx (添加一行代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Outlet, NavLink, ScrollRestoration } from 'react-router-dom'; // 1. 导入 ScrollRestoration
// ... 其他 imports 和组件代码

const AppLayout = () => {
// ... useNavigation hook 和其他逻辑
return (
<Layout>
{/* ... Header, Content with Outlet, Footer ... */}

{/* 2. 在应用的根布局的某个地方渲染它即可,通常放在末尾 */}
<ScrollRestoration />
</Layout>
);
};

export default AppLayout;

就这样!无需任何额外配置,您的应用现在就拥有了媲美传统多页应用的滚动历史记录功能。


本节小结

我们学习了三个极具价值的 UX 增强工具,它们能让我们的数据驱动应用变得更加完善和贴心:

  • useNavigation: 通过捕获全局导航状态,为用户提供即时的加载反馈(如 Loading Bar)。
  • useFetcher: 在不引起页面跳转的前提下,与 actionloader 交互,是实现点赞、收藏等“微交互”的不二之选。
  • <ScrollRestoration>: 仅需一行代码,即可为应用带来浏览器级的滚动位置恢复体验。

7.7. 健壮性与安全:错误处理和路由守卫

一个生产级的应用,不仅要在正常情况下工作,更要在异常情况下表现得体。React Router 的 数据模式 提供了一套与数据流深度集成的、声明式的错误处理和访问控制方案,远比传统的 try/catchuseEffect 判断更优雅、更强大。

7.7.1. 优雅降级:errorElementuseRouteError

痛点: 在 7.4 节的 loader 示例中,如果 fetch 失败(例如,网络错误或服务器返回 404),loader 会抛出一个错误。默认情况下,这个未被捕获的错误会导致整个应用崩溃,白屏。我们需要一个机制来捕获这些错误,并向用户展示一个友好的错误页面。

解决方案: 在路由对象上声明一个 errorElement。当该路由的 loader, action 或组件渲染过程中抛出任何错误时,React Router 会自动捕获它,并渲染你在 errorElement 中指定的组件,而不是原来的 element

第一步:创建一个通用的错误页面组件
这个组件将使用 useRouteError Hook 来获取被抛出的具体错误信息。

文件路径: src/pages/ErrorPage.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
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom';
import { Result, Button } from 'antd';

const ErrorPage = () => {
const error = useRouteError(); // 1. 获取路由层捕获的错误
const navigate = useNavigate();

let status = 500;
let title = '出错了';
let subTitle = '抱歉,应用发生了一个未知错误。';

// 2. isRouteErrorResponse 可以判断错误是否是 loader/action 中 throw new Response() 产生的
if (isRouteErrorResponse(error)) {
status = error.status;
title = error.statusText;
subTitle = error.data;
}
// 也可以判断其他类型的错误
else if (error instanceof Error) {
title = error.name;
subTitle = error.message;
}

return (
<Result
status={status as any}
title={title}
subTitle={subTitle}
extra={
<Button type="primary" onClick={() => navigate('/')}>
返回首页
</Button>
}
/>
);
};

export default ErrorPage;
深度解析:useRouteError 返回什么?
2025-10-03

useRouteError 的文档说它返回 unknown 类型,这是为什么?

这是一个很好的 TypeScript 实践。因为在 JavaScript 中,你可以 throw "一个字符串"throw 123throw new Error(),甚至 throw new Response()throw 后面可以跟任何东西。

所以 useRouteError 无法预知会捕获到什么类型的错误,返回 unknown 是最类型安全的。这强制我们必须在使用前,先对 error 进行类型检查,比如使用 isRouteErrorResponseinstanceof Error,从而避免运行时错误。

明白了,unknown 是在促使我们编写更健壮的代码。

第二步:在路由配置中应用 errorElement
我们可以为单个路由配置 errorElement,也可以为父路由配置,它将捕获所有子路由中未被处理的错误,形成“错误边界”。

文件路径: src/router/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
// ...
import { productDetailLoader } from '../pages/ProductDetail';
import ErrorPage from '../pages/ErrorPage'; // 1. 导入错误页面

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorPage />, // 2. 为根路由配置一个全局的错误边界
children: [
// ...
{
path: 'products/:productId',
element: <ProductDetail />,
loader: productDetailLoader,
// 3. (可选) 也可以在这里配置一个更具体的错误页,它会覆盖父级的配置
// errorElement: <ProductErrorPage />,
},
// ...
],
},
// ...
]);

export default router;

现在,再次访问一个不存在的产品 ID(例如 /products/999),我们的 productDetailLoaderthrow new Response("...404...")。这个错误会被 React Router 捕获,然后渲染我们在根路由上配置的 <ErrorPage />,优雅地向用户展示了 404 页面,而不是整个应用白屏。

image-20251003092507222

7.7.2. 访问控制:使用 loader 实现路由守卫

痛点: 应用中的某些页面,如“用户中心”,只应在用户登录后才能访问。我们需要一种机制,在用户进入该页面前进行权限检查,如果未登录,则强制跳转到登录页。

解决方案: 在 loader 中实现权限校验。

知识转译: 这与 Vue Router 的 beforeEach 全局导航守卫在 目标 上是一致的,但在 实现 上截然不同。Vue 的守卫是命令式的、全局拦截的;而 React Router 的方案是声明式的、与需要保护的路由直接绑定。loader 方案的巨大优势在于,权限检查和页面所需数据的加载可以合并在同一个函数中,并且它在组件渲染前执行,能彻底避免“页面内容闪现”的问题。

实战示例: 保护我们的 /dashboard 路由。

第一步:创建一个模拟的认证工具模块

文件路径: src/utils/auth.ts (新建文件)

1
2
3
4
5
6
// 这是一个极其简化的模拟认证模块
export const auth = {
isLoggedIn: () => localStorage.getItem('isLoggedIn') === 'true',
login: () => localStorage.setItem('isLoggedIn', 'true'),
logout: () => localStorage.removeItem('isLoggedIn'),
};

第二步:创建一个受保护路由的 loader

文件路径: src/pages/dashboard/DashboardLayout.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Outlet, NavLink, redirect } from 'react-router-dom'; // 1. 导入 redirect
import { auth } from '../../utils/auth'; // 2. 导入认证工具

// 3. 为 DashboardLayout 创建一个 loader
export const loader = () => {
// 4. 在 loader 中进行权限检查
if (!auth.isLoggedIn()) {
// 5. 如果未登录,直接抛出一个 redirect
// React Router 会捕获它并执行跳转
return redirect('/login');
}
// 6. 如果已登录,可以返回该页面所需的数据,比如用户信息
// 如果没有数据要返回,返回 null 或一个空对象即可
return { user: { name: 'Prorise' } };
};

// ... DashboardLayout 组件代码保持不变

第三步:创建登录页并配置路由

文件路径: src/pages/Login.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
import { Form as RouterForm, redirect } from 'react-router-dom';
import { Card, Form, Input, Button } from 'antd';
import { auth } from '../utils/auth';

// 登录页的 action
export const action = async () => {
auth.login(); // 模拟登录
return redirect('/dashboard'); // 登录成功后跳转到仪表盘
};

const LoginPage = () => (
<Card title="请登录">
<RouterForm method="post">
<Form.Item label="用户名">
<Input name="username" defaultValue="admin" />
</Form.Item>
<Form.Item label="密码">
<Input.Password name="password" defaultValue="password" />
</Form.Item>
<Button type="primary" htmlType="submit">登录</Button>
</RouterForm>
</Card>
);

export default LoginPage;

第四步:更新路由配置,应用守卫

文件路径: src/router/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
// ...
import DashboardLayout, { loader as dashboardLoader } from '../pages/dashboard/DashboardLayout';
import LoginPage, { action as loginAction } from '../pages/Login';

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorPage />,
children: [
// ...
{
path: 'dashboard',
element: <DashboardLayout />,
loader: dashboardLoader, // <-- 将 loader 应用到路由上,路由守卫生效!
children: [
// ...
],
},
{
path: 'login',
element: <LoginPage />,
action: loginAction,
},
// ...
],
},
// ...
]);

现在,当一个未登录的用户尝试访问 /dashboard 或其任何子页面时,dashboardLoader 会被触发,检查到未登录状态后 throw redirect('/login'),用户将被无缝地、在看到任何仪表盘内容之前,就被重定向到了登录页面。

img


本节小结

我们为应用构建了两道至关重要的防线,极大地提升了其健壮性和安全性。

  • 错误处理: 通过在路由对象上配置 errorElement,我们可以捕获 loaderaction 或渲染中抛出的错误,并使用 useRouteError 在一个专门的错误组件中优雅地展示错误信息。
  • 路由守卫: 利用 loader 在组件渲染前执行的特性,我们可以在其中加入权限校验逻辑。如果校验失败,通过抛出 redirect() 即可实现一个无页面闪烁、与数据加载逻辑合二为一的现代化路由守卫。

7.8. 性能优化专题

随着我们的应用功能日益丰富,所有页面的代码都打包在一个 main.js 文件中,会导致初始加载体积越来越大,影响用户的首次访问速度。代码分割 (Code Splitting) 是解决这个问题的关键技术。本节,我们将学习 React Router 中实现代码分割的最佳实践。

7.8.1. 路由级代码分割:使用 lazy 属性

知识转译: Vue Router 中实现懒加载非常直接:component: () => import('./About.vue')。React Router 的 lazy 属性在目标上是相同的——按需加载路由,但在能力上更为强大。它不仅能懒加载组件,还能同时懒加载与该路由关联的 loaderactionErrorBoundary 等所有逻辑。

深度解析:Route `lazy` vs. React.lazy()
2025-10-04

为什么我们不直接用 React 自带的 React.lazy()Suspense 呢?

这是一个非常好的问题。React.lazy() 只能懒加载组件本身。在我们的 数据模式 下,这意味着:我们需要先加载路由配置。然后导航到某页面,触发 React.lazy 开始下载组件代码。在组件代码下载完之后,开始渲染。组件渲染后,loader 的数据还不存在,因为 loader 和组件是分离的。

这就破坏了 loader 在组件渲染前加载数据的核心优势。

而路由的 lazy 属性是 一体化 的。当导航发生时,React Router 会 同时并行 地去请求组件代码块和执行 loader 的数据请求。当两者都完成后,再用获取到的数据渲染组件。这避免了“代码-数据”的瀑布流,是性能上的最优解。

实战示例: 对我们的“产品详情页”进行代码分割。

第一步:将路由模块的逻辑提取到专用文件中
最佳实践是,将一个懒加载路由的所有相关导出(loader, Component 等)放在同一个文件中。

文件路径: src/pages/ProductDetail.tsx (修改)

注意: 使用懒加载时,必须导出为 “loader” 而不是自定义名称

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
import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom';
import { Card, Descriptions } from 'antd';

// 1. 将 loader 保持为具名导出
// 注意:使用懒加载时,必须导出为 "loader" 而不是自定义名称
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { productId } = params;
const response = await fetch(`https://dummyjson.com/products/${productId}`);
if (!response.ok) {
throw new Response("Product Not Found", { status: 404, statusText: "未找到产品" });
}
return response.json();
};

type Product = Awaited<ReturnType<typeof loader>>;

// 2. 关键:将原来的 default export 改为具名导出 `Component`
// React Router 的 lazy API 会自动寻找这些特定名称的导出
export function Component() {
const product = useLoaderData() as Product;

return (
<Card title={product.title}>
{/* ... Card 内容不变 ... */}
</Card>
);
};

// (可选) 也可以在这里导出 ErrorBoundary, action 等
// export function ErrorBoundary() { ... }

注意: 懒加载的模块 不应该有 default 导出。React Router 会根据 loader, action, Component, ErrorBoundary 等固定的具名导出来识别和加载对应的功能。

第二步:在路由配置中用 lazy 替换 loaderelement

现在,我们来修改中心化的路由配置,告诉 React Router 如何去懒加载这个模块。

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

// 导入布局和非懒加载的页面
import AppLayout from '../components/AppLayout';
import Home from '../pages/Home';
// ...

// 不再需要直接导入 ProductDetail 组件或它的 loader 了
// import ProductDetail, { loader as productDetailLoader } from '../pages/ProductDetail';

const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
// ...
children: [
// ...
{
path: 'products/:productId',
// 1. 移除 element 和 loader 属性
// element: <ProductDetail />,
// loader: productDetailLoader,

// 2. 添加 lazy 属性,其值为一个调用动态导入的函数
lazy: () => import('../pages/ProductDetail'),
},
// ...
],
},
// ...
]);

export default router;

完成! 现在,当应用初始加载时,ProductDetail 页面的所有代码(包括其 loader 逻辑)都不会被包含在主包里。只有当用户第一次导航到 /products/1 这样的路径时,React Router 才会去下载对应的代码块,并执行其 loader,然后渲染 Component

您可以打开浏览器的“网络”面板,切换路由,亲眼见证新的 JS chunk 文件是如何被按需加载的。


本节小结

我们掌握了 React Router 数据模式 下进行性能优化的核心武器:

  • lazy 路由属性: 它是 React.lazy() 在路由场景下的超集替代品,可以同时懒加载与路由相关的 所有逻辑(Component, loader, action 等)。
  • 并行加载: lazy 模式能够确保组件代码和 loader 数据并行获取,最大化地提升了懒加载路由的性能。
  • 约定优于配置: 懒加载的模块遵循固定的 具名导出export function Component, export const loader),简化了配置。

7.9. 本章核心总结与展望

恭喜您!我们已经完成了对现代 React Router (v7 SPA 模式) 的深度探索。从基础的 JSX 路由,到中心化的路由配置,再到革命性的 loaderaction 数据流,我们不仅“翻译”了您在 Vue 世界中熟悉的路由概念,更掌握了一套构建高性能、高健壮性、高体验的现代化单页应用的强大范式。

7.9.1. 核心模式回顾:数据模式 的架构思想

回顾整个学习过程,我们最终沉淀出了一套专业、可扩展的 SPA 路由架构:

  1. 中心化配置: 我们使用 createBrowserRouter 在一个独立的 router/index.tsx 文件中定义整个应用的路由结构。这让路由的宏观视图保持清晰,易于管理。
  2. 代码共置 (Co-location): 我们将与特定路由强相关的业务逻辑——loader(数据读取)和 action(数据写入)——与该路由渲染的组件放在同一个文件中。这实现了“高内聚、低耦合”,极大地提升了代码的可维护性。
  3. 数据流闭环: 我们掌握了由 loader -> Component -> <Form> -> action -> redirect/revalidation -> loader 构成的自动化数据流闭环。这个模式从根本上消除了传统 useEffect 数据获取带来的种种复杂性。

这套架构思想,是本章最宝贵的财富,它将是您未来构建任何复杂 React 应用的坚实地基。

7.9.2. 核心 API 速查表

分类关键项核心描述
路由设置createBrowserRouter(推荐) 创建一个支持 数据模式 的路由实例,接收一个路由对象数组。
RouterProvider用于在应用中“提供”由 createBrowserRouter 创建的路由实例。
Route Object在路由数组中定义路由规则的对象,包含 path, element, children, loader, action, errorElement, lazy 等。
核心组件<Link> / <NavLink>声明式客户端导航组件,NavLink 额外支持激活状态。
<Outlet />嵌套路由的渲染出口,等同于 Vue 的 <router-view>
<Form>声明式表单组件,自动将提交导向路由的 action
<ScrollRestoration>即插即用的滚动位置恢复组件。
数据钩子useLoaderData()在组件中获取当前路由 loader 函数返回的数据。
useActionData()获取最近一次 action 提交的返回值,常用于表单错误提示。
useFetcher()在不触发导航的情况下,调用任意路由的 loaderaction
导航钩子useNavigate()获取编程式导航函数,等同于 Vue 的 router.push/replace/back
useParams()获取 URL 中的动态段参数(如 :id),等同于 Vue 的 route.params
useSearchParams()获取并操作 URL 查询字符串,等同于 Vue 的 route.query + setter
UX 钩子useNavigation()获取全局导航状态 (idle, loading, submitting),用于实现加载指示器。
useRouteError()errorElement 组件中获取路由层捕获的错误信息。

7.9.3. 高频面试题精选

面试官深度追问
2025-10-04

React Router v7 的 loader 模型相比传统的 useEffect 数据获取,解决了哪些核心痛点?

它主要解决了四大痛点:首先,简化了组件逻辑,将数据获取的关注点从组件中分离,不再需要手动管理 loading、error 等状态。其次,提升了性能,通过并行请求数据避免了 useEffect 模式下的“请求瀑布流”。再次,改善了用户体验,数据在组件渲染前就已准备好,消除了加载状态导致的页面内容闪烁。最后,增强了健壮性loader 中抛出的错误可以被路由的 errorElement 统一捕获,实现了声明式的错误边界。

很好。那 useFetcher 和标准的 <Form> 提交有什么区别?你会在什么场景下选择使用 useFetcher

它们的核心区别在于是否会触发 页面导航。标准的 <Form> 提交是一个完整的导航事件,URL 可能会改变,并且会触发全局 loader 的“再验证”,适用于创建、更新等需要刷新整个页面数据的场景。而 useFetcher 则是在“幕后”调用 actionloader不会改变 URL,也不会触发全局导航状态。因此,它非常适合那些“微交互”场景,比如:给文章点赞、将商品加入购物车、订阅邮件列表等。这些操作只需要更新一小部分数据,而不需要整个页面跳转或刷新。

7.9.4. 知识拓展:“API 遗珠”一览

我们的学习路径聚焦于构建 SPA 的核心。然而,React Router 还提供了许多用于处理特定场景的工具。当您在未来遇到更复杂的需求时,可以从这些“遗珠”中寻找答案:

  • useBlocker / usePrompt: 用于在用户离开有未保存修改的页面时,弹出确认提示,阻止导航。
  • createHashRouter: createBrowserRouter 的替代品,使用 URL 的 hash 部分进行路由(/#/path),用于不支持 History API 的旧环境。
  • useMatches: 一个高级钩子,可以返回当前匹配的所有路由对象数组。常用于根据路由元信息(handle 属性)动态生成面包屑导航。
  • shouldRevalidate: 在路由对象上定义一个函数,用于精细化控制 action 提交后,哪些 loader 需要被重新执行,以优化性能。
  • Await / useAsyncValue: 用于处理 loader 中返回的流式响应 (deferred data) 的高级特性,可以让页面的部分内容先显示出来。

7.9.5. 展望:通往全栈之路

恭喜您!您已完全掌握了 React Router 在 SPA 领域的最佳实践。

更令人兴奋的是,您今天学到的 loader, action, Form数据模式 的核心思想,并不仅仅局限于客户端。它们正是 Remix、Next.js 等现代 React 全栈框架的基石。在这些框架中,您的 loaderaction 函数可以直接在服务端运行,无缝对接数据库和后端服务。

您今天的学习,已为您未来进入 React 全栈开发的世界,铺平了最坚实的道路。