16 KiB
tags, create time
| tags | 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 核心概念
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(实际渲染页面的叶子节点)。
// 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 在页面切换时保持存活,避免重复初始化。
{
// /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
],
}
// 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 />是一个占位组件。当用户访问/dashboard/stats时:
- React Router 匹配到
path: "dashboard"这个 layout route,渲染<DashboardLayout />- 在
<DashboardLayout />内部遇到<Outlet />→ 继续向下匹配子路由- 子路由
<StatsPage />被渲染到<Outlet />的位置关键理解:没有
<Outlet />,子路由内容就无法显示!这是 v6/v7 中嵌套路由的唯一通信方式。
[!warning] 常见坑:忘记加 Outlet
// ❌ 错误:layout route 的子路由永远不会渲染 function DashboardLayout() { return <><Sidebar /><StatsPage /></>; // 硬编码了具体页面 } // ✅ 正确:用 Outlet 留出插槽 function DashboardLayout() { return <><Sidebar /><Outlet /></>; }
路由参数获取
React Router 提供了一组 Hook,让我们可以在组件中读取 URL 信息:
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,让数据获取成为路由级别的一等公民:
// 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 />,
}
// 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,用户访问时才下载。
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"的自然过渡:
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 提供了两种参数匹配方式:
// :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 抛出异常时自动渲染:
{
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)。
{
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,因为它在服务端渲染和预取场景下也能正确拦截。
// ❌ 方案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 管理技巧
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 基础