昨天写支付页面的时候,手贱直接在地址栏输了
/pay回车,页面居然直接出来了。我当时就愣了 —— 这支付页不是应该登录才能进吗?合着我写的那个鉴权组件是摆设?盯着屏幕看了五分钟,改了三版代码,控制台的
false还在那躺着。后来才发现,我把localStorage里存的字符串'true'直接当布尔值判断了。就这么个低级错误,耗了我快半小时。索性借着这个机会,把 React Router 从头到尾捋了一遍。从最基础的路由配置,到懒加载、嵌套路由、动态参数、重定向,再到路由鉴权,连 Hash 模式和 History 模式的区别也翻出来对比了一遍。踩了不少坑,也想通了很多之前一知半解的地方。
这篇文章就当是我的学习笔记,想到哪写到哪,你要是也在学路由,说不定能少走点弯路。
先搞懂:前端路由到底在干嘛
说实话,我之前用路由都是直接抄配置,Route 写上去能跑就行。至于它底层怎么实现的,为什么点一下链接页面不刷新,从来没细想过。
以前做网页是啥样的?你点一个链接,浏览器给后端发请求,后端返回一整个新的 HTML 页面,浏览器重新渲染。所以每次跳转都会白一下,体验很差。这叫后端路由。
后来有了 SPA(单页应用),整个网站就一个 HTML 文件,页面切换全靠 JS 动态渲染。那问题来了 ——URL 不变的话,用户怎么知道自己在哪?刷新一下不就回首页了?
前端路由就是干这个的:改变 URL,但不刷新页面,根据 URL 显示对应的组件。
就这么简单一句话,实现方案有两种:
- Hash 模式:利用 URL 里
#后面的部分(锚点),改变它不会触发页面刷新,监听hashchange事件来更新视图。URL 里带个#,长得有点丑,比如#/products/123。 - History 模式:用 HTML5 新增的
history.pushStateAPI,能往浏览器历史记录里塞一条记录,URL 变了但页面不刷新。长得好看,跟正常 URL 一样,但刷新的时候会真的去请求后端,后端没配就 404。
这俩的区别我后面还会再踩坑说到,先记着有这么两派就行。
项目目录先摆清楚
我习惯先把目录搭好再写代码。页面级组件统一扔 pages 里,每个页面一个文件夹,入口都叫 index.jsx。这样后面加文件方便,也不会一堆 jsx 堆在根目录看着乱。
你看这个结构:
components放公共组件,比如导航栏pages放页面级组件,Home、About、Login 这些- 复杂的页面再往下套,比如 Products 下面有详情页和新增页
ProtectRoute.jsx是鉴权组件,直接扔 src 根目录了
说实话,目录结构这东西没有标准答案,自己看着舒服、团队统一就行。我是觉得页面多了之后,按功能分文件夹比平铺清爽太多。
最基础的路由配置,别一上来就搞复杂的
先从最简单的开始,能跑通再说。
安装就不说了,npm i react-router-dom 完事。我用的是 v6 版本,跟 v5 差别还挺大的,以前的 Switch 没了,改成 Routes 了,语法也变了不少。
核心就三个东西:Router、Routes、Route。
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'
const App = () => {
return (
<Router>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
</Routes>
</Router>
)
}
我第一反应是:这不就是个映射表吗?path 对应 URL,element 对应要显示的组件。URL 一变,Routes 就去匹配,找到对应的 Route,把 element 渲染出来。
对,本质就是这么回事。
注意v6 之后
component属性没了,统一用element,而且要传 JSX,不是传组件引用。刚开始写我还习惯性写component={Home},报错了才反应过来。
导航用 Link,别用 a 标签
页面能切换了,总不能让用户手动改地址栏吧。
一开始我图省事直接写了 <a href="/about">About</a>,一点 —— 页面刷了。我当时还纳闷,不是 SPA 吗怎么还刷新?
后来才想明白,原生 <a> 标签就是会发请求刷新页面的。React Router 给我们封装好了 Link 组件,本质还是渲染成 a 标签,但拦截了默认行为,用 JS 去改路由。
import { Link } from 'react-router-dom'
function Navigation() {
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
</ul>
</nav>
)
}
把这个导航组件放在 Router 里面、Routes 外面,这样切换页面的时候导航栏一直都在,只有下面内容区变。
这就是 SPA 的感觉了 —— 局部刷新,丝滑。
路由懒加载:别一上来把所有页面都加载了
页面写多了之后,打包出来的 JS 文件会越来越大。首页打开要等所有页面的代码都下载执行完,首屏就慢了。
但用户刚进网站可能只看首页,其他页面根本不点。那能不能用到哪个页面再加载哪个?
这就是路由懒加载。
React 自带了 lazy 和 Suspense 来干这个事。
import { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
// 不用普通 import 了,用 lazy 动态导入
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const Products = lazy(() => import('./pages/Products'))
const App = () => {
return (
<Router>
{/* lazy 组件加载过程中显示 fallback 的内容 */}
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
<Route path='/products' element={<Products />} />
</Routes>
</Suspense>
</Router>
)
}
lazy(() => import('./pages/Home')) 这行是什么意思?就是告诉 Webpack:这个组件别打包进主 bundle 里,单独拆成一个文件,等真正渲染到它的时候再去请求。
Suspense 就是个加载兜底,异步加载组件的过程中,先显示 fallback 里的内容,加载完了再替换成真正的组件。
我第一次用的时候忘了包 Suspense,直接报错。提示信息还挺友好的,说 lazy 组件必须放在 Suspense 里面。
小提醒懒加载不是越多越好。如果页面很小,拆成一堆小文件反而会因为网络请求多变慢。一般按路由维度拆就够了,别每个组件都 lazy。
动态路由:URL 里带参数怎么拿
比如用户详情页 /user/123,商品详情页 /products/456,后面的数字是动态的,总不能每个 ID 都写一条 Route 吧。
这时候用动态路由,路径参数用 : 开头。
<Route path='/user/:id' element={<UserProfile />} />
<Route path='/products/:productId' element={<ProductDetail />} />
然后在组件里用 useParams 这个 Hook 拿参数。
import { useParams } from 'react-router-dom'
function ProductDetail() {
// 解构出来,名字跟路由里定义的要一致
const { productId } = useParams()
return (
<>
<h3>产品详情: {productId}</h3>
</>
)
}
就这么简单。我刚开始纠结过参数名能不能随便起 —— 能,只要路由里定义的和组件里解构的对上就行。
嵌套路由:二级路由怎么玩
产品列表页下面,既要能看详情,又要能新增产品。这两个都是产品页的子页面,导航在上面,下面内容区切换。
这就是嵌套路由。
父路由的 element 里必须留一个 Outlet 作为子路由的出口。我第一次写的时候忘了加,死活不显示子组件,对着路由配置看了十分钟都没发现问题。
// Products/index.jsx
import { Outlet } from 'react-router-dom'
function Products() {
return (
<>
<h1>产品列表</h1>
{/* 二级路由渲染的位置,必须有 */}
<Outlet />
</>
)
}
然后路由配置里,子路由写在父路由的标签里面,path 不用写父级前缀,直接写相对路径就行。
<Route path='/products' element={<Products />}>
{/* 匹配 /products/xxx */}
<Route path=':productId' element={<ProductDetail />} />
{/* 匹配 /products/new */}
<Route path='new' element={<NewProduct />} />
</Route>
这里有个小细节:new 这条路由要写在 :productId 前面吗?
我特意试了一下,v6 是按最佳匹配来的,不是按顺序。访问 /products/new 的时候,它知道 new 是精确匹配,比动态参数优先级高,不会把 new 当成 productId。v5 时代要按顺序写的坑,v6 填了。
重定向:旧地址跳新地址
有时候旧路径不用了,要跳转到新地址;或者用户访问 /home 要自动跳到根路径。
用 Navigate 组件,设置 to 属性就行。
import { Navigate } from 'react-router-dom'
<Route path='/old-path' element={<Navigate to='/new-path' />} />
默认是 push 模式,也就是会在历史记录里留一条。如果不想让用户退回到旧地址,可以加 replace 属性。
<Navigate to='/new-path' replace />
这个 replace 后面鉴权的时候还会用到,很重要。
404 页面:兜底路由
用户乱输地址的时候,总得给个友好的提示吧。
用 path='*' 匹配所有路径,放在所有路由的最后面。前面都匹配不上的时候,就走这条。
<Route path='*' element={<NotFound />} />
我在 NotFound 组件里加了个三秒后自动跳首页的逻辑,用 useNavigate 做编程式跳转。
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
function NotFound() {
const navigate = useNavigate()
useEffect(() => {
const timer = setTimeout(() => {
navigate('/')
}, 3000)
return () => clearTimeout(timer)
}, [navigate])
return <>NotFound,3秒后返回首页</>
}
useNavigate 是编程式导航的核心。不是所有跳转都靠点击 Link,表单提交完跳走、定时跳转、接口返回后跳转,都得用它。
用法也简单:
navigate('/path')前进到新页面navigate('/path', { replace: true })替换当前历史记录navigate(-1)后退一页,navigate(1)前进一页
路由鉴权:这才是今天的重头戏
终于说到最开始那个坑了。支付页面必须登录才能进,没登录就给我跳登录页。
怎么实现?写一个高阶组件(或者说包装组件),把真正的页面组件包在里面。进去之前先检查登录状态,没登录就重定向到登录页,登录了就渲染子组件。
这就是 ProtectRoute 鉴权路由。
第一版:写出来了,但有坑
import { Navigate } from 'react-router-dom'
const ProtectRoute = ({ children }) => {
// 从 localStorage 拿登录状态
const isLogin = localStorage.getItem('isLogin')
// 注意这里!坑!
if (!isLogin) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
路由里这么用:
<Route
path='/pay'
element={
<ProtectRoute>
<Pay />
</ProtectRoute>
}
/>
写完我兴冲冲地去测,把 localStorage 清掉,访问 /pay—— 果然跳登录页了。我想这也不难啊。
然后我登录成功,再去访问 /pay,页面出来了。一切正常。
直到我手贱存了个 'false' 字符串进去……
localStorage.getItem('isLogin') 拿出来的是字符串 'false',JavaScript 里非空字符串转布尔值是 true。所以 !isLogin 是 false,鉴权通过了。
也就是说,只要 localStorage 里有这个 key,不管值是什么,都算登录了。
这 bug 要是上线了,支付页谁都能进,我直接原地失业。
修正版:字符串转布尔值
const isLogin = localStorage.getItem('isLogin') === 'true'
就加个 === 'true' 的判断,稳妥了。
进阶:登录后跳回原来的页面
还有个体验问题:用户正在逛商品,点支付,被弹去登录。登录完直接跳首页了,用户还得重新找商品,很烦。
正确的做法是:跳登录页的时候,把当前路径带过去,登录成功后再跳回来。
怎么带过去?用 state。
const ProtectRoute = ({ children }) => {
const isLogin = localStorage.getItem('isLogin') === 'true'
if (!isLogin) {
// 把当前路径存在 state 里,传给登录页
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname }}
/>
)
}
return <>{children}</>
}
登录页这边用 useLocation 拿 state。
import { useNavigate, useLocation } from 'react-router-dom'
const Login = () => {
const navigate = useNavigate()
const location = useLocation()
// 从 state 里拿到来源路径,没有就默认去首页
const from = location.state?.from || '/'
const 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('用户名或密码错误')
}
}
// ... 表单渲染
}
这里 replace: true 也很关键。如果不用 replace,登录成功后点后退,还能退回到登录页。用户会懵 —— 我不是已经登录了吗怎么又回来了?
用 replace 就是把登录页这条历史记录替换掉,退不回去,体验更顺滑。
说句实在的真实项目里别把登录状态存在 localStorage 里,太不安全了。这里就是 demo 演示一下原理。正经项目都是存 token,发请求的时候带在请求头里,由后端校验。
Hash 模式和 History 模式,我选哪个
最开始我用的是 HashRouter,因为不用后端配合,本地开发直接就能跑。URL 里带个 #,比如 #/products/123。
后来觉得丑,换成了 BrowserRouter。本地开发没问题,因为 webpack-dev-server 帮我们做了处理。
但部署到服务器上就有坑了。你直接访问首页没问题,点链接跳转也没问题,但一旦在子页面刷新 ——404 了。
为什么?因为 History 模式下 URL 是真的变了,比如 localhost:5173/pay。刷新的时候浏览器真的会去服务器请求 /pay 这个路径对应的资源。服务器上根本没有这个文件,当然 404。
Hash 模式就没这问题,因为 # 后面的东西根本不会发给服务器。服务器永远只返回 index.html,剩下的交给前端自己处理。
所以 History 模式需要后端配合,把所有路径都重定向到 index.html,让前端路由来接管。Nginx 里配个 try_files 就行:
try_files $uri $uri/ /index.html;
各有优劣吧:
- Hash:丑,兼容性好,不用后端配
- History:好看,需要后端支持,刷新 404 是经典坑
一般公司项目用 History 的多,毕竟 URL 美观,后端配一下也不是啥难事。自己写小 demo 用 Hash 省事。
说说我踩过的几个坑
这一圈写下来,踩的坑真不少。挑几个印象深的说说,你们别再踩了。
坑 1:嵌套路由忘了写 Outlet
这个前面提过了,真的很容易忘。父组件里不写 <Outlet />,子路由匹配上了也没地方渲染。控制台也不报错,就是页面空白,贼气人。
以后写嵌套路由,第一反应先去父组件里找 Outlet 写了没。
坑 2:localStorage 取出来是字符串
这个是我今天的导火索。存的时候存的字符串,取出来直接当布尔值用,判断逻辑完全反了。
记住:localStorage 里存的全是字符串,数字、布尔值存进去再取出来都变成字符串了。要用就做类型转换。
坑 3:v5 的老语法直接往 v6 上套
刚开始学的时候搜资料,很多还是 v5 的写法。Switch、component、render、exact…… 这些 v6 里要么改了要么没了。
最典型的就是 v5 要写 exact 才能精确匹配,v6 默认就是精确匹配,不用写了。还有嵌套路由的写法也完全不一样了。
搜资料的时候记得带上版本号,不然很容易越看越乱。
坑 4:编程式导航用了 window.location.href
刚开始图省事,直接 window.location.href = '/' 跳转。页面是跳过去了,但刷新了一下,SPA 的意义何在?
既然用了 React Router,就老老实实用 useNavigate。
坑 5:鉴权组件里直接用 location
我第一版鉴权组件里直接写了 location.pathname,居然没报错还能跑。后来才反应过来,这里用的是全局的 window.location,不是路由的 location 对象。
虽然在这个场景下结果一样,但不规范。正确的做法应该在组件里调用 useLocation() Hook 拿。
不过因为 ProtectRoute 本身就在 Router 上下文里,其实直接用 window.location 也能拿到当前路径。但还是建议用路由提供的 API,保持一致性。
坑 6:懒加载组件没包 Suspense
这个报错信息很明确,一般不会踩第二次。但第一次用的时候大概率会忘。
坑 7:路由顺序乱写导致匹配错乱
虽然 v6 是智能匹配,但特别复杂的路由还是建议把精确路径放前面,通配符放后面。404 的 * 路由一定放在最后。
养成好习惯总没错。
最后扯两句
这一圈撸下来,最大的感受是:路由这东西,看着配置挺多,核心思想其实很简单 ——URL 和组件做映射,URL 变了组件就换,页面不刷新。
剩下的懒加载、嵌套路由、动态参数、鉴权、重定向,都是在这个基础上延伸出来的功能。一个个拆开来,都不难。难的是第一次接触的时候,一堆新概念堆过来,容易懵。
我学习的习惯就是先跑通最小 demo,能跑了再往上加功能,加一个验证一个。出问题了就缩小范围,定位到底是哪一步错了。比对着文档一口气写一大堆然后跑不下去强多了。
当然,React Router 还有很多东西我没提到,比如 NavLink 激活样式、useRoutes 配置式路由、路由元信息这些。但日常开发中,上面这些基本能覆盖 80% 的场景了。剩下的用到了再查文档也不迟。
如果你也正在学路由,看到这里有收获的话,可以自己动手写一遍。光看不练假把式,很多坑只有自己踩过才记得住。
搞懂了记得回来留个言,我也想看看你理解的前端路由是什么样的。