Files
cs-note/hhs/REACT/3. 生态工具篇/08-路由管理.md
T
2026-05-24 11:42:38 +08:00

452 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 基础