React Router v6 实战:从零构建 SPA 路由系统
前言
在单页应用(SPA)开发中,前端路由是不可或缺的核心能力。React Router 作为 React 生态中最流行的路由库,在 v6 版本中带来了许多简洁而强大的 API。本文将通过一个完整的实战项目,带你从入门到进阶,系统掌握 React Router v6 的核心用法。
项目结构一览:
bash
src/
├── App.jsx # 路由配置核心
├── component/
│ └── navigation.jsx # 导航栏组件
└── pages/
├── Home/index.jsx # 首页
├── About/index.jsx # 关于页
├── UserProfile/index.jsx # 用户详情(动态路由)
├── NotFound/index.jsx # 404 页面
└── Products/
├── index.jsx # 商品列表(嵌套路由父级)
├── Detail/index.jsx # 商品详情(二级路由)
└── New/index.jsx # 新增商品(二级路由)
一、路由容器:选用 HashRouter
jsx
import { HashRouter as Router } from 'react-router-dom';
const App = () => {
return (
<Router>
{/* 路由内容 */}
</Router>
);
};
这里选择了 HashRouter。它与 BrowserRouter 的区别在于:
| 对比维度 | HashRouter | BrowserRouter |
|---|---|---|
| URL 形态 | /#/about |
/about |
| 服务端要求 | 无需配置 | 需配置 fallback |
| 适用场景 | 静态部署、Electron | 需要 SEO 的生产环境 |
HashRouter 的原理是利用 location.hash 的变化来模拟页面切换,URL 中 # 之后的部分不会被发送到服务器,因此即使将项目部署到任意静态文件服务器上也不会出现 404------这对演示项目和内部工具来说非常友好。
二、路由懒加载:告别首屏全量打包
jsx
import { lazy, Suspense } from 'react';
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 ProductDetail = lazy(() => import('./pages/Products/Detail'));
const Products = lazy(() => import('./pages/Products'));
const NewProduct = lazy(() => import('./pages/Products/New'));
为什么需要懒加载?
在没有懒加载的情况下,所有页面组件会在构建时被打包进同一个 bundle。用户访问首页时,About 页、User 页、商品详情页的代码也会被一起下载------即便用户根本不会访问它们。随着页面增多,首屏加载会越来越慢。
lazy() 做了什么?
它利用 ES 的动态 import() 语法。Vite 或 Webpack 在构建时遇到 import('./pages/Home'),会将 Home 组件拆分成一个独立的 chunk 文件。只有当用户真正访问 / 路径时,这个 chunk 才会被下载和执行。
Suspense 的作用:
jsx
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
{/* ... */}
</Routes>
</Suspense>
懒加载是异步的------从发起请求到组件代码就绪之间存在一个"空窗期"。Suspense 的 fallback 属性定义了这段时间内显示的内容。你可以把它替换成一个骨架屏或加载动画,提升用户体验。
一个关键细节是:console.log('loading Home') 写在组件定义之后,在懒加载模式下,这个 log 只会在首次访问该路由时执行一次------你可以打开控制台验证,当你在导航栏来回切换时,每个页面模块只会被加载一次,后续访问直接从缓存读取。
三、声明式导航:<Link> 替代 <a>
jsx
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/123">商品详情</Link></li>
<li><Link to="/products/new">新增商品</Link></li>
</ul>
</nav>
);
}
为什么不能用 <a>?
SPA 的核心价值之一是 无刷新切换页面 。如果使用原生 <a href="/about">,浏览器会向服务器发送一个完整的 HTTP 请求,导致整个页面重新加载------React 的状态、内存中的一切都会丢失,首屏加载的优化也化为乌有。
<Link> 组件本质上是增强版的 <a>:它阻止了默认的跳转行为,改为通过 history API 更新 URL 并触发 React Router 的内部匹配逻辑,整个过程不刷新页面,组件树在同一个 React 渲染周期内完成切换。
四、路由配置:<Routes> 与 <Route>
jsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
<Route path="old-path" element={<Navigate replace to="/new-path" />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
这是整个应用的路由配置表,也是 React Router v6 的核心。我们逐一深入。
4.1 精确匹配:path="/"
v6 默认使用精确匹配(exact matching)。path="/" 只会匹配 URL 为 / 的情况,不会像 v5 那样模糊匹配到所有路径。这意味着你不再需要手动添加 exact 属性。
<Routes> 的内部是一个"排他性"的匹配过程------它会遍历所有 <Route>,找到第一个 匹配的路径并渲染,其余忽略。这就是为什么 404 路由放在最后------* 匹配一切,放在最前面会把所有路径都吞掉。
4.2 动态路由参数:/user/:id
jsx
// 路由定义
<Route path="/user/:id" element={<UserProfile />} />
// UserProfile 组件中获取参数
import { useParams } from 'react-router-dom';
function UserProfile() {
let { id } = useParams();
return <h2>User Profile: {id}</h2>;
}
:id 是一个动态段(dynamic segment) ------冒号前缀告诉 React Router 这是一个占位符,匹配任意非空字符串。当用户访问 /user/123 时,id 的值为 "123";访问 /user/小陈 时,id 的值为 "小陈"。
useParams() 是 v6 提供的 Hook,返回一个对象,键名为你在路由配置中写的参数名。你可以在一个路径中使用多个参数:/user/:userId/post/:postId。
4.3 嵌套路由(Nested Routes)
嵌套路由是 v6 中最精华的设计之一,它完美映射了 UI 的层级关系。
jsx
// 父级路由:Products
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
Products(父组件)必须使用 <Outlet />:
jsx
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>Products</h1>
<Outlet /> {/* 👈 子路由的组件渲染在这里 */}
</>
);
};
<Outlet /> 是嵌套路由的"插槽"------它告诉 React Router:"子路由匹配到的组件,渲染到这里来。" 这使得布局复用变得极其自然:
- 访问
/products→ 只渲染<h1>Products</h1>,<Outlet />位置为空 - 访问
/products/123→ Products 页面内,<Outlet />渲染 ProductDetail 组件 - 访问
/products/new→ Products 页面内,<Outlet />渲染 NewProduct 组件
这种嵌套关系完美契合了"商品模块"的业务直觉------所有商品子页面共享商品大标题和导航。
4.4 路由重定向:<Navigate>
jsx
<Route path="old-path" element={<Navigate replace to="/new-path" />} />
当用户访问 /products/old-path 时,<Navigate> 组件会立即将 URL 替换为 /new-path。replace 属性表示用新 URL 替换当前的历史记录条目(而非追加),这意味着用户点击"后退"时不会回到旧路径------这正是重定向场景下的预期行为。
4.5 404 兜底:path="*"
jsx
<Route path="*" element={<NotFound />} />
* 是通配符,匹配所有不在以上配置中的路径。把它放在 <Routes> 的最后,它就充当了"兜底"角色------所有合法路由都没匹配上时,渲染 404 页面。
五、编程式导航:useNavigate
jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate('/'); // 3 秒后自动跳回首页
}, 3000);
}, []);
return <h1>404 Not Found</h1>;
};
有些场景下,你无法使用 <Link> 进行跳转------比如提交表单后、定时器回调中、或者需要根据异步操作结果决定跳转目标时。useNavigate() 返回一个函数,你可以在任何逻辑中调用它来触发路由跳转。
在这个 404 页面中,我们实现了一个友好的"自动回首页"体验:用户看到 404 提示后,3 秒倒计时自动跳回首页,无需手动操作。相较于传统的 window.location.href = '/',navigate('/') 同样是 SPA 内部的无刷新跳转,不产生额外的网络请求。
六、完整数据流
让我们梳理一次完整的用户操作流程,串联以上所有知识点:
bash
用户点击 "商品详情" 链接
│
▼
<Link to="/products/123"> 拦截点击事件
│
▼
HashRouter 更新 URL 为 /#/products/123
│
▼
<Routes> 进行路由匹配
├─ / ✗ 不匹配
├─ /about ✗ 不匹配
├─ /user/:id ✗ 不匹配
├─ /products ✔ 匹配前缀!
│ └─ 继续匹配子路由
│ ├─ :productId ✔ "123" 匹配!
│ │ → 渲染 <ProductDetail />
│ └─ new ✗
└─
│
▼
懒加载检查:ProductDetail 的 chunk 是否已缓存?
├─ 否 → 下载 chunk → 解析组件代码
└─ 是 → 直接渲染
│
▼
Products 组件渲染:
<h1>Products</h1>
<Outlet /> → 渲染 <ProductDetail />
│
▼
ProductDetail 内部:
useParams() 获取 { productId: "123" }
→ 渲染 <h1>ProductDetail: 123</h1>
整个过程没有一次完整的页面刷新,所有的 URL 变化、组件切换都在客户端完成。
七、最佳实践总结
基于本项目,提炼出几条在 React Router v6 开发中值得遵循的原则:
-
路由懒加载是标配 :只要是页面级组件,用
lazy(() => import(...))包裹,不要犹豫。打包体积的减负是实打实的。 -
嵌套路由 +
<Outlet />取代手动布局:当你发现自己在多个页面中重复写相同的 Header/Sidebar 时,应该立刻想到用嵌套路由抽离布局层。 -
<Navigate>处理重定向,useNavigate处理逻辑跳转:前者用于路由配置中的声明式重定向,后者用于事件回调、异步流程中的编程式跳转,各司其职。 -
404 放最后 :
path="*"总是放在<Routes>的末尾,这是"兜底"语义的直观体现,也是 v6 匹配机制的要求。 -
善用
useParams而非手动解析 :动态路由参数直接通过 Hook 获取,类型安全、语义清晰,不要自己去切window.location字符串。
结语
React Router v6 用更少的 API 实现了更强的表达能力。从 lazy() + Suspense 的懒加载,到嵌套路由与 <Outlet /> 的布局复用,再到 useParams / useNavigate 的函数式 Hook,每个设计都在引导开发者写出更清晰、更可维护的路由代码。
本文的完整项目代码涵盖了 React Router v6 的绝大部分日常开发场景,你可以以此为起点,在此基础上加入路由守卫、面包屑、路由动画等进阶特性。