Vite + React 19 + Router v7,一文搞懂 HashRouter、嵌套路由、懒加载和 404 兜底

140 阅读7分钟

Vite + React 19 + Router v7,一文搞懂 HashRouter、嵌套路由、懒加载和 404 兜底

从零搭建一个 SPA 路由系统,涵盖 HashRouter、嵌套路由、路由懒加载、参数传递、重定向和 404 兜底——每个知识点都配有"踩坑提醒"。

前言

如果你正在学习 React 路由,大概率会碰到这些问题:

  • HashRouterBrowserRouter 到底有什么区别?
  • 嵌套路由的 <Outlet /> 为什么写了也没渲染?
  • useParams() 明明调了,为啥拿到的值是 undefined
  • 路由懒加载怎么配 <Suspense> 才不报错?

这篇文章用一个完整的 Demo 项目,把 React Router v7 的核心 API 逐一拆解。适合刚学 React 路由、想系统梳理知识点的同学阅读。文末附有完整可运行的项目代码,克隆即用。

读完你会收获:

  • 一套可运行的 SPA 路由项目结构
  • HashRouter / Routes / Route / Link / Outlet / useParams / Navigate / useNavigate 的实战用法
  • 路由懒加载 + Suspense 的正确搭配
  • 至少 3 个"不看不知道"的坑

项目概览

这是一个基于 Vite + React 19 + react-router-dom v7 的 Hash 模式单页应用,包含 6 个页面 + 导航栏。

组件树

App
├── HashRouter
│   └── Suspense (Loading... 兜底)
│       ├── Navigation          ← 导航栏,Link 跳转
│       └── Routes
│           ├── "/"             → Home
│           ├── "/about"        → About
│           ├── "/user/:id"UserProfile      (动态参数)
│           ├── "/products"Products          (父路由)
│           │   ├── ":productsId"ProductDetail   (子路由)
│           │   └── "new"NewProduct       (子路由)
│           ├── "/old-path"     → Navigate 重定向到 /new-path
│           └── "*"NotFound          (404 兜底)

核心技术点

类别用到的 API
路由模式HashRouter
路由配置RoutesRoute
导航Link
动态参数useParams
嵌套路由Outlet
懒加载React.lazy + Suspense
重定向Navigate
编程式跳转useNavigate

核心知识点:深入 3 个最重要的概念

知识点 1:HashRouter — URL 里为什么有个 #

它是什么?

HashRouter 利用 URL 中 # 后面的部分(叫 hash)来管理前端路由。# 后面的内容变化时,浏览器不会向服务器发请求,页面也不会刷新。

https://example.com/#/user/123
                       ↑
                     这一整段叫 hash

为什么这个项目用 HashRouter 而不是 BrowserRouter?

HashRouterBrowserRouter
URL 样子xxx.com/#/aboutxxx.com/about
刷新页面不会 404需要服务端配置(否则 404)
部署难度零配置,丢服务器就能跑需要 nginx/Caddy 配置 fallback
适用场景演示项目、静态部署、GitHub Pages生产应用(配合服务端)

一句话:HashRouter 省心,BrowserRouter 好看。Demo 项目、静态托管用 HashRouter;正式上线且有后端支持用 BrowserRouter。

在这个项目里怎么用的?

// App.jsx
import { HashRouter as Router } from 'react-router-dom';

<Router>
  <Navigation />
  <Routes>
    <Route path="/" element={<Home />} />
    {/* ... */}
  </Routes>
</Router>

HashRouter 像一个"外壳",包裹整个应用,接管所有路由逻辑。


知识点 2:嵌套路由 + Outlet — 父组件不变,子组件切换

它是什么?

嵌套路由让你把页面的公共部分写在父组件里,子路由只负责变化的内容

用生活场景类比:

淘宝商品列表页,顶部导航栏和侧边筛选栏永远在那里,切换"手机"或"电脑"分类时,只有中间的商品区域在变。父组件 = 壳子(导航+筛选栏),子公司 = 中间那坨商品卡片。

在这个项目里怎么用的?

路由配置(App.jsx):

<Route path="/products" element={<Products />} >       {/* 父:壳子 */}
  <Route path=":productsId" element={<ProductDetail />} />  {/* 子:详情 */}
  <Route path="new" element={<NewProduct />} />             {/* 子:新增 */}
</Route>

父组件(Products/index.jsx):

import { Outlet } from 'react-router-dom';

const Products = () => (
  <>
    <h1>产品列表</h1>    {/* 公共标题,永远显示 */}
    <Outlet />           {/* 子组件渲染在这里 */}
  </>
);

实际效果:

访问 URL<h1> 产品列表 </h1><Outlet /> 里渲染
/products✅ 显示
/products/123✅ 显示<ProductDetail />
/products/new✅ 显示<NewProduct />

踩坑提醒<Outlet /> 必须写在父组件的 return 里,不是写在路由配置里!很多人把 <Outlet /> 当成 <Route> 的属性来配,怎么都不生效,原因就在这。


知识点 3:React.lazy + Suspense — 按需加载,首页不卡

它是什么?

默认情况下,import Home from './pages/Home' 会把 Home 组件的代码打包进主文件——用户不管访问哪个页面,都要下载全部页面的代码。

React.lazy() 配合 import() 实现动态导入:只有用户真正访问某个页面时,才去下载那个页面的 JS。

在这个项目里怎么用的?

// App.jsx
import { lazy, Suspense } from 'react';

// ❌ 静态导入:所有页面一起下载
// import Home from './pages/Home';

// ✅ 动态导入:访问时才下载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/Products/ProductDetail'));
const NewProduct = lazy(() => import('./pages/Products/NewProduct'));

Suspense 的作用

lazy 组件在下载时,React 需要显示一个"加载中"的界面——这就是 <Suspense>fallback

<Suspense fallback={<div>Loading...</div>}>
  <Navigation />
  <Routes>
    <Route path="/" element={<Home />} />
    {/* ... */}
  </Routes>
</Suspense>

当用户首次访问 /about 时,流程是这样的:

点击"About"链接
  ↓
React 发现 About 组件还没下载
  ↓
显示 fallback:<div>Loading...</div>   ← 用户看到加载提示
  ↓
异步下载 About 的 JS 文件
  ↓
下载完成,替换为 <About /> 组件        ← 用户看到页面内容

踩坑提醒<Suspense> 必须包裹 lazy 组件,否则 React 会报 A React component suspended while rendering 错误。


逐组件拆解

Navigation — 导航栏

import { Link } from 'react-router-dom';

function Navigation() {
  return (
    <nav>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/user/123">小家</Link></li>
        <li><Link to="/products">产品列表</Link></li>
        <li><Link to="/products/new">新增产品</Link></li>
        <li><Link to="/products/123">产品详情</Link></li>
      </ul>
    </nav>
  );
}

为什么用 <Link> 而不是 <a>

<a href="/about"><Link to="/about">
页面刷新整个页面刷新,白屏闪烁不刷新,局部替换
SPA 体验❌ 失去 SPA 优势✅ 流畅切换
状态保持❌ 丢失✅ 保留

<Link> 底层调用了 history.pushState(),改变 URL 但不触发浏览器刷新——这是 SPA 体验的基石。


UserProfile — 动态路由参数

import { useParams } from 'react-router-dom';

function UserProfile() {
    const { id } = useParams();   // 从 URL 中提取参数
    return <h1>UserProfile {id}</h1>;
}

链路:

路由规则  /user/:id           ← :id 是占位符,定义了参数名
用户访问  /user/小家           ← 把 "小家" 填入 :id 的位置
useParams() 返回  { id: "小家" }  ← 组件拿到参数值
页面显示  UserProfile 小家

Products + ProductDetail — 嵌套路由 + 参数名匹配问题 ⚠️

路由配置:

<Route path="/products" element={<Products />} >
  <Route path=":productsId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

注意这里有两个子路由:

  • :productsId — 动态参数,匹配 /products/123/products/abc
  • new — 固定路径,精确匹配 /products/new

ProductDetail 组件:

function ProductDetail() {
    const { productId } = useParams();  // 从 URL 中读取 productId
    return <h3>产品详情 {productId}</h3>;
}

🔴 这里有一个极易被忽视的坑!

路由里定义的是 :productsId(带 s),但组件里解构的是 productId(不带 s)。 参数名必须完全一致,否则 productId 永远是 undefined

useParams() 返回的是一个对象,key 就是路由里 : 后面的名字。path=":productsId" 意味着 useParams() 返回 { productsId: '123' },取 productId 当然取不到。

正确写法:const { productsId } = useParams();


NotFound — 404 兜底 + 编程式导航

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const NotFound = () => {
    const navigate = useNavigate();

    useEffect(() => {
        setTimeout(() => {
            navigate('/');     // 3 秒后自动跳回首页
        }, 3000);
    }, []);

    return <h1>404 Not Found</h1>;
};

两个关键点:

  1. path="*" 兜底匹配 — 放在 <Routes> 的最后,所有未匹配的路径都会被它捕获:

    <Route path="*" element={<NotFound />} />
    
  2. useNavigate 编程式跳转 — 不用 <Link>,而是在代码逻辑里主动跳转。适合"3 秒后自动跳回首页"这种场景。


Navigate — 路由重定向

<Route path="old-path" element={
    <Navigate replace to="/new-path" />} />

访问 /old-path 时,自动跳转到 /new-pathreplace 属性表示替换当前历史记录(用户点"后退"不会回到 old-path),不加 replace 则保留历史记录。


总结

回顾一下你从这个项目能带走的核心知识:

  1. HashRouter# 后面的路径变化不触发浏览器刷新,适合静态部署
  2. Routes + Route — 声明式路由配置,path 匹配 URL,element 指定渲染组件
  3. Link 替代 <a> — 保持 SPA 体验,不刷新页面
  4. :xxx 动态参数 + useParams() — 参数名必须和路由定义一致,否则取到 undefined
  5. 嵌套路由 + <Outlet /> — 父组件写公共布局,子路由内容填入 Outlet 占位符
  6. React.lazy + <Suspense> — 按需加载页面,减少首屏体积
  7. path="*" 兜底 + useNavigate / Navigate — 404 页面 + 自动跳转 / 重定向

下一步可以扩展的方向:添加路由守卫(登录鉴权)、面包屑导航、路由过渡动画、配合 Zustand/Redux 做全局状态管理。


完整项目代码

项目代码已托管在 Gitee,克隆即可运行:

🔗 仓库地址gitee.com/dcx2758/ai_…

# 克隆整个仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git

# 进入项目目录
cd ai_doubao_dcx/fe/react/router/react-route-demo

# 安装依赖
npm install

# 启动开发服务器
npm run dev

# 浏览器访问
# http://localhost:5173

项目结构

react-route-demo/
├── package.json
├── vite.config.js
├── index.html
└── src/
    ├── main.jsx                # 入口文件
    ├── App.jsx                 # 路由配置核心
    ├── App.css
    ├── index.css
    ├── components/
    │   └── Navigation.jsx      # 导航栏组件
    └── pages/
        ├── Home/
        │   └── index.jsx       # 首页
        ├── About/
        │   └── index.jsx       # 关于页
        ├── UserProfile/
        │   └── index.jsx       # 用户页(动态参数)
        ├── Products/
        │   ├── index.jsx       # 产品列表(嵌套路由父组件)
        │   ├── ProductDetail/
        │   │   └── index.jsx   # 产品详情(子路由)
        │   └── NewProduct/
        │       └── index.jsx   # 新增产品(子路由)
        └── NotFound/
            └── index.jsx       # 404 页面

技术栈

依赖版本
Vite^8.0.12
React^19.2.6
react-dom^19.2.6
react-router-dom^7.18.2

npm install 后即可运行,无需额外配置。


写代码的路上,路由是你绕不过去的一关。希望这篇文章帮你少踩几个坑 🍀 觉得有用的话欢迎点赞收藏,有问题评论区交流 👏