--- 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["路由匹配
按 path 从上到下匹配"] Match --> BestMatch["最佳匹配胜出"] BestMatch --> Render["渲染组件树
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 树` → `渲染对应组件` 两条跳转路径: - **声明式导航**:用户点击 `` 组件,浏览器地址栏更新但不刷新 - **程序化导航**: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: , // 所有页面共用的外层 shell children: [ { index: true, element: }, // "/" → 首页 { path: "about", element: }, // "/about" ], }, ]); function App() { return ; } ``` > [!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: , // 侧边栏 + Header 常驻不卸载 children: [ { index: true, element: }, // /dashboard → DashboardHome { path: "stats", element: }, // /dashboard/stats → StatsPage { path: "settings", element: }, // /dashboard/settings → SettingsPage { path: "settings/:tab", element: }, // /dashboard/settings/profile ], } ``` ```tsx // DashboardLayout.tsx import { Outlet } from "react-router-dom"; function DashboardLayout() { return (
{/* 是嵌套内容的"占位槽" */}
); } ``` > [!note] 的本质 > `` 是一个**占位组件**。当用户访问 `/dashboard/stats` 时: > 1. React Router 匹配到 `path: "dashboard"` 这个 layout route,渲染 `` > 2. 在 `` 内部遇到 `` → 继续向下匹配子路由 > 3. 子路由 `` 被渲染到 `` 的位置 > > **关键理解**:没有 ``,子路由内容就无法显示!这是 v6/v7 中嵌套路由的唯一通信方式。 > [!warning] 常见坑:忘记加 Outlet > ```tsx > // ❌ 错误:layout route 的子路由永远不会渲染 > function DashboardLayout() { > return <>; // 硬编码了具体页面 > } > > // ✅ 正确:用 Outlet 留出插槽 > function DashboardLayout() { > return <>; > } > ``` ## 路由参数获取 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

User #{id}, searching for "{query}"

; } ``` ### 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: , } ``` ```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 (

{post.title}

{post.body}

{actionResult?.success &&
保存成功!
}
); } ``` > [!question] loader vs useEffect 获取数据有什么区别? > - **loader**:先取数据 → 数据就绪后 → 才渲染页面。用户不会看到空状态或 loading 骨架屏(配合 Suspense 可以显示 fallback)。 > - **useEffect + useState**:先渲染空白/loading → 请求发出 → 数据回来 → 更新 UI。会有闪烁过程。 > > loader 本质是**服务端渲染(SSR)友好的异步数据模式**。即使不使用 SSR,它的"数据先于渲染"语义也能减少水合闪烁。 ## 懒加载 Code Splitting 代码分割的核心目标:**减少首屏加载体积**。通过 `React.lazy()` + ``,将非关键路由的代码拆成独立 chunk,用户访问时才下载。 ```tsx import { lazy, Suspense } from "react"; // 路由级代码分割 —— 只在组件真正需要时才加载 const AdminPage = lazy(() => import("./pages/AdminPage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); // Route 配置中包裹 Suspense { path: "admin", element: ( }> ), } ``` ### 更优方案: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: ( }> {/* 2. chunk 加载完毕 + loader 数据就绪后渲染 */} ), } ``` > [!tip] 懒加载时机判断 > - **不要懒加载**:首页、登录页等高频入口 > - **适合懒加载**:设置页、管理员面板、低频功能模块 > - **chunk 大小建议**:每个 chunk gzipped 后不超过 100KB > - **公共代码尽量提取**:避免多个 chunk 重复包含 React / utils ## 动态路由与 Splat 路由 URL 中的可变部分是前端路由的核心灵活性来源。React Router 提供了两种参数匹配方式: ```tsx // :param —— 匹配单个路径段 { path: "users/:userId", element: } // /users/42 ✅ // /users/42/posts ❌(不匹配,需子路由) // * splat —— 贪婪匹配剩余所有段 { path: "docs/*", element: } // /docs/a/b/c ✅ { path: "*", element: } // /anything ❌ → 404 fallback // 多参数组合 { path: "posts/:postId/comments/:commentId", element: , // /posts/1/comments/5 } ``` ### Error Route —— 错误边界处理 v7 中每个路由都可以声明 `errorElement`,当 loader/action 抛出异常时自动渲染: ```tsx { path: "admin", loader: adminLoader, // 可能抛 401/403 errorElement: , // 接管错误 UI } // AdminErrorBoundary.tsx import { useRouteError } from "react-router-dom"; function AdminErrorBoundary() { const error = useRouteError() as Response; if (error.status === 401) return ; if (error.status === 403) return ; return ; } ``` > [!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 ; return <>{children}; } // ❌ 方案2:HOC 包装 const withAuth = (Component: React.FC) => { return (props: any) => { const { loading, authenticated } = useAuth(); if (loading) return ; if (!authenticated) return ; return ; }; }; // ✅ 方案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: , } ``` ### 多级权限设计建议 | 层级 | 方式 | 说明 | |------|------|------| | URL 层 | `errorElement` + loader | 拦截非法访问,返回 401/403 | | UI 层 | `useLoaderData()` 判断 | 基于用户角色隐藏按钮/菜单项 | | API 层 | 后端 JWT / Session | **最终防线**,前端判断必须被后端验证 | > [!warning] 前端权限 ≠ 安全保障 > 所有前端守卫只影响用户体验,真正的安全必须由后端 API 保障。不要把敏感逻辑放在前端 loader 中。 ## 导航 API 对比与选择 | 方式 | 代码示例 | 适用场景 | 保留历史 | Prefetch | |------|----------|----------|---------|----------| | `` | `` | 页面内跳转(推荐首选) | ✅ | 可配置 prefetch | | `navigate()` | `navigate("/home")` | JS 逻辑中触发跳转 | ✅ | ❌ | | `navigate(-1)` | — | "返回上一页"按钮 | ✅ | N/A | | `` | `` | 登录后自动重定向,不污染 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 基础