别再瞎写 React Router!7 个高频踩坑点一次性讲透

0 阅读14分钟

昨天写支付页面的时候,手贱直接在地址栏输了 /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.pushState API,能往浏览器历史记录里塞一条记录,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 了,语法也变了不少。

核心就三个东西:RouterRoutesRoute

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 自带了 lazySuspense 来干这个事。

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。所以 !isLoginfalse,鉴权通过了。

也就是说,只要 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 的写法。Switchcomponentrenderexact…… 这些 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% 的场景了。剩下的用到了再查文档也不迟。

如果你也正在学路由,看到这里有收获的话,可以自己动手写一遍。光看不练假把式,很多坑只有自己踩过才记得住。

搞懂了记得回来留个言,我也想看看你理解的前端路由是什么样的。