第八章 现代 React 导航艺术:React Router 完全精通
第八章 现代 React 导航艺术:React Router 完全精通
Prorise第八章: 现代 React 导航艺术:React Router v7 完全精通
摘要: 在我们精通了 Vue Router 的集中式配置路由之后,现在我们将目光投向它在 React 世界中的核心对等物:React Router。然而,v7 版本的 React Router 带来了一次关键的思维升级:它不再是单一的路由库,而是一个“多策略”的导航框架。它提出了三种截然不同的工作模式,允许我们根据项目的复杂度和架构控制需求,做出最恰当的选择。本章的核心使命,就是为您这位经验丰富的 Vue 工程师,精准“翻译” React Router 的核心概念,并直接引领您掌握用于构建现代化 SPA 的最佳模式。
8.0. 前言:迎接 React Router v7 的多策略时代
8.0.1. 核心变革:从“配置清单”到“策略选择”
在 Vue 的世界里,我们早已习惯于在一个 router.ts 文件中,通过一个清晰的 routes 数组来定义整个应用的导航地图——这是一种 “配置清单”式 的心智模型,集中且直观。
React Router v7 则提供了一种更为灵活的范式:策略选择。它不再强制唯一的实现方式,而是将路由能力分解为三种可递进的模式,让开发者从一开始就决定自己需要多大程度的“掌控权”或“便利性”。
这是与 Vue Router 基础功能最直接的对等物。
它完全遵循 React“万物皆组件”的哲学,我们使用 <BrowserRouter> 作为顶层容器,并用 <Routes> 和 <Route> 这些 JSX 组件来声明式地构建路由结构。
核心 API: <BrowserRouter>
一句话理解: 纯粹的、运行在浏览器端的客户端路由,负责将 URL 匹配到组件。它就是您熟悉的那个“交通警察”。
1 | import { BrowserRouter } from "react-router"; |
这是 React Router 对现代 SPA 数据解决方案给出的答案,也是我们本章的绝对核心。
它通过 createBrowserRouter API 将路由配置从 React 的渲染流程中分离出来,从而解锁了一系列强大的数据处理能力,比如在导航前加载数据的 loader 和处理表单提交的 action。
核心 API: createBrowserRouter, RouterProvider
一句话理解: 在“交通警察”的基础上,增加了“数据调度员”的角色,让路由层参与到数据的获取与变更中。
1 | import { |
这是 React Router 的终极形态,但已超出了本章 SPA 的范畴。
它通过 Vite 插件将“数据模式”进一步封装,提供了类似 Next.js 或 Remix 的全栈开发体验,包含文件路由、代码分割、多种渲染策略(SSR/SSG)等。
说明: 由于您后续将专门学习 Next.js,我们将 跳过 对 框架模式 的深入探讨,以确保我们的学习路径聚焦于构建最高效的 SPA。
8.0.2. 我们应该选择哪种模式?
面对“选择的暴政”,我们为您提供一条“武断”但绝对正确的路线图:
- 对于迁移旧项目或只想用最纯粹路由功能的场景:
声明式模式是您最熟悉的路径,它能 1:1 “翻译”您在 Vue Router 中的大部分经验。 - 对于所有 2025 年启动的、有实际数据交互的全新 SPA 项目:
数据模式是唯一推荐的最佳实践。它所提供的loader和action范式,能够从架构层面根除useEffect数据获取带来的种种弊病,是构建健壮、体验一流应用的基石。
8.0.3. 本章学习地图:从“转译”到“超越”
我们深知您追求的是最高效的知识迁移。因此,本章将遵循一条精心设计的路径:
- 基础转译: 我们将从
声明式模式开始,快速带您过一遍基础的路由定义、导航、参数获取等功能,帮您将在 Vue Router 中已有的知识快速对等到 React Router 中。 - 核心进阶: 接着,我们将把全部重心投入到
数据模式。这部分内容将超越简单的“翻译”,向您展示 React Router 在数据流处理上的现代化思考,这套loader/action机制将是您 React 工具箱中最锋利的武器之一。 - 实战巩固: 最后,我们将结合两大模式,深入探讨嵌套路由、路由守卫、性能优化等高级实战技巧。
准备好了吗?让我们开始这场从 Vue 到 React 的高效路由“转译”之旅。
8.1. 基础构建:搭建你的第一个路由系统
在上一节中,我们明确了 React Router v7 的核心策略。现在,我们将从您最熟悉的领域开始——声明式模式。这个模式下的路由构建方式,可以看作是您在 Vue 中使用 <router-link> 和 <router-view> 经验的直接“翻译”。我们的目标是,快速地用 React 的“方言”,搭建起一个基础的、功能完备的单页应用。
8.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 | import { StrictMode } from 'react' |
<BrowserRouter> 包裹 <App /> 的操作,和 Vue 里的 app.use(router) 有什么异同?
思想上是完全一致的。它们都是为了将路由实例的能力“注入”到整个应用中。
不同点在于实现方式。Vue 通过插件机制 (app.use) 将路由能力混入到所有组件实例中。而 React 则利用其核心的 Context API,<BrowserRouter> 本质上是一个 Context Provider,它将路由信息(如当前 location、导航函数等)放到了一个全局可访问的上下文中,任何被它包裹的子组件都可以通过 Hooks (如 useNavigate) 从这个上下文中获取所需的功能。
明白了,所以 React 的方式更符合其“显式”和“组合”的哲学。
8.1.2. 创建你的第一个路由表
在 声明式模式 中,我们使用 JSX 来定义路由。这与 Vue 的配置式方法形成了鲜明对比,但逻辑上是等价的。
<Routes>: 相当于 Vue Router 配置中的routes: [...]数组,它是一个容器,包裹了所有的路由规则。<Route>: 相当于数组中的每一个路由对象{ path, component }。它通过path属性匹配 URL,通过element属性指定要渲染的组件。
让我们在 App.tsx 中定义几条最基础的路由。
文件路径: src/App.tsx
1 | // 1. 导入路由组件和我们的页面组件 |
为了让示例能跑起来,我们需要创建对应的页面文件。
文件路径: src/pages/Home.tsx
1 | const Home = () => { |
文件路径: src/pages/About.tsx
1 | const About = () => { |
文件路径: src/pages/Products.tsx
1 | const Products = () => { |
8.1.3. 核心导航组件:<Link> 与 <NavLink>
现在我们有了页面,但如何跳转呢?答案是使用 <Link> 和 <NavLink> 组件。
知识转译: 这两个组件共同扮演了 Vue 中
<router-link>的角色。
<Link>: 最基础的导航组件。它会被渲染成一个<a>标签,但它会拦截默认的页面刷新行为,改为在客户端进行路由跳转。它的核心属性是to,用于指定目标路径。<NavLink>: 是<Link>的一个特殊版本,它“知道”自己所指向的路由是否处于“激活”状态。当 URL 与其to属性匹配时,它会自动添加一个active类。这对于导航菜单的高亮显示非常有用。
让我们在 App.tsx 中添加一个真正的导航栏。
文件路径: src/App.tsx (更新)
1 | // 1. 导入路由组件和我们的页面组件 |
为了让激活状态可见,我们需要添加一些简单的 CSS。
文件路径: src/index.css
1 | /* 当 NavLink 激活时,React Router 会自动为其添加 .active 类 */ |
NavLink 还允许我们通过函数形式的 className 或 style 属性,更精细地控制激活状态的样式,这与 Vue Router 3+ 的 v-slot API 思路非常相似。
1 | <NavLink |
8.1.4. 视图渲染出口:<Outlet /> 的核心作用
知识转译:
Outlet组件完全等同于 Vue 中的<router-view>。
它的作用是作为子路由的渲染占位符。当我们在后续章节学习嵌套路由时,<Outlet /> 的重要性将完全展现出来。现在,我们可以利用它来重构我们的应用,创建一个共享的布局(Layout)组件。
8.1.5. 实战演练:使用 Layout 组件重构应用
一个专业的应用,通常页面结构是共享的,比如都有相同的页头和页脚。让我们遵循最佳实践,从一开始就建立良好的代码组织。
第一步:创建项目结构
1 | # src/ |
第二步:创建 AppLayout 组件
这个组件包含了共享的导航栏,以及用于渲染具体页面内容的 <Outlet />。
文件路径: src/components/AppLayout.tsx
1 | import { NavLink, Outlet } from "react-router-dom"; |
第三步:在 App.tsx 中使用新布局
我们将 App.tsx 的路由配置,改造为嵌套路由结构。
文件路径: src/App.tsx (最终版)
1 | import { Routes, Route } from 'react-router-dom' |
本节小结
我们已经成功地使用 声明式模式 搭建起了一个结构清晰、可维护的 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 /> | 子路由渲染出口 |
现在,我们的应用有了坚实的“骨架”。
8.2. 路由参数与编程式控制
在 8.1 节中,我们初步体验了 JSX 路由。现在,我们将进行一次重要的重构,以引入一种更专业、更贴近 Vue 开发者习惯的 中心化路由管理 模式。这将为我们后续学习 数据模式 打下坚实的基础。
我们将创建一个专门的文件来管理所有路由规则,并简化所有组件的逻辑,剔除加载、重试等干扰因素,只保留核心的路由交互和必要的边界情况处理(如“未找到 ID”)。
8.2.1. 最佳实践:创建中心化路由配置文件
知识转译: 我们现在要做的,就是创建 React 世界里的
router.ts。
我们将使用 createBrowserRouter 这个官方推荐的 API。它接收一个路由对象数组,其结构与 Vue Router 的 routes 数组非常相似。
第一步:创建 router/index.tsx 文件
文件路径: src/router/index.tsx (新建文件夹和文件)
1 | import { createBrowserRouter } from 'react-router-dom'; |
第二步:改造 main.tsx 和 App.tsx
现在,入口文件 main.tsx 不再使用 <BrowserRouter>,而是使用 <RouterProvider> 来“提供”我们刚刚创建的路由实例。
文件路径: src/main.tsx (修改)
1 | import React from 'react'; |
因为所有路由逻辑都已移出,App.tsx 现在不再需要,我们可以直接删除它,或者将其改造成其他用途。项目的入口和路由渲染完全由 main.tsx 和 router/index.tsx 控制。
8.2.2. 读取动态参数:useParams()
知识转译:
useParams()Hook 是 Vue 中route.params对象的直接对等物,他专门用于获取类似于 Product/: id 这样的路径 id 用于操作每一个不同的页面数据
现在路由已配置好,我们来编写 ProductDetail 组件,它将使用 useParams 来获取 URL 中的 productId。
文件路径: src/pages/ProductDetail.tsx (新建/修改)

1 | import { useParams } from "react-router-dom"; |
8.2.3. 处理查询字符串:useSearchParams()
知识转译:
useSearchParams()不仅等同于 Vue 的route.query(用于读取),它还提供了一个 setter 函数,这让它更像一个与 URL 查询字符串同步的useState。
我们来为 Products 列表页增加排序功能。
文件路径: src/pages/Products.tsx (修改)

1 | import { Link, useSearchParams } from "react-router-dom"; |
8.2.4. 命令式导航:useNavigate()
知识转译:
useNavigate()Hook 是 Vue 中router.push()和router.replace()等编程式导航 API 的集合体。
场景: 在产品详情页 ProductDetail.tsx 添加一个“返回列表”和“后退”的按钮。
文件路径: src/pages/ProductDetail.tsx (修改)

1 | import { useParams, useNavigate } from 'react-router-dom'; |
本节小结
通过本次重构,我们不仅掌握了处理动态路由的核心 Hooks,更重要的是,我们建立了一套更专业、可维护的中心化路由管理方案。
| 需求场景 | Vue Router 方案 | React Router 对等物 (v7 推荐) |
|---|---|---|
| 路由配置 | router.ts 内的 routes 数组 | router/index.tsx 内的 createBrowserRouter |
| 读取 URL 动态段 | route.params | useParams() |
| 读取/修改查询参数 | route.query / router.push | useSearchParams() |
| 编程式跳转/后退 | router.push / router.back | useNavigate() |
这套新结构不仅让您感觉更亲切,也为我们下一阶段学习更强大的 数据模式 铺平了道路。
8.3. 布局与 UI 结构化:嵌套路由
在 8.2 节中,我们通过创建中心化的路由配置文件,极大地提升了项目的可维护性。您可能已经注意到,在 router/index.tsx 中,我们已经使用了 children 属性,将所有页面都嵌套在了 AppLayout 之下。这其实就是嵌套路由的初步应用。
本节,我们将深入探讨这一强大功能,通过构建一个更真实、多层级的“用户中心”场景,来彻底掌握如何使用嵌套路由构建复杂且优雅的应用布局。
知识转译: React Router 中路由对象的
children属性,与 Vue Router 路由配置中的children数组,其思想和功用是完全一致的。它们都是用来描述一个路由内嵌于另一个路由之中的父子关系。
8.3.1. 场景分析:构建多层嵌套的用户中心
想象一下,我们的应用需要一个 /dashboard 路径,作为所有用户相关页面的入口。这个“用户中心”本身有自己独特的布局,比如一个侧边栏导航,包含“个人资料”、“我的订单”等链接。同时,整个用户中心又需要复用全局的页头和页脚(即我们之前创建的 AppLayout)。
这就构成了一个典型的二级嵌套结构:
- 第一层嵌套:
DashboardLayout(用户中心布局) 渲染在AppLayout的<Outlet />中。 - 第二层嵌套:
Profile(个人资料) 或Orders(我的订单) 页面,渲染在DashboardLayout的<Outlet />中。

8.3.2. 实现方案:children 属性与 <Outlet /> 的组合
让我们通过编码来实现这个场景。
第一步:创建用户中心相关的组件
我们需要一个新的布局组件和两个新的页面组件。
文件路径: src/pages/dashboard/DashboardLayout.tsx (新建文件夹和文件)
1 | import { Outlet, NavLink } from 'react-router-dom'; |
文件路径: src/pages/dashboard/Orders.tsx (新建文件)
1 | import { Card } from 'antd'; |
第二步:更新中心化路由配置
现在,我们修改 router/index.tsx,将新的路由规则添加进去。
文件路径: src/router/index.tsx (修改)
1 | // ... 其他 imports |
8.3.3. 索引路由 (index Route):父路由下的默认子页面
完成了上面的配置后,我们访问 /dashboard/orders 页面可以正常显示。但如果我们只访问父路径 /dashboard,会发现右侧内容区是空白的。这是因为没有任何子路由匹配 /dashboard 这个确切的路径。
我们需要一个“默认子路由”。在 React Router 中,这通过 index 属性来实现。

知识转译:
index: true与 Vue Router 中{ path: '', component: ... }的作用完全相同,都是为了定义父路由下的默认显示内容,我们可以采取 index: true 也可以采取空 path,两种方法都可以,但我们更为推荐更具有语义化的 index: true
第一步:创建仪表盘主页组件
文件路径: src/pages/dashboard/DashboardHome.tsx (新建文件)
1 | import { Card } from 'antd'; |
第二步:在路由配置中添加索引路由
文件路径: src/router/index.tsx (修改)
1 | // ... 其他 imports |
现在,再次访问 /dashboard,DashboardHome 组件就会被正确地渲染到 DashboardLayout 的 <Outlet /> 中。
8.3.4. “未找到”页面:使用 path="*"
最后,我们需要处理用户访问不存在的路径时的场景。一个健壮的应用应该向用户展示一个清晰的 “404 Not Found” 页面,而不是一个空白页。
这通过一个特殊的“捕获所有” (catch-all) 路由来实现,其路径为 *。

知识转译:
path: "*"的概念和用法与 Vue Router 中的path: "/:pathMatch(.*)*"几乎一致。
第一步:创建 NotFound 页面组件
我们可以利用 Ant Design 的 <Result> 组件来快速创建一个美观的 404 页面。
文件路径: src/pages/NotFound.tsx (新建文件)
1 | import { Button, Result } from 'antd'; |
第二步:在路由配置中添加捕获所有路由
重要: 捕获所有路由 (path: "*") 必须放在路由配置数组的 最末尾。路由匹配是自上而下进行的,如果把它放在前面,它会拦截所有路径,导致其他路由失效。
文件路径: src/router/index.tsx (修改)
1 | // ... 其他 imports |
现在,访问任何未定义的路径,例如 /this/path/does/not/exist,应用都会优雅地展示我们创建的 404 页面。
本节小结
我们通过一个实际的用户中心案例,深入掌握了嵌套路由的全部核心概念,构建出了一个结构清晰、可扩展的应用骨架。
- 多层嵌套: 通过在路由对象中嵌套
children数组,并结合各级布局中的<Outlet />组件,可以实现任意深度的布局嵌套。 - 索引路由: 使用
index: true属性,为父路由指定一个默认显示的子页面。 - 404 页面: 在路由配置的末尾添加
{ path: "*", ... }规则,可以捕获所有未匹配的 URL,提供友好的用户反馈。
8.4. 【核心】数据流革命 (上):使用 loader 读取数据
我们即将进入 React Router 最具变革性的领域:数据加载。为了让您真正体会到 loader 带来的颠覆性优势,我们将遵循一个两步走的实战路径:
- 亲历痛点: 首先,我们将使用您最熟悉的
useEffect模式,从一个真实的公共 API (dummyjson.com) 获取数据,完整地构建一个产品详情页。 - 见证奇迹: 然后,我们将引入
loader,用它彻底重构刚才的页面,亲眼见证代码是如何变得极致简洁和强大。
8.4.1. 亲历痛点:使用 useEffect 构建数据获取组件
场景: 我们需要在产品详情页 (/products/:productId) 中,根据 URL 的 id,从 https://dummyjson.com/products/:id 接口获取并展示产品数据。
第一步:在路由中注册一个“无 loader”的路由
我们暂时只定义路径和组件,不添加任何 loader。
文件路径: src/router/index.tsx
1 | // ... |
第二步:使用 useEffect 编写“传统”数据获取组件
现在,我们来编写 ProductDetail 组件。请仔细体会这个过程中我们需要手动处理的每一个细节。
文件路径: src/pages/ProductDetail.tsx
1 | import { useState, useEffect } from 'react'; |
小结: 请运行项目并访问 /products/1。功能是完善的,但请回顾我们编写的代码:超过 50 行,手动管理了 3 个状态,编写了 try/catch/finally,并需要时刻注意 useEffect 的依赖项。这就是我们需要解决的“痛点”。
8.4.2. 见证奇迹:使用 loader 和 useLoaderData 重构
现在,让我们见证 数据模式 的威力。
第一步:为路由添加 loader 函数
我们回到 router/index.tsx,将刚才在 useEffect 中编写的数据获取逻辑,直接“搬运”到 loader 中。
文件路径: src/router/index.tsx (修改)
1 | // ... |
第二步:用 useLoaderData 彻底简化组件
现在,ProductDetail 组件的使命变得无比纯粹:它不再需要关心如何、何时获取数据,只需消费 loader 准备好的“现成饭菜”。
文件路径: src/pages/ProductDetail.tsx (彻底重构)
1 | import { useLoaderData } from 'react-router-dom'; |
对比一下:我们的组件代码从 50+ 行锐减到不足 20 行,所有数据获取的复杂性都被优雅地移除了。这,就是 loader 的力量。
8.4.3. 原理初探:从瀑布流到并行数据获取
loader 带来的不仅是代码的简化,还有性能的提升。
- 旧
useEffect模式: 浏览器渲染父组件 -> 父组件useEffect请求数据 -> 渲染子组件 -> 子组件useEffect请求数据… 这是一个串行的 瀑布流。 - 新
loader模式: 导航发生时,React Router 会分析出目标 URL 将匹配的所有路由(父、子、孙…),然后找到它们各自的loader函数,并通过Promise.all并行触发 所有这些数据请求。
这意味着,所有层级的数据会同时开始加载,大大缩短了用户的总等待时间。
本节小结
通过一次亲身实践的重构,我们深刻体会到了 loader 模式的颠覆性优势:
- 关注点分离: 组件回归渲染本质,数据获取逻辑归于路由配置。
- 代码量锐减: 无需再为每个组件编写
useState和useEffect的数据获取样板代码。 - 体验与性能提升: 数据在渲染前就已就绪,且可并行加载,用户能更快看到完整页面。
- 内置错误处理:
loader中抛出的错误可以被路由层统一捕获,无需在组件内try/catch。
8.5. 【核心】数据流革命 (下):使用 action 变更数据
知识转译: 如果说
loader是对onMounted/useEffect数据获取的革命,那么action就是对传统@submit.prevent+axios.post+ 手动更新状态这一整套“表单提交三部曲”的颠覆。
8.5.1. 亲历痛点:传统 React 表单处理的繁琐
在引入 action 之前,让我们先快速回顾一下在 React 中处理表单提交的“标准流程”,以便更深刻地体会 action 带来的便利。
场景: 我们需要创建一个 /products/new 页面,允许用户添加一个新产品。
1 | // 这是一个不使用 action 的"传统"表单组件 |
小结: 上述代码逻辑清晰,但暴露了几个核心痛点:
- 逻辑耦合: 数据验证、API 请求、状态更新、页面跳转等逻辑全部耦合在组件内部。
- 状态繁多: 需要手动管理
submitting、formErrors等多个 UI 状态。 - 代码冗余: 每个需要提交表单的组件,几乎都要重复这套逻辑。
8.5.2. 新范式 action:在路由层处理数据变更
action 函数与 loader 师出同门,它的核心思想是:将处理数据变更(创建、更新、删除)的逻辑,也从组件中提升到路由配置层。
当 React Router 的 <Form> 组件提交时,它不会触发页面刷新,而是会将表单数据打包成一个 Request 对象,发送给匹配路由的 action 函数进行处理。
第一步:在路由中定义 action 函数
文件路径: src/router/index.tsx (修改)
1 | import { createBrowserRouter, redirect } from 'react-router-dom'; |
8.5.3. <Form> 与 useActionData:简化组件交互
现在,我们来创建全新的 NewProduct 组件,看看它在 action 的加持下能变得多简单。
<Form>: React Router 提供的Form组件,它会自动将提交请求指向当前路由的action。我们不再需要onSubmit和e.preventDefault()。useActionData: 这个 Hook 用来获取action函数的返回值。如果action返回了验证错误,我们就可以通过它拿到错误信息并展示在 UI 上。
文件路径: src/pages/NewProduct.tsx (新建文件)
注意: React Router 和我们使用的 Ant Design 有一个很明显的表单收集的坑,这里一定要关注一下代码里的 name 属性应该加在那个组件上,如果不确定,一定要都加
1 | import { Form as RouterForm, useActionData } from "react-router-dom"; |
再次对比:这个新组件几乎没有任何自己的“逻辑”。它只负责定义表单结构和展示 action 返回的错误信息。验证、API 调用、成功跳转、提交状态管理……所有这些复杂性都被 action 和 <Form> 优雅地封装了。
我注意到 action 成功后,返回产品列表页时,列表数据会自动更新(如果我们真的添加了的话),这是为什么?
问得好!这正是 数据模式 最强大的特性之一:自动数据再验证 (Automatic Revalidation)。
当一个 action 成功执行后(即没有抛出错误且返回了 redirect 或其他数据),React Router 会 自动重新调用当前页面上所有 loader 函数,以确保 UI 展示的是最新鲜的数据。
所以,loader (读) -> <Form> (写) -> action (处理) -> redirect (导航) -> 重新调用 loader (更新视图),形成了一个完美的数据流闭环。您再也不需要手动去刷新或更新列表状态了。
8.5.4. 最佳实践:解耦 Loader 与 Action
到目前为止,我们的 loader 和 action 函数都直接写在了 router/index.tsx 文件里。当项目简单时,这很直观。但随着路由和业务逻辑的增多,这个文件会迅速变得臃肿不堪,违背了“关注点分离”的原则。
真正的最佳实践是 将数据逻辑 (loader/action) 与其服务的组件放在一起。这被称为“代码共置” (Co-location)。
现在,我们来对项目进行一次重要的专业化重构。
第一步:将 loader 移动到 ProductDetail 页面
文件路径: src/pages/ProductDetail.tsx (修改)
1 | import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'; // 导入 LoaderFunctionArgs 类型 |
第二步:将 action 移动到 NewProduct 页面
文件路径: src/pages/NewProduct.tsx (修改)
1 | import { Form as RouterForm, useActionData, redirect, type ActionFunctionArgs } from 'react-router-dom'; |
第三步:清理并更新 router/index.tsx
现在,我们的路由配置文件变得前所未有的清爽。它只负责“配置”和“组装”,不再包含任何具体的业务逻辑实现。
文件路径: src/router/index.tsx (最终版)
1 | import { createBrowserRouter } from 'react-router-dom'; |
*.tsx(页面组件文件): 同时负责自身的 UI 渲染 和 数据逻辑 (loader/action)。高内聚!router/index.tsx: 只负责 路由结构的定义 和 逻辑的组装。低耦合!这才是专业且可扩展的 React Router 项目结构。
本节小结
我们通过引入 action 模式,并结合“代码共置”的最佳实践,完成了数据流革命的最后一块拼图。现在,我们拥有了一套功能完备、高度解耦且极其强大的数据处理范式。
| 需求场景 | 传统 React 方案 | React Router 数据模式 (最佳实践) |
|---|---|---|
| 数据读取逻辑 | 写在组件 useEffect 中 | 与组件共置,定义为 loader 函数 |
| 数据变更逻辑 | 写在组件 onSubmit 中 | 与组件共置,定义为 action 函数 |
| 路由配置 | - | 在中心化的 router/index.tsx 中导入并组装 loader 和 action |
| 变更后反馈 | 手动 useState 管理错误 | useActionData |
| 成功后跳转 | useNavigate | redirect() from action |
| 变更后数据同步 | 手动刷新或更新状态 | 自动再验证 |
8.6. 提升用户体验的利器
我们已经掌握了 loader 和 action 这两大核心,它们构建了应用数据流的“主干道”。然而,一个优秀的应用,不仅要功能正确,更要体验流畅。本节,我们将学习 React Router 提供的三个“UX 神器”,它们能极大地提升用户在数据交互过程中的感知体验。
8.6.1. 感知全局状态:useNavigation 与全局 Loading Bar
痛点: 当用户点击链接或提交表单后,如果 loader 或 action 需要执行耗时操作(如复杂的网络请求),页面会有一段时间“毫无反应”。用户不知道是应用卡死了还是正在加载,这种不确定性会带来焦虑。
解决方案: useNavigation Hook。这个 Hook 能让你在应用的任何地方,实时监控全局的路由状态。
核心思想:
useNavigation返回一个navigation对象,其state属性有三种可能的值:
'idle': 空闲状态,当前无任何导航或数据加载。'loading': 导航正在进行中,并且下一个页面的loader正在被调用。'submitting': 表单正在提交中,某个路由的action正在被调用。
对于实现一个全局加载指示器,我们只需要关心 navigation.state 是否为 'idle'。
实战示例: 在我们的主布局 AppLayout 中添加一个全局进度条。
文件路径: src/components/AppLayout.tsx
1 | import { Outlet, NavLink } from "react-router-dom"; |
现在,当您从首页点击导航到“慢加载页面”时,例如我们之前调用了网络 API 的页面,会立刻在页面中心看到一个加载图标,为用户提供了即时的操作反馈。

8.6.2. 页面“微交互”:useFetcher
痛点: 很多时候,我们需要与后端进行数据交互,但并不希望因此改变 URL 或刷新整个页面。典型的例子包括:点赞一篇文章、将商品加入购物车、提交一个订阅邮箱等。使用 <Form> 会触发导航和全局 loader 的再验证,对于这些“微交互”来说,成本太高。
解决方案: useFetcher Hook。它就像一个“迷你版”的 <Form>,可以让你在幕后调用任何路由的 loader 或 action,而不会引起任何 URL 变化。
知识转译:
useFetcher可以理解为 React Router 内置的、与数据流(action/loader)深度集成的axios或fetch客户端。
实战示例: 在页面任意位置放置一个通讯订阅表单。
第一步:为“资源路由”创建独立的 action 文件
我们的 /subscribe 路由没有对应的页面组件,它只提供一个数据处理的端点。对于这类“资源路由”,最佳实践是为其逻辑创建一个独立的文件,而不是污染全局的 router/index.tsx。
文件路径: src/actions/subscribe.ts (新建文件夹和文件)
1 | import { type ActionFunctionArgs } from 'react-router-dom'; |
第二步:在路由配置中“组装”资源路由
现在,我们的 router/index.tsx 变得非常干净。它只负责导入并“注册”这个 action,而不关心其内部实现。
文件路径: src/router/index.tsx (修改)
1 | import { createBrowserRouter } from 'react-router-dom'; |
第三步:创建使用 fetcher 的组件
NewsletterForm 组件的代码 完全不需要改变。它只关心 action="/subscribe" 这个端点,而完全不关心这个端点背后的逻辑是如何组织的。这正是关注点分离带来的好处。
文件路径: src/components/NewsletterForm.tsx (新建文件)
1 | import { useFetcher } from "react-router-dom"; |

8.6.3. 滚动行为管理:<ScrollRestoration>
痛点: 在单页应用中,当你从一个很长的列表页(已滚动到页面中间)导航到详情页,然后再点击浏览器的“后退”按钮时,你常常会回到列表页的顶部,而不是你离开时的位置。这不符合原生网页的体验。
解决方案: <ScrollRestoration> 组件。这是一个“即插即用”的组件,只需在应用中渲染一次,它就会自动为你处理滚动位置的保存和恢复。
实战示例: 在主布局中添加滚动恢复功能。
文件路径: src/components/AppLayout.tsx (添加一行代码)
1 | import { Outlet, NavLink, ScrollRestoration } from 'react-router-dom'; // 1. 导入 ScrollRestoration |
就这样!无需任何额外配置,您的应用现在就拥有了媲美传统多页应用的滚动历史记录功能。
本节小结
我们学习了三个极具价值的 UX 增强工具,它们能让我们的数据驱动应用变得更加完善和贴心:
useNavigation: 通过捕获全局导航状态,为用户提供即时的加载反馈(如 Loading Bar)。useFetcher: 在不引起页面跳转的前提下,与action或loader交互,是实现点赞、收藏等“微交互”的不二之选。<ScrollRestoration>: 仅需一行代码,即可为应用带来浏览器级的滚动位置恢复体验。
8.7. 健壮性与安全:错误处理和路由守卫
一个生产级的应用,不仅要在正常情况下工作,更要在异常情况下表现得体。React Router 的 数据模式 提供了一套与数据流深度集成的、声明式的错误处理和访问控制方案,远比传统的 try/catch 和 useEffect 判断更优雅、更强大。
8.8.1. 优雅降级:errorElement 与 useRouteError
痛点: 在 8.4 节的 loader 示例中,如果 fetch 失败(例如,网络错误或服务器返回 404),loader 会抛出一个错误。默认情况下,这个未被捕获的错误会导致整个应用崩溃,白屏。我们需要一个机制来捕获这些错误,并向用户展示一个友好的错误页面。
解决方案: 在路由对象上声明一个 errorElement。当该路由的 loader, action 或组件渲染过程中抛出任何错误时,React Router 会自动捕获它,并渲染你在 errorElement 中指定的组件,而不是原来的 element。
第一步:创建一个通用的错误页面组件
这个组件将使用 useRouteError Hook 来获取被抛出的具体错误信息。
文件路径: src/pages/ErrorPage.tsx (新建文件)
1 | import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'; |
useRouteError 的文档说它返回 unknown 类型,这是为什么?
这是一个很好的 TypeScript 实践。因为在 JavaScript 中,你可以 throw "一个字符串",throw 123,throw new Error(),甚至 throw new Response()。throw 后面可以跟任何东西。
所以 useRouteError 无法预知会捕获到什么类型的错误,返回 unknown 是最类型安全的。这强制我们必须在使用前,先对 error 进行类型检查,比如使用 isRouteErrorResponse 或 instanceof Error,从而避免运行时错误。
明白了,unknown 是在促使我们编写更健壮的代码。
第二步:在路由配置中应用 errorElement
我们可以为单个路由配置 errorElement,也可以为父路由配置,它将捕获所有子路由中未被处理的错误,形成“错误边界”。
文件路径: src/router/index.tsx (修改)
1 | // ... |
现在,再次访问一个不存在的产品 ID(例如 /products/999),我们的 productDetailLoader 会 throw new Response("...404...")。这个错误会被 React Router 捕获,然后渲染我们在根路由上配置的 <ErrorPage />,优雅地向用户展示了 404 页面,而不是整个应用白屏。

8.8.2. 访问控制:使用 loader 实现路由守卫
痛点: 应用中的某些页面,如“用户中心”,只应在用户登录后才能访问。我们需要一种机制,在用户进入该页面前进行权限检查,如果未登录,则强制跳转到登录页。
解决方案: 在 loader 中实现权限校验。
知识转译: 这与 Vue Router 的
beforeEach全局导航守卫在 目标 上是一致的,但在 实现 上截然不同。Vue 的守卫是命令式的、全局拦截的;而 React Router 的方案是声明式的、与需要保护的路由直接绑定。loader方案的巨大优势在于,权限检查和页面所需数据的加载可以合并在同一个函数中,并且它在组件渲染前执行,能彻底避免“页面内容闪现”的问题。
实战示例: 保护我们的 /dashboard 路由。
第一步:创建一个模拟的认证工具模块
文件路径: src/utils/auth.ts (新建文件)
1 | // 这是一个极其简化的模拟认证模块 |
第二步:创建一个受保护路由的 loader
文件路径: src/pages/dashboard/DashboardLayout.tsx (修改)
1 | import { Outlet, NavLink, redirect } from 'react-router-dom'; // 1. 导入 redirect |
第三步:创建登录页并配置路由
文件路径: src/pages/Login.tsx (新建文件)
1 | import { Form as RouterForm, redirect } from 'react-router-dom'; |
第四步:更新路由配置,应用守卫
文件路径: src/router/index.tsx (修改)
1 | // ... |
现在,当一个未登录的用户尝试访问 /dashboard 或其任何子页面时,dashboardLoader 会被触发,检查到未登录状态后 throw redirect('/login'),用户将被无缝地、在看到任何仪表盘内容之前,就被重定向到了登录页面。

本节小结
我们为应用构建了两道至关重要的防线,极大地提升了其健壮性和安全性。
- 错误处理: 通过在路由对象上配置
errorElement,我们可以捕获loader、action或渲染中抛出的错误,并使用useRouteError在一个专门的错误组件中优雅地展示错误信息。 - 路由守卫: 利用
loader在组件渲染前执行的特性,我们可以在其中加入权限校验逻辑。如果校验失败,通过抛出redirect()即可实现一个无页面闪烁、与数据加载逻辑合二为一的现代化路由守卫。
8.8. 性能优化专题
随着我们的应用功能日益丰富,所有页面的代码都打包在一个 main.js 文件中,会导致初始加载体积越来越大,影响用户的首次访问速度。代码分割 (Code Splitting) 是解决这个问题的关键技术。本节,我们将学习 React Router 中实现代码分割的最佳实践。
8.8.1. 路由级代码分割:使用 lazy 属性
知识转译: Vue Router 中实现懒加载非常直接:
component: () => import('./About.vue')。React Router 的lazy属性在目标上是相同的——按需加载路由,但在能力上更为强大。它不仅能懒加载组件,还能同时懒加载与该路由关联的loader、action、ErrorBoundary等所有逻辑。
为什么我们不直接用 React 自带的 React.lazy() 和 Suspense 呢?
这是一个非常好的问题。React.lazy() 只能懒加载组件本身。在我们的 数据模式 下,这意味着:我们需要先加载路由配置。然后导航到某页面,触发 React.lazy 开始下载组件代码。在组件代码下载完之后,开始渲染。组件渲染后,loader 的数据还不存在,因为 loader 和组件是分离的。
这就破坏了 loader 在组件渲染前加载数据的核心优势。
而路由的 lazy 属性是 一体化 的。当导航发生时,React Router 会 同时并行 地去请求组件代码块和执行 loader 的数据请求。当两者都完成后,再用获取到的数据渲染组件。这避免了“代码-数据”的瀑布流,是性能上的最优解。
实战示例: 对我们的“产品详情页”进行代码分割。
第一步:将路由模块的逻辑提取到专用文件中
最佳实践是,将一个懒加载路由的所有相关导出(loader, Component 等)放在同一个文件中。
文件路径: src/pages/ProductDetail.tsx (修改)
注意: 使用懒加载时,必须导出为 “loader” 而不是自定义名称
1 | import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom'; |
注意: 懒加载的模块 不应该有 default 导出。React Router 会根据 loader, action, Component, ErrorBoundary 等固定的具名导出来识别和加载对应的功能。
第二步:在路由配置中用 lazy 替换 loader 和 element
现在,我们来修改中心化的路由配置,告诉 React Router 如何去懒加载这个模块。
文件路径: src/router/index.tsx (修改)
1 | import { createBrowserRouter } from 'react-router-dom'; |
完成! 现在,当应用初始加载时,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),简化了配置。
8.9. 本章核心总结与展望
恭喜您!我们已经完成了对现代 React Router (v7 SPA 模式) 的深度探索。从基础的 JSX 路由,到中心化的路由配置,再到革命性的 loader 和 action 数据流,我们不仅“翻译”了您在 Vue 世界中熟悉的路由概念,更掌握了一套构建高性能、高健壮性、高体验的现代化单页应用的强大范式。
8.9.1. 核心模式回顾:数据模式 的架构思想
回顾整个学习过程,我们最终沉淀出了一套专业、可扩展的 SPA 路由架构:
- 中心化配置: 我们使用
createBrowserRouter在一个独立的router/index.tsx文件中定义整个应用的路由结构。这让路由的宏观视图保持清晰,易于管理。 - 代码共置 (Co-location): 我们将与特定路由强相关的业务逻辑——
loader(数据读取)和action(数据写入)——与该路由渲染的组件放在同一个文件中。这实现了“高内聚、低耦合”,极大地提升了代码的可维护性。 - 数据流闭环: 我们掌握了由
loader->Component-><Form>->action->redirect/revalidation->loader构成的自动化数据流闭环。这个模式从根本上消除了传统useEffect数据获取带来的种种复杂性。
这套架构思想,是本章最宝贵的财富,它将是您未来构建任何复杂 React 应用的坚实地基。
8.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() | 在不触发导航的情况下,调用任意路由的 loader 或 action。 | |
| 导航钩子 | 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 组件中获取路由层捕获的错误信息。 |
8.9.3. 高频面试题精选
React Router v7 的 loader 模型相比传统的 useEffect 数据获取,解决了哪些核心痛点?
它主要解决了四大痛点:首先,简化了组件逻辑,将数据获取的关注点从组件中分离,不再需要手动管理 loading、error 等状态。其次,提升了性能,通过并行请求数据避免了 useEffect 模式下的“请求瀑布流”。再次,改善了用户体验,数据在组件渲染前就已准备好,消除了加载状态导致的页面内容闪烁。最后,增强了健壮性,loader 中抛出的错误可以被路由的 errorElement 统一捕获,实现了声明式的错误边界。
很好。那 useFetcher 和标准的 <Form> 提交有什么区别?你会在什么场景下选择使用 useFetcher?
它们的核心区别在于是否会触发 页面导航。标准的 <Form> 提交是一个完整的导航事件,URL 可能会改变,并且会触发全局 loader 的“再验证”,适用于创建、更新等需要刷新整个页面数据的场景。而 useFetcher 则是在“幕后”调用 action 或 loader,不会改变 URL,也不会触发全局导航状态。因此,它非常适合那些“微交互”场景,比如:给文章点赞、将商品加入购物车、订阅邮件列表等。这些操作只需要更新一小部分数据,而不需要整个页面跳转或刷新。
8.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) 的高级特性,可以让页面的部分内容先显示出来。
8.9.5. 展望:通往全栈之路
恭喜您!您已完全掌握了 React Router 在 SPA 领域的最佳实践。
更令人兴奋的是,您今天学到的 loader, action, Form 等 数据模式 的核心思想,并不仅仅局限于客户端。它们正是 Remix、Next.js 等现代 React 全栈框架的基石。在这些框架中,您的 loader 和 action 函数可以直接在服务端运行,无缝对接数据库和后端服务。
您今天的学习,已为您未来进入 React 全栈开发的世界,铺平了最坚实的道路。













