React Router v7 完全指南:一个 Demo 吃透前端路由

不讲 API 列表,而是带你从零搭一个完整的 React 单页应用路由系统。读完你会知道每个 API 为什么存在、什么时候用、以及容易踩的坑。


目录


开篇:你打开的真的是"新页面"吗?

你在浏览器地址栏看到 /home/products/123/about,看起来和传统网站一模一样。但你注意到没有------页面切换时没有白屏,没有 loading 转圈,甚至 DevTools 的 Network 面板里也没有新的 HTML 请求。

这不是魔法,这是前端路由

这篇文章带你用 React Router v7 从零搭建一个完整的前端路由系统,覆盖 7 个核心知识点。每个知识点都会回答三个问题:是什么、为什么需要、怎么写(以及为什么这样写)。


一、前端路由为什么存在

先回到本质:什么是路由?

路由就是一个映射关系------"这个 URL → 那个内容"。浏览器地址栏里的路径变了,页面显示的内容跟着变。

传统做法是这样:

sequenceDiagram participant U as 用户 participant B as 浏览器 participant S as 服务器 U->>B: 点击 /about 链接 B->>S: GET /about.html S-->>B: 200 OK 整个 HTML 文档 B->>B: 清空页面,重新渲染整个 DOM Note over B: 白屏闪烁

每一次导航都是一次 HTTP 往返 + 整页重建。 在 3G 网络或页面很重的时候,用户体验就是"点一下、白一下、等一会儿"。

前端路由的思路是换一种做法:

graph LR A[URL 变化] --> B{谁处理?} B -->|传统| C[服务器返回新 HTML] C --> D[整页刷新] B -->|前端路由| E[JavaScript 拦截] E --> F[局部 DOM 替换] F --> G[丝滑切换无白屏]

核心理念:URL 要变(不同页面得有不同的 URL),但不能每次都找服务器要新 HTML------JavaScript 自己在客户端就把 DOM 换了。

实现方式有两种:Hash 路由 (URL 里带 #)和 History 路由 (URL 干净如 /page1)。本文项目用的是 HashRouter,原理是监听 hashchange 事件------Hash 部分的改变不会触发浏览器发 HTTP 请求,所以页面不会刷新。


先看项目里最基础的路由配置:

jsx 复制代码
// App.jsx --- 路由配置,所有路由的"总开关"
import { HashRouter, Routes, Route } from 'react-router-dom'

<HashRouter>            {/* ① 路由器:包裹一切,决定用 Hash 还是 History 模式 */}
  <Routes>              {/* ② 路由集合:里面放多个 Route,只渲染第一个匹配的 */}
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    <Route path="/user/:id" element={<UserProfile />} />
  </Routes>
</HashRouter>

三个角色,分工明确

组件 职责 类比
HashRouter 监听 URL 变化,提供路由上下文 整个"导航系统"
Routes 扫描所有子 Route,找到匹配的渲染 "导航地图"
Route 一条规则:这个 path 对应那个 element "一条路"

导航链接:为什么不用 <a> 标签?

jsx 复制代码
// ❌ 用 a 标签:点击会触发整页刷新,SPA 状态全丢
<a href="/about">关于我们</a>

// ✅ 用 Link 组件:React Router 拦截点击,只做局部替换
// components/Navigation.jsx
import { Link } from 'react-router-dom'

<Link to="/">Home</Link>
<Link to="/about">About</Link>

<Link> 本质上渲染的还是 <a> 标签,但它拦截了点击事件,阻止浏览器默认的页面跳转行为,改为用 JavaScript 更新 URL 并触发组件切换。

⚠️ 新手坑 :用 <a> 做 SPA 内部导航。URL 是变了,但页面整个刷新了,之前的状态(表单输入、滚动位置、Redux store)全没了。除了跳转到外部网站,SPA 内部一律用 <Link>


三、路由懒加载:首页快三秒的秘密

如果项目有 20 个页面,用户打开首页就要下载 20 个页面的 JS 代码------但他可能只看首页。

jsx 复制代码
// App.jsx
import { lazy, Suspense } from 'react'

// 🔑 webpack/vite 看到 import() 就会把每个页面拆成独立的 JS chunk
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const UserProfile = lazy(() => import('./pages/UserProfile'))

// ⚠️ 懒加载的组件必须用 Suspense 包裹,
// 因为加载需要时间,React 需要知道"还没加载完时显示什么"
<Suspense fallback={<div>Loading...</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    {/* ... */}
  </Routes>
</Suspense>

lazy() 告诉打包工具:"这个组件单独打包,访问到了再下载。" Suspensefallback 则是下载期间的占位 UI。

加载时机一目了然:

graph TD A[用户访问 /] --> B[下载 Home chunk] A --> C[不下载 About chunk] A --> D[不下载 UserProfile chunk] E[用户点击 About 链接] --> F[下载 About chunk] style C fill:#444,stroke:#555 style D fill:#444,stroke:#555

效果:首页从"下载全部 20 个页面"变成"只下载当前 1 个页面",首屏加载量降低 80% 以上。


四、嵌套路由:页面套页面的结构之美

实际项目中,页面很少是"平铺"的。比如产品模块有两个子页面------产品详情新增产品 ,它们都挂在 /products 下面:

jsx 复制代码
// App.jsx --- 嵌套路由:子 Route 写在父 Route 标签内部
<Route path="/products" element={<Products />}>
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

注意子路由的 path相对路径":productId" 不是 "/products/:productId")。React Router 会自动拼接成 /products/:productId

父组件 Products 里需要一个"插槽"来放子路由的内容:

jsx 复制代码
// pages/products/index.jsx
import { Outlet } from 'react-router-dom'

const Products = () => <Outlet />
// 🔑 Outlet 是"子路由的渲染位":访问 /products/123 时这里渲染 Detail
// 访问 /products/new 时这里渲染 NewProduct

路由匹配过程如下:

graph TD A[&#34;URL: /products/123&#34;] --> B[&#34;匹配 Route /products&#34;] B --> C[&#34;渲染 Products 组件&#34;] C --> D[&#34;Products 里 Outlet 等待子路由&#34;] D --> E[&#34;继续匹配子路由 :productId&#34;] E --> F[&#34;渲染 ProductDetail 到 Outlet 位置&#34;] F --> G[&#34;最终输出:h1 产品列表 + h3 产品详情-123&#34;]

一句话理解嵌套路由 :父 Route 负责"框架"(侧边栏、面包屑、模块标题),Outlet 是留给子页面的"内容区"。URL 每深入一层,就在 Outlet 里多渲染一层。


五、动态路由参数:同一个组件渲染不同数据

/user/123/user/456 用的都是 UserProfile 组件,但展示的是不同用户。useParams 负责从 URL 中提取动态部分:

jsx 复制代码
// pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom'

const UserProfile = () => {
  const { id } = useParams()  // 🔑 从 URL 中解出 :id 的值
  console.log(id)             // 访问 /user/123 → "123"
  return <p>用户 ID: {id}</p>
}

路由配置中 :id 的冒号前缀表示"这是一个动态段",React Router 会把它提取出来放到 useParams() 返回的对象里。结合嵌套路由使用也一样------/products/:productId 匹配到的 productId,在 ProductDetail 及它的任何更深层子组件中,都可通过 useParams() 拿到。

真实场景通常会配合 useEffect 发请求:

jsx 复制代码
const { productId } = useParams()
useEffect(() => {
  fetch(`/api/products/${productId}`).then(/* ... */)
}, [productId])  // ⚠️ 别忘了依赖:productId 变了要重新请求

Link 是"用户主动点击",但有些场景需要"代码自动跳"------比如提交表单成功后跳到列表页、登录过期后跳到登录页、404 页面倒计时后跳回首页。

useNavigate 就是干这个的:

jsx 复制代码
// pages/NotFound/index.jsx --- 3 秒后自动跳回首页
import { useNavigate } from 'react-router-dom'

const navigate = useNavigate()
useEffect(() => {
  setTimeout(() => navigate('/'), 3000)
}, [])

navigate() 的工作方式和点击 <Link to="/"> 一模一样------都经过 React Router 的拦截,不会触发整页刷新。如果用 window.location.href = '/' 也能跳,但那是"真·页面跳转"------整页刷新、状态全丢。

三者的使用场景

方式 场景 刷新页面?
<Link to="/"> 导航栏、菜单 ❌ 不刷新
navigate('/') 表单提交后、权限不够跳登录 ❌ 不刷新
window.location.href 跳转到外部网站(唯一推荐场景) ✅ 刷新

七、404 兜底:优雅的"页面不存在"

用户输入了一个不存在的 URL,不能给他看白屏或报错。path="*" 通配符匹配所有未被上面 Route 捕获的路径:

jsx 复制代码
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
  {/* ... 其他路由 ... */}

  {/* 🔑 放在最后!匹配所有上面没匹配到的 URL */}
  <Route path="*" element={<NotFound />} />
</Routes>

⚠️ 重点坑:* 必须放在 <Routes> 的最后面。 React Router 是从上到下 匹配的,* 能匹配任何路径。放在前面的话,后面的所有 Route 都不会被匹配到------用户永远看不到 Home、About,全是 404。


八、布局决策:父路由放什么、子路由放什么

这是实际开发中最常碰到的设计问题。回顾我们的项目结构:

bash 复制代码
/products              → Products 组件(布局层)
  /products/:productId → ProductDetail(内容层)
  /products/new        → NewProduct(内容层)

什么放父路由? 所有子页面共同需要的东西。比如侧边栏、面包屑、模块导航。

什么放子路由? 只有特定页面需要的东西。比如"产品列表"标题------只有详情页需要,新增页不需要。

jsx 复制代码
// ✅ 正确:父路由只做"框架",子路由各自负责内容
// Products --- 纯容器,没有自己的 UI
const Products = () => <Outlet />

// Detail --- 自己需要"产品列表"标题,自己加
const ProductDetail = () => {
  const { productId } = useParams()
  return (
    <>
      <h1>产品列表</h1>
      <h3>产品详情-{productId}</h3>
    </>
  )
}

// New --- 不需要标题,什么都不用管
const NewProduct = () => <h1>新增产品</h1>

判断标准一句话:加一段 UI 到父路由之后,问自己------"这段 UI 出现在 /products/new,合理吗?" 不合理就往下挪。

如果未来产品模块的 3 个子页面都需要同一个侧边栏,那再把侧边栏提回 Products 里------这和 React 的 state 提升是同一个思路,只不过提升的是 UI 而不是状态。


结尾:一张图回顾全部知识点

graph TD A[HashRouter 包裹一切] --> B[Routes 路由集合] B --> C1[Route / → Home] B --> C2[Route /about → About] B --> C3[Route /user/:id → UserProfile] B --> C4[Route /products → Products] B --> C5[Route * → NotFound] C4 --> D1[Outlet] D1 --> E1[子 Route :productId → Detail] D1 --> E2[子 Route new → NewProduct] C1 -.-> F1[lazy + Suspense 懒加载] C2 -.-> F1 C3 -.-> F2[useParams 拿动态参数] C5 -.-> F3[useNavigate 3秒后跳回]

这个 Demo 里包含了 React Router 的 7 个核心知识点

# 知识点 文件
1 路由基础配置(HashRouter / Routes / Route) App.jsx
2 Link 导航(不用 a 标签) Navigation.jsx
3 路由懒加载(lazy + Suspense) App.jsx
4 嵌套路由(Outlet) App.jsx + products/index.jsx
5 动态参数(useParams) UserProfile/index.jsx + Detail/index.jsx
6 编程式导航(useNavigate) NotFound/index.jsx
7 404 兜底(path="*") App.jsx

前端路由的本质就是用 JavaScript 接管了原本属于服务器的"URL → 内容"映射。React Router 在这个基础上做了三件事:声明式配置、组件化路由、性能自动优化。理解了这七个知识点,你就掌握了 React 前端路由的全貌。

下次你搭新项目的路由时,打开这个 Demo,照着这个结构搭就行------懒加载提速、嵌套路由分层、404 兜底,一个都不少。


相关推荐
烬羽1 小时前
React Router 嵌套路由:一个 `Outlet` 引发的布局思考
react.js·面试·前端框架
烬羽1 小时前
从"整个页面刷新"到"丝滑切换":手写一个 HashRouter 彻底搞懂前端路由
javascript·前端框架·全栈
用户938515635072 小时前
从零理解 React Router v6:每一个 API 都是怎么工作的
前端·javascript·全栈
kyriewen4 小时前
别再这样写条件渲染了——你的React组件里藏着这5种定时炸弹
前端·javascript·react.js
梦想CAD控件5 小时前
网页端CAD的图形选择、编辑与夹点操作教程
前端·javascript·node.js
月月大王的3D日记6 小时前
Three.js 入门系列(8):六种光源全解析 —— 关灯了,开光!
前端·javascript
自然 醒6 小时前
v-tooltip自定义指令封装
前端·javascript·vue.js
mONESY7 小时前
🎨 从零搭建 React + TypeScript + Vite 项目:以 Color Picker 为例,聊聊前端工程化的 model/api 分层架构
react.js
其美杰布-富贵-李8 小时前
第 10 篇:灯光与阴影
javascript·three.js