Files
cs-note/hhs/REACT/3. 生态工具篇/08-路由管理.md
T

452 lines
16 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [React, Router, Navigation, Frontend]
create time: 2026-04-29 22:07
---
# 路由管理
## 概述
前端路由决定了"URL 变化 → 组件渲染"的映射关系。在单页应用(SPA)中,浏览器**不会重新加载整页**,而是由路由库拦截 URL 变更、动态切换组件——这也就是为什么 SPA 体验如此流畅。
> [!question] 思考:浏览器刷新和客户端跳转的本质区别
> - **刷新**:向服务器发起完整 HTTP 请求,拿到新 HTML → 重建整个 DOM
> - **客户端跳转**:只改变 URL + 执行 JS 渲染对应组件,无需请求页面资源
>
> 理解这个区别是掌握前端路由的前提。
本文档以 React Router v7 为主线,覆盖路由配置、嵌套路由、懒加载、数据加载、守卫拦截等核心模式。同时补充 TanStack Router 作为类型安全方案的对比。
## React Router v7 核心概念
```mermaid
flowchart TD
Start["URL 变更"] --> Match["路由匹配<br/>按 path 从上到下匹配"]
Match --> BestMatch["最佳匹配胜出"]
BestMatch --> Render["渲染组件树<br/>Layout Route + Child Routes"]
Render --> Outlet["Outlet 占位"]
Outlet --> Content["具体页面组件"]
Sub1["声明式: Link"] -.-> Start
Sub2["程序化: navigate()"] -.-> Start
subgraph Config["路由配置 Route Tree"]
Root["path / RootLayout"]
Root --> Index["index true HomePage"]
Root --> About["path about AboutPage"]
Root --> Dash["path dashboard DashboardLayout"]
Dash --> Stats["path stats StatsPage"]
Dash --> Settings["path settings SettingsPage"]
end
Render --> Config
style Start fill:#F4DBD6,color:#000
style Render fill:#61DAFB,color:#000
style Config fill:#E8E8E8,color:#000
```
### SPA 路由的工作流程
`浏览器操作` → `拦截 URL 变化` → `匹配 Route 树` → `渲染对应组件`
两条跳转路径:
- **声明式导航**:用户点击 `<Link>` 组件,浏览器地址栏更新但不刷新
- **程序化导航**:JS 调用 `navigate("/path")`,常用于登录后跳转、表单提交后重定向
---
## React Router vs TanStack Router 选型
| 维度 | React Router (v7) | TanStack Router |
|------|-------------------|-----------------|
| Bundle 大小 | ~25KB | ~20KB |
| TypeScript 支持 | 需手动写类型 | 代码生成,全链路类型安全 |
| Data APIs | loader / action(v7)| loader / action(更成熟)|
| 学习曲线 | 低(熟悉 React 即懂) | 中(有自身 API 体系)|
| 适用场景 | 大多数项目 | TS 重度项目、大型应用 |
> [!tip] 选型建议
> - 中小型项目或团队 TS 经验一般 → **React Router**,上手快、文档丰富
> - 大型企业级项目、强依赖类型安全 → **TanStack Router**,编译期捕获路由错误
### 基本结构
React Router v7 使用扁平化的 Route Tree,父子关系通过 `children` 数组表达。核心概念:**Layout Route**(不切换内容的父布局)和 **Page Route**(实际渲染页面的叶子节点)。
```tsx
// React Router DOM v7
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />, // 所有页面共用的外层 shell
children: [
{ index: true, element: <HomePage /> }, // "/" → 首页
{ path: "about", element: <AboutPage /> }, // "/about"
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
```
> [!note] Index Route vs Path Route
> - `index: true`:当 parent route 被匹配且**没有更深的子路径**时激活。例如 `/dashboard` 命中 Layout 但没带子路径 → index route 显示。
> - `path: "xxx"`:当 URL 带有该段时才激活。例如 `/dashboard/stats` → stats route。
## Layout Route 与嵌套路由
Layout Route 是一种**不随 URL 变化而卸载**的父级路由。它的核心作用是让侧边栏、顶部导航等共用 UI 在页面切换时保持存活,避免重复初始化。
```tsx
{
// /dashboard 及其所有子路径共用此布局
path: "dashboard",
element: <DashboardLayout />, // 侧边栏 + Header 常驻不卸载
children: [
{ index: true, element: <DashboardHome /> }, // /dashboard → DashboardHome
{ path: "stats", element: <StatsPage /> }, // /dashboard/stats → StatsPage
{ path: "settings", element: <SettingsPage /> }, // /dashboard/settings → SettingsPage
{ path: "settings/:tab", element: <SettingsTab /> }, // /dashboard/settings/profile
],
}
```
```tsx
// DashboardLayout.tsx
import { Outlet } from "react-router-dom";
function DashboardLayout() {
return (
<div className="dashboard-layout">
<Sidebar />
<header><Navigation /></header>
<main>
{/* <Outlet /> 是嵌套内容的"占位槽" */}
<Outlet />
</main>
</div>
);
}
```
> [!note] <Outlet /> 的本质
> `<Outlet />` 是一个**占位组件**。当用户访问 `/dashboard/stats` 时:
> 1. React Router 匹配到 `path: "dashboard"` 这个 layout route,渲染 `<DashboardLayout />`
> 2. 在 `<DashboardLayout />` 内部遇到 `<Outlet />` → 继续向下匹配子路由
> 3. 子路由 `<StatsPage />` 被渲染到 `<Outlet />` 的位置
>
> **关键理解**:没有 `<Outlet />`,子路由内容就无法显示!这是 v6/v7 中嵌套路由的唯一通信方式。
> [!warning] 常见坑:忘记加 Outlet
> ```tsx
> // ❌ 错误:layout route 的子路由永远不会渲染
> function DashboardLayout() {
> return <><Sidebar /><StatsPage /></>; // 硬编码了具体页面
> }
>
> // ✅ 正确:用 Outlet 留出插槽
> function DashboardLayout() {
> return <><Sidebar /><Outlet /></>;
> }
> ```
## 路由参数获取
React Router 提供了一组 Hook,让我们可以在组件中读取 URL 信息:
```tsx
import { useSearchParams, useParams, useNavigate, useLocation } from "react-router-dom";
function UserPage() {
const { id } = useParams(); // /user/:id → { id: "123" }
const [searchParams] = useSearchParams(); // ?q=hello&page=1
const location = useLocation(); // { pathname, search, hash }
const navigate = useNavigate(); // 编程式导航
const query = searchParams.get("q"); // "hello"
console.log(location.pathname); // "/user/123"
return <h1>User #{id}, searching for "{query}"</h1>;
}
```
### Hook 速查表
| Hook | 返回 | 典型用法 |
|------|------|----------|
| `useParams()` | `{ param: string }` | 从 URL 段中提取动态值(如 `/:id`) |
| `useSearchParams()` | `[URLSearchParams, func]` | 读写查询字符串(如 `?q=xxx`) |
| `useLocation()` | `{ pathname, search, hash }` | 当前 URL 各部分的细粒度访问 |
| `useNavigate()` | `navigate(path, options?)` | 编程式跳转、后退、替换 history entry |
> [!tip] searchParams vs Location.search
> - `searchParams.get("key")` 直接得到解码后的字符串,方便操作
> - `location.search` 是原始字符串 `"?q=hello"`,需要手动解析
> - 推荐优先使用 `useSearchParams()`,类型更安全。
### v7 Data Loader —— 数据预加载
v7 引入了 `loader` 和 `action`,让数据获取成为路由级别的一等公民:
```tsx
// Route 定义
{
path: "posts/:postId",
// loader 在组件渲染前执行,返回的数据自动注入 component props
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`);
if (!res.ok) throw new Error("Post not found");
return res.json(); // 返回的对象将作为 props 传给组件
},
action: async ({ request, params }) => {
const formData = await request.formData();
const content = formData.get("content") as string;
await fetch(`/api/posts/${params.postId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
return { success: true }; // action 返回值可通过 useActionData() 读取
},
// errorElement 处理 loader/action 中抛出的错误
errorElement: <PostErrorBoundary />,
}
```
```tsx
// PostPage.tsx —— 消费 loader 数据
import { useLoaderData, useActionData } from "react-router-dom";
function PostPage() {
const post = useLoaderData() as { title: string; body: string }; // loader 的返回值
const actionResult = useActionData(); // action 的返回值
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
{actionResult?.success && <div className="toast">保存成功!</div>}
</div>
);
}
```
> [!question] loader vs useEffect 获取数据有什么区别?
> - **loader**:先取数据 → 数据就绪后 → 才渲染页面。用户不会看到空状态或 loading 骨架屏(配合 Suspense 可以显示 fallback)。
> - **useEffect + useState**:先渲染空白/loading → 请求发出 → 数据回来 → 更新 UI。会有闪烁过程。
>
> loader 本质是**服务端渲染(SSR)友好的异步数据模式**。即使不使用 SSR,它的"数据先于渲染"语义也能减少水合闪烁。
## 懒加载 Code Splitting
代码分割的核心目标:**减少首屏加载体积**。通过 `React.lazy()` + `<Suspense>`,将非关键路由的代码拆成独立 chunk,用户访问时才下载。
```tsx
import { lazy, Suspense } from "react";
// 路由级代码分割 —— 只在组件真正需要时才加载
const AdminPage = lazy(() => import("./pages/AdminPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
// Route 配置中包裹 Suspense
{
path: "admin",
element: (
<Suspense fallback={<PageSpinner />}>
<AdminPage />
</Suspense>
),
}
```
### 更优方案:loader + Suspense(v7 Data Router)
当 `loader` 和 `lazy()` 结合使用时,可以在数据加载之前就展示 fallback,实现"Loading → Fallback → Page"的自然过渡:
```tsx
const ExplorePage = lazy(
() => import("./pages/ExplorePage"),
{ ssr: false } // v7 可选:标记为非 SSR 页面
);
{
path: "explore",
loader: async () => {
// 1. 先执行数据请求(此时显示 fallback)
const categories = await fetchCategories();
return { categories };
},
element: (
<Suspense fallback={<ExploreFallback />}>
{/* 2. chunk 加载完毕 + loader 数据就绪后渲染 */}
<ExplorePage />
</Suspense>
),
}
```
> [!tip] 懒加载时机判断
> - **不要懒加载**:首页、登录页等高频入口
> - **适合懒加载**:设置页、管理员面板、低频功能模块
> - **chunk 大小建议**:每个 chunk gzipped 后不超过 100KB
> - **公共代码尽量提取**:避免多个 chunk 重复包含 React / utils
## 动态路由与 Splat 路由
URL 中的可变部分是前端路由的核心灵活性来源。React Router 提供了两种参数匹配方式:
```tsx
// :param —— 匹配单个路径段
{ path: "users/:userId", element: <UserProfile /> } // /users/42 ✅
// /users/42/posts ❌(不匹配,需子路由)
// * splat —— 贪婪匹配剩余所有段
{ path: "docs/*", element: <DocViewer /> } // /docs/a/b/c ✅
{ path: "*", element: <NotFoundPage /> } // /anything ❌ → 404 fallback
// 多参数组合
{
path: "posts/:postId/comments/:commentId",
element: <CommentDetail />, // /posts/1/comments/5
}
```
### Error Route —— 错误边界处理
v7 中每个路由都可以声明 `errorElement`,当 loader/action 抛出异常时自动渲染:
```tsx
{
path: "admin",
loader: adminLoader, // 可能抛 401/403
errorElement: <AdminErrorBoundary />, // 接管错误 UI
}
// AdminErrorBoundary.tsx
import { useRouteError } from "react-router-dom";
function AdminErrorBoundary() {
const error = useRouteError() as Response;
if (error.status === 401) return <Navigate to="/login" replace />;
if (error.status === 403) return <UnauthorizedPage />;
return <GenericErrorPage message={error.data?.message ?? "加载失败"} />;
}
```
> [!warning] 全局 404 位置很重要
> `{ path: "*" }` 必须放在路由列表**最末尾**。React Router 从上到下匹配,一旦命中即停止。如果提前放 `*`,后续所有路由都不会生效。
## Form Actions —— 数据变更流程
`action` 和 `loader` 是 v7 Data Router 的对称设计:`loader` 负责读取(GET),`action` 负责写入(POST/PATCH/DELETE)。
```tsx
{
path: "posts/:postId",
action: async ({ params, request }) => {
const formData = await request.formData();
const title = formData.get("title") as string;
await fetch(`/api/posts/${params.postId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
// 提交后重定向到详情页(PRG 模式,避免重复提交)
return new Response(null, {
status: 302,
headers: { Location: `/posts/${params.postId}` },
});
},
}
```
> [!tip] PRG 模式(Post/Redirect/Get)
> action 执行完毕后**必须 redirect**,否则用户刷新页面时会重复提交表单。这是后端开发的老经验,同样适用于 SPA 的 Data Router 场景。
## 路由守卫与权限控制
权限校验的演进路线:**客户端条件渲染 → Loader 前置检查**。推荐优先使用 loader guard,因为它在服务端渲染和预取场景下也能正确拦截。
```tsx
// ❌ 方案1:条件渲染(简单但不够优雅)
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
// ❌ 方案2:HOC 包装
const withAuth = (Component: React.FC) => {
return (props: any) => {
const { loading, authenticated } = useAuth();
if (loading) return <Spinner />;
if (!authenticated) return <Navigate to="/login" replace />;
return <Component {...props} />;
};
};
// ✅ 方案3:v7 Loader Guard(推荐,可配合 errorElement 统一错误处理)
{
path: "admin",
loader: async () => {
const res = await fetch("/api/me");
if (!res.ok) throw new Response("未登录", { status: 401 });
const user = await res.json();
if (!user.isAdmin) throw new Response("权限不足", { status: 403 });
return user; // 用户信息注入 component props
},
element: <AdminPanel />,
}
```
### 多级权限设计建议
| 层级 | 方式 | 说明 |
|------|------|------|
| URL 层 | `errorElement` + loader | 拦截非法访问,返回 401/403 |
| UI 层 | `useLoaderData()` 判断 | 基于用户角色隐藏按钮/菜单项 |
| API 层 | 后端 JWT / Session | **最终防线**,前端判断必须被后端验证 |
> [!warning] 前端权限 ≠ 安全保障
> 所有前端守卫只影响用户体验,真正的安全必须由后端 API 保障。不要把敏感逻辑放在前端 loader 中。
## 导航 API 对比与选择
| 方式 | 代码示例 | 适用场景 | 保留历史 | Prefetch |
|------|----------|----------|---------|----------|
| `<Link>` | `<Link to="/about">` | 页面内跳转(推荐首选) | ✅ | 可配置 prefetch |
| `navigate()` | `navigate("/home")` | JS 逻辑中触发跳转 | ✅ | ❌ |
| `navigate(-1)` | — | "返回上一页"按钮 | ✅ | N/A |
| `<Navigate replace />` | `<Navigate to="/login" replace />` | 登录后自动重定向,不污染 history | ❌ | N/A |
| `router.navigate()` | TanStack Router 写法 | TS 项目、类型安全导航 | ✅ | 内置 |
### History Entry 管理技巧
```tsx
const navigate = useNavigate();
// 登录成功后替换当前 entry,用户点后退不会回到登录页
navigate("/dashboard", { replace: true });
// 弹窗关闭后回到原来的位置
navigate(-1);
// 带 state 跳转(常用于传递临时状态)
navigate("/settings", { state: { fromTab: "security" } });
// 子页面通过 useLocation().state 读取
```
## 关联笔记
- [[hhs/REACT/3. 生态工具篇/09-状态管理]] — 路由守卫中获取用户信息需配合状态管理或 Context
- [[hhs/REACT/3. 生态工具篇/10-TS + React]] — TanStack Router 的全链路类型安全依赖完善的 TS 基础