第七章 现代 React 导航艺术:React Router 完全精通
第七章 现代 React 导航艺术:React Router 完全精通
Prorise第七章: 现代 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 | 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。
7.0.2. 我们应该选择哪种模式?
面对“选择的暴政”,我们为您提供一条“武断”但绝对正确的路线图:
- 对于迁移旧项目或只想用最纯粹路由功能的场景:
声明式模式
是您最熟悉的路径,它能 1:1 “翻译”您在 Vue Router 中的大部分经验。 - 对于所有 2025 年启动的、有实际数据交互的全新 SPA 项目:
数据模式
是唯一推荐的最佳实践。它所提供的loader
和action
范式,能够从架构层面根除useEffect
数据获取带来的种种弊病,是构建健壮、体验一流应用的基石。
7.0.3. 本章学习地图:从“转译”到“超越”
我们深知您追求的是最高效的知识迁移。因此,本章将遵循一条精心设计的路径:
- 基础转译: 我们将从
声明式模式
开始,快速带您过一遍基础的路由定义、导航、参数获取等功能,帮您将在 Vue Router 中已有的知识快速对等到 React Router 中。 - 核心进阶: 接着,我们将把全部重心投入到
数据模式
。这部分内容将超越简单的“翻译”,向您展示 React Router 在数据流处理上的现代化思考,这套loader/action
机制将是您 React 工具箱中最锋利的武器之一。 - 实战巩固: 最后,我们将结合两大模式,深入探讨嵌套路由、路由守卫、性能优化等高级实战技巧。
准备好了吗?让我们开始这场从 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 | import { StrictMode } from 'react' |
<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 | // 1. 导入路由组件和我们的页面组件 |
为了让示例能跑起来,我们需要创建对应的页面文件。
文件路径: src/pages/Home.tsx
1 | const Home = () => { |
文件路径: src/pages/About.tsx
1 | const About = () => { |
文件路径: src/pages/Products.tsx
1 | const Products = () => { |
7.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 |
7.1.4. 视图渲染出口:<Outlet />
的核心作用
知识转译:
Outlet
组件完全等同于 Vue 中的<router-view>
。
它的作用是作为子路由的渲染占位符。当我们在后续章节学习嵌套路由时,<Outlet />
的重要性将完全展现出来。现在,我们可以利用它来重构我们的应用,创建一个共享的布局(Layout)组件。
7.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 /> | 子路由渲染出口 |
现在,我们的应用有了坚实的“骨架”。
7.2. 路由参数与编程式控制
在 7.1
节中,我们初步体验了 JSX 路由。现在,我们将进行一次重要的重构,以引入一种更专业、更贴近 Vue 开发者习惯的 中心化路由管理 模式。这将为我们后续学习 数据模式
打下坚实的基础。
我们将创建一个专门的文件来管理所有路由规则,并简化所有组件的逻辑,剔除加载、重试等干扰因素,只保留核心的路由交互和必要的边界情况处理(如“未找到 ID”)。
7.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
控制。
7.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"; |
7.2.3. 处理查询字符串:useSearchParams()
知识转译:
useSearchParams()
不仅等同于 Vue 的route.query
(用于读取),它还提供了一个 setter 函数,这让它更像一个与 URL 查询字符串同步的useState
。
我们来为 Products
列表页增加排序功能。
文件路径: src/pages/Products.tsx
(修改)
1 | import { Link, useSearchParams } from "react-router-dom"; |
7.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() |
这套新结构不仅让您感觉更亲切,也为我们下一阶段学习更强大的 数据模式
铺平了道路。
7.3. 布局与 UI 结构化:嵌套路由
在 7.2
节中,我们通过创建中心化的路由配置文件,极大地提升了项目的可维护性。您可能已经注意到,在 router/index.tsx
中,我们已经使用了 children
属性,将所有页面都嵌套在了 AppLayout
之下。这其实就是嵌套路由的初步应用。
本节,我们将深入探讨这一强大功能,通过构建一个更真实、多层级的“用户中心”场景,来彻底掌握如何使用嵌套路由构建复杂且优雅的应用布局。
知识转译: React Router 中路由对象的
children
属性,与 Vue Router 路由配置中的children
数组,其思想和功用是完全一致的。它们都是用来描述一个路由内嵌于另一个路由之中的父子关系。
7.3.1. 场景分析:构建多层嵌套的用户中心
想象一下,我们的应用需要一个 /dashboard
路径,作为所有用户相关页面的入口。这个“用户中心”本身有自己独特的布局,比如一个侧边栏导航,包含“个人资料”、“我的订单”等链接。同时,整个用户中心又需要复用全局的页头和页脚(即我们之前创建的 AppLayout
)。
这就构成了一个典型的二级嵌套结构:
- 第一层嵌套:
DashboardLayout
(用户中心布局) 渲染在AppLayout
的<Outlet />
中。 - 第二层嵌套:
Profile
(个人资料) 或Orders
(我的订单) 页面,渲染在DashboardLayout
的<Outlet />
中。
7.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 |
7.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 />
中。
7.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,提供友好的用户反馈。
7.4. 【核心】数据流革命 (上):使用 loader
读取数据
我们即将进入 React Router 最具变革性的领域:数据加载。为了让您真正体会到 loader
带来的颠覆性优势,我们将遵循一个两步走的实战路径:
- 亲历痛点: 首先,我们将使用您最熟悉的
useEffect
模式,从一个真实的公共 API (dummyjson.com
) 获取数据,完整地构建一个产品详情页。 - 见证奇迹: 然后,我们将引入
loader
,用它彻底重构刚才的页面,亲眼见证代码是如何变得极致简洁和强大。
7.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
的依赖项。这就是我们需要解决的“痛点”。
7.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
的力量。
7.4.3. 原理初探:从瀑布流到并行数据获取
loader
带来的不仅是代码的简化,还有性能的提升。
- 旧
useEffect
模式: 浏览器渲染父组件 -> 父组件useEffect
请求数据 -> 渲染子组件 -> 子组件useEffect
请求数据… 这是一个串行的 瀑布流。 - 新
loader
模式: 导航发生时,React Router 会分析出目标 URL 将匹配的所有路由(父、子、孙…),然后找到它们各自的loader
函数,并通过Promise.all
并行触发 所有这些数据请求。
这意味着,所有层级的数据会同时开始加载,大大缩短了用户的总等待时间。
本节小结
通过一次亲身实践的重构,我们深刻体会到了 loader
模式的颠覆性优势:
- 关注点分离: 组件回归渲染本质,数据获取逻辑归于路由配置。
- 代码量锐减: 无需再为每个组件编写
useState
和useEffect
的数据获取样板代码。 - 体验与性能提升: 数据在渲染前就已就绪,且可并行加载,用户能更快看到完整页面。
- 内置错误处理:
loader
中抛出的错误可以被路由层统一捕获,无需在组件内try/catch
。
7.5. 【核心】数据流革命 (下):使用 action
变更数据
知识转译: 如果说
loader
是对onMounted
/useEffect
数据获取的革命,那么action
就是对传统@submit.prevent
+axios.post
+ 手动更新状态这一整套“表单提交三部曲”的颠覆。
7.5.1. 亲历痛点:传统 React 表单处理的繁琐
在引入 action
之前,让我们先快速回顾一下在 React 中处理表单提交的“标准流程”,以便更深刻地体会 action
带来的便利。
场景: 我们需要创建一个 /products/new
页面,允许用户添加一个新产品。
1 | // 这是一个不使用 action 的"传统"表单组件 |
小结: 上述代码逻辑清晰,但暴露了几个核心痛点:
- 逻辑耦合: 数据验证、API 请求、状态更新、页面跳转等逻辑全部耦合在组件内部。
- 状态繁多: 需要手动管理
submitting
、formErrors
等多个 UI 状态。 - 代码冗余: 每个需要提交表单的组件,几乎都要重复这套逻辑。
7.5.2. 新范式 action
:在路由层处理数据变更
action
函数与 loader
师出同门,它的核心思想是:将处理数据变更(创建、更新、删除)的逻辑,也从组件中提升到路由配置层。
当 React Router 的 <Form>
组件提交时,它不会触发页面刷新,而是会将表单数据打包成一个 Request
对象,发送给匹配路由的 action
函数进行处理。
第一步:在路由中定义 action
函数
文件路径: src/router/index.tsx
(修改)
1 | import { createBrowserRouter, redirect } from 'react-router-dom'; |
7.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
(更新视图),形成了一个完美的数据流闭环。您再也不需要手动去刷新或更新列表状态了。
7.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 |
变更后数据同步 | 手动刷新或更新状态 | 自动再验证 |
7.6. 提升用户体验的利器
我们已经掌握了 loader
和 action
这两大核心,它们构建了应用数据流的“主干道”。然而,一个优秀的应用,不仅要功能正确,更要体验流畅。本节,我们将学习 React Router 提供的三个“UX 神器”,它们能极大地提升用户在数据交互过程中的感知体验。
7.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 的页面,会立刻在页面中心看到一个加载图标,为用户提供了即时的操作反馈。
7.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"; |
7.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>
: 仅需一行代码,即可为应用带来浏览器级的滚动位置恢复体验。
7.7. 健壮性与安全:错误处理和路由守卫
一个生产级的应用,不仅要在正常情况下工作,更要在异常情况下表现得体。React Router 的 数据模式
提供了一套与数据流深度集成的、声明式的错误处理和访问控制方案,远比传统的 try/catch
和 useEffect
判断更优雅、更强大。
7.7.1. 优雅降级:errorElement
与 useRouteError
痛点: 在 7.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 页面,而不是整个应用白屏。
7.7.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()
即可实现一个无页面闪烁、与数据加载逻辑合二为一的现代化路由守卫。
7.8. 性能优化专题
随着我们的应用功能日益丰富,所有页面的代码都打包在一个 main.js
文件中,会导致初始加载体积越来越大,影响用户的首次访问速度。代码分割 (Code Splitting) 是解决这个问题的关键技术。本节,我们将学习 React Router 中实现代码分割的最佳实践。
7.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
),简化了配置。
7.9. 本章核心总结与展望
恭喜您!我们已经完成了对现代 React Router (v7
SPA 模式) 的深度探索。从基础的 JSX 路由,到中心化的路由配置,再到革命性的 loader
和 action
数据流,我们不仅“翻译”了您在 Vue 世界中熟悉的路由概念,更掌握了一套构建高性能、高健壮性、高体验的现代化单页应用的强大范式。
7.9.1. 核心模式回顾:数据模式
的架构思想
回顾整个学习过程,我们最终沉淀出了一套专业、可扩展的 SPA 路由架构:
- 中心化配置: 我们使用
createBrowserRouter
在一个独立的router/index.tsx
文件中定义整个应用的路由结构。这让路由的宏观视图保持清晰,易于管理。 - 代码共置 (Co-location): 我们将与特定路由强相关的业务逻辑——
loader
(数据读取)和action
(数据写入)——与该路由渲染的组件放在同一个文件中。这实现了“高内聚、低耦合”,极大地提升了代码的可维护性。 - 数据流闭环: 我们掌握了由
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() | 在不触发导航的情况下,调用任意路由的 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 组件中获取路由层捕获的错误信息。 |
7.9.3. 高频面试题精选
React Router v7 的 loader
模型相比传统的 useEffect
数据获取,解决了哪些核心痛点?
它主要解决了四大痛点:首先,简化了组件逻辑,将数据获取的关注点从组件中分离,不再需要手动管理 loading、error 等状态。其次,提升了性能,通过并行请求数据避免了 useEffect
模式下的“请求瀑布流”。再次,改善了用户体验,数据在组件渲染前就已准备好,消除了加载状态导致的页面内容闪烁。最后,增强了健壮性,loader
中抛出的错误可以被路由的 errorElement
统一捕获,实现了声明式的错误边界。
很好。那 useFetcher
和标准的 <Form>
提交有什么区别?你会在什么场景下选择使用 useFetcher
?
它们的核心区别在于是否会触发 页面导航。标准的 <Form>
提交是一个完整的导航事件,URL 可能会改变,并且会触发全局 loader
的“再验证”,适用于创建、更新等需要刷新整个页面数据的场景。而 useFetcher
则是在“幕后”调用 action
或 loader
,不会改变 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 全栈框架的基石。在这些框架中,您的 loader
和 action
函数可以直接在服务端运行,无缝对接数据库和后端服务。
您今天的学习,已为您未来进入 React 全栈开发的世界,铺平了最坚实的道路。