做 SPA 项目,路由是绕不过去的一关。
页面切不动、白屏刷新、登录后跳不回原页面、DataCloneError 报错…… 这些坑,基本每个写 React 的人都踩过。
这篇文章带你从零搭一套完整可用的路由系统:懒加载、动态路由、嵌套路由、重定向、404 兜底、鉴权拦截,全部实战代码,复制就能跑。
看完你能解决三件事:
- 搞懂前端路由到底在干什么
- 配出一套生产可用的路由方案
- 避开鉴权跳转的两个经典大坑

一、为什么需要前端路由
先说清楚一件事:传统路由是后端的事。
用户点个链接,浏览器发请求,后端返回新页面,屏幕白一下,体验拉胯。
前后端分离后,前端接管了页面切换,这就是 SPA(单页应用)。
核心思路一句话:
URL 变了,但页面不刷新,由 JS 来切换显示的组件。
React 生态里,干这件事的就是 react-router-dom。
二、路由选型:HashRouter 还是 BrowserRouter
这俩都能用,但原理不同。
HashRouter
URL 长这样:http://localhost:5173/#/pay
- 改的是 hash 部分(# 后面)
- 改 hash 不会刷新页面,监听 hashchange 事件就行
- 缺点:URL 有点丑
BrowserRouter
URL 长这样:http://localhost:5173/pay
- 走的是 HTML5 的 history API(pushState / replaceState)
- URL 干净,符合 RESTful 风格
- 缺点:部署时服务端要配置回退到 index.html,否则刷新就 404
本项目用的是 BrowserRouter:
import { BrowserRouter as Router } from 'react-router-dom';
<Router>
{/* 应用内容 */}
</Router>
新手建议:本地开发用 BrowserRouter 没问题,上线前记得配 nginx,不然刷新页面直接白屏。
三、路由配置实战:一套配齐 7 种用法
直接看核心配置文件,我把每种用法都标了注释:
import { lazy, Suspense } from 'react';
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from 'react-router-dom';
import Navigation from './component/Navigation';
import ProtectRoute from './ProtectRoute';
import Pay from './pages/Pay';
// 1. 路由懒加载:按需加载,提升首页速度
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./Products'));
const ProductDetail = lazy(() => import('./Products/ProductDetail'));
const NewProduct = lazy(() => import('./Products/New'));
const Login = lazy(() => import('./pages/Login'));
const App = () => {
return (
<Router>
<Suspense fallback={<div>等等我呗…</div>}>
<Navigation />
<div id="container">
<Routes>
{/* 基础路由 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* 2. 动态路由:冒号占位 */}
<Route path="/user/:id" element={<User />} />
{/* 3. 嵌套路由:多级菜单 */}
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
{/* 4. 重定向:旧路径跳新路径 */}
<Route path="/old-path" element={
<Navigate replace to="/products/new" />
} />
{/* 5. 鉴权路由:包裹一层门禁 */}
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
{/* 6. 404 兜底:* 贪婪匹配所有未命中的路径 */}
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</Suspense>
</Router>
);
};
export default App;
逐个拆开说。
1. 懒加载:别让首页背全量包
lazy + Suspense 是性能优化的标配。
没有懒加载,所有页面打包进一个 bundle,首页加载慢得像蜗牛。
用了懒加载,访问哪个页面才加载哪个 chunk,首页体积直接瘦下来。
Suspense 的 fallback 是加载时的占位,给用户一个"正在加载"的反馈。
2. 动态路由:一个参数搞定详情页
<Route path="/user/:id" element={<User />} />
/user/123、/user/456 共用同一个组件,组件里用 useParams() 取参数:
import { useParams } from 'react-router-dom';
const User = () => {
const { id } = useParams();
return <div>用户ID:{id}</div>;
};
详情页、编辑页都靠这一招。
3. 嵌套路由:父子结构清晰
产品模块下有列表、详情、新建,用嵌套路由最清晰:
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
父组件 Products 里用 <Outlet /> 占位,子路由会渲染在占位处:
import { Outlet } from 'react-router-dom';
const Products = () => (
<div>
<h1>产品列表</h1>
<Outlet />
</div>
);
4. 重定向:旧路径平滑迁移
<Route path="/old-path" element={<Navigate replace to="/products/new" />} />
replace 表示替换历史记录,用户点后退不会回到旧路径。
5. 404 兜底
<Route path="*" element={<NotFound />} />
* 是通配符,放在最后,前面没匹配上的全归它。
四、Link 组件:别用 a 标签
导航栏用 Link,别用 <a>:
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">User</Link></li>
<li><Link to="/products/123">产品详情</Link></li>
<li><Link to="/pay">支付</Link></li>
</ul>
</nav>
);
}
为什么不能用 <a>?
因为 <a> 会触发整页刷新,SPA 直接废了。
Link 内部调用 history.pushState,只改 URL 不刷新,这才是 SPA 该有的样子。
五、鉴权路由:本文的重点
这节是全文干货密度最高的地方,两个坑我挨个讲。
需求场景
用户没登录就想访问 /pay?拦下来,跳到登录页。
登录成功后,自动跳回他本来想去的 /pay,而不是傻乎乎地跳到首页。
门禁组件:ProtectRoute
import { Navigate, useLocation } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
const isLogin = localStorage.getItem('isLogin') === 'true';
const location = useLocation();
if (!isLogin) {
// 把"从哪来"的信息塞进 state,登录页取出来跳回去
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <div>{children}</div>;
};
这里的关键是 children。
children 是 React 的"插槽"机制——父组件包裹的子节点,会作为 props.children 传进来。
<ProtectRoute>
<Pay /> {/* 这就是 children */}
</ProtectRoute>
这种模式让 ProtectRoute 变成可复用的门禁:任何需要鉴权的页面,套一层就行,不用改原组件。
登录页:取值跳回
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// 链式取值,拿不到就回退首页
const from = location.state?.from?.pathname || '/';
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const username = formData.get('username');
const password = formData.get('password');
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true');
navigate(from, { replace: true });
} else {
alert('登录失败');
}
}
return (
<form onSubmit={handleSubmit}>
<h1>登录页</h1>
<input type="text" name="username" placeholder="请输入用户名" required />
<input type="password" name="password" placeholder="请输入密码" required />
<button type="submit">登录</button>
</form>
);
};
这行代码是整条链的灵魂
const from = location.state?.from?.pathname || '/';
拆开看:
| location.state | /login 路由上挂的 state |
| location.state?.from | ProtectRoute 传过来的原 location 对象 |
| ?.from?.pathname | 取它的 pathname,即 /pay |
| ` |
?. 可选链的作用:直接访问 /login 时 state 是 undefined,没有 ?. 会直接报 TypeError。
六、踩坑实录:两个坑我替你踩过了
坑 1:DataCloneError: Location object could not be cloned
报错现场:
Uncaught DataCloneError: Failed to execute 'replaceState' on 'History':
Location object could not be cloned.
错误写法:
// 直接用了全局 window.location,它是 DOM 对象
return <Navigate to="/login" replace state={{ from: location }} />
这里的 location 是 window.location,是浏览器宿主对象。
React Router 内部会把 state 传给 history.replaceState(),浏览器用结构化克隆算法序列化它。而 DOM Location 对象无法被克隆,于是抛错。
正确写法:用 useLocation() 拿到的是普通对象,可克隆:
const location = useLocation();
return <Navigate to="/login" replace state={{ from: location }} />
记住一句话:路由相关的东西,永远用 react-router-dom 提供的 hook,别碰 window.location。
坑 2:登录后回不到原页面,跳到了首页
错误现场:从 /pay 被拦到 /login,登录成功后却跳到了 /。
原因:两边数据格式对不上。
// ProtectRoute 传的是字符串
state={{ from: location.href }} // "http://localhost:5173/pay"
// Login 却按对象取
location.state?.from?.pathname // 字符串上没有 pathname,结果是 undefined
字符串上没有 .pathname,取出来是 undefined,|| '/' 兜底生效,于是跳到了首页。
修复:两边统一成对象形态。
// ProtectRoute
state={{ from: location }} // 传 useLocation() 的对象
// Login
location.state?.from?.pathname // 正确取到 "/pay"
避坑原则:传值和取值的数据结构必须严格对应,跨组件传对象时尤其要确认字段名一致。
坑 3:FormData 取不到值
这是个隐藏坑,新手很容易中招。
错误写法:
<input type="text" placeholder="请输入用户名" required />
<input type="password" placeholder="请输入密码" required />
new FormData(form) 只收集带 name 属性的表单控件。上面两个 input 没写 name,formData.get('username') 永远返回 null,登录永远失败。
正确写法:
<input type="text" name="username" placeholder="请输入用户名" required />
<input type="password" name="password" placeholder="请输入密码" required />
记住:用 FormData,input 必须有 name,这是 HTML 表单的基础规则,跟 React 无关。
七、路由对象速查表
| useNavigate | 代码里主动跳转 | navigate('/pay', { replace: true }) |
| useLocation | 拿当前路由信息 | location.pathname、location.state |
| useParams | 取动态路由参数 | const { id } = useParams() |
| Link | 声明式跳转 | <Link to="/about">关于</Link> |
| Navigate | 组件式重定向 | <Navigate to="/login" replace /> |
| Outlet | 嵌套路由占位 | 父组件里渲染子路由 |
navigate 对应编程式导航,Link 对应声明式导航,按场景选。
八、总结
一图看懂整套鉴权流程:
三个核心原则:
- 路由 state 只放可序列化的普通对象,别放 DOM 对象
- 传值和取值的字段结构要严格对应
- 表单控件必须加 name,FormData 才能收集到值
网硕互联帮助中心




评论前必须登录!
注册