做 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 的
historyAPI(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> ); };这行代码是整条链的灵魂
constfrom=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(),浏览器用结构化克隆算法序列化它。而 DOMLocation对象无法被克隆,于是抛错。
正确写法:用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 无关。
七、路由对象速查表
| 对象/Hook | 作用 | 典型用法 |
|---|---|---|
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对应声明式导航,按场景选。
八、总结
一图看懂整套鉴权流程:
- 用户访问
/pay ProtectRoute检查localStorage,没登录Navigate跳/login,把当前 location 塞进state.from- 登录页
useLocation取出from.pathname - 登录成功,
navigate(from, { replace: true })跳回/pay
三个核心原则:
- 路由 state 只放可序列化的普通对象,别放 DOM 对象
- 传值和取值的字段结构要严格对应
- 表单控件必须加
name,FormData 才能收集到值