This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/3. 生态工具篇/08-路由管理.md
T
2026-04-29 22:28:21 +08:00

16 KiB
Raw Blame History

tags, create time
tags create time
React
Router
Navigation
Frontend
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 时:

  1. React Router 匹配到 path: "dashboard" 这个 layout route,渲染 <DashboardLayout />
  2. 在 <DashboardLayout /> 内部遇到 <Outlet /> → 继续向下匹配子路由
  3. 子路由 <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 基础