做 React 开发这些年,几乎每个项目都离不开路由。React Router 这个库,刚开始接触时觉得很简单,不就是配置几个 Route 把页面串起来吗?等真正做起项目才发现,这里面的门道比想象中多得多:嵌套路由怎么组织、刷新页面 404 怎么处理、登录鉴权怎么拦截、路由变化怎么监听、面包屑怎么根据路由生成……每一个单拎出来都是实际开发躲不开的问题。
这篇文章我会从零开始,把我从入门到在多个项目中落地 React Router 的经验完整梳理一遍。我不会只讲 API,而是按照真实项目开发的顺序来写:先讲清楚路由到底解决了什么问题,再从配置、传参、嵌套布局、鉴权一步步往下走,最后把常见的坑和排查思路列成清单。无论你是刚接触 React 的初学者,还是被路由问题折腾过的中级开发者,这篇文章应该都能帮你省下不少查资料的功夫。
版本说明一下,我用的所有代码示例都以 React Router v6 为准。现在是 2024 年了,v6 早就是稳定版本,网上很多教程还是 v5 的老写法,直接拿过来用会出现各种兼容问题,这一块后面我会专门列一节讲区别。
1. 为什么需要路由:先搞懂它解决什么问题
1.1 没有路由的世界:SPA 的困境
先说个最基础的问题:React 本身是构建单页应用(SPA)的框架,单页应用最大的特点就是只有一个 HTML 页面,所有内容都是 JavaScript 动态渲染的。如果没有路由,你面对的会是一个极其分裂的开发状态:切换页面只能靠组件内部的 state 控制,比如if (page === 'home')显示首页,if (page === 'about')显示关于页。
这种方式在只有两三个页面、且不需要用户通过 URL 直达某个页面时勉强能用。但一旦项目超过五个页面,麻烦就来了:用户刷新一下浏览器就回到了默认页,浏览器前进后退按钮完全失效,你没法把一个页面链接发给同事让他直接打开,后端也无法区分用户当前在做什么操作。可以说,SPA 缺了路由,就像一个没有门牌号和走廊的大楼,屋子再多也没法有条理地进出。
React Router 就是这个“门牌号和走廊”的解决方案。它本质上做的事情有两件:一是把 URL 和 UI 组件绑定起来,让浏览器地址栏的变化能够驱动页面内容的切换;二是提供一套导航机制,让用户通过点击链接、按钮或手动改地址都能到达对应的页面。
1.2 React Router 的核心设计思想
React Router v6 的设计思路一句话可以概括:路由即组件。在 v6 里,你声明路由的方式不是写配置对象,而是直接写 JSX 组件树。比如<BrowserRouter>包在最外层,里面用<Routes>和<Route>来声明路径与组件的映射关系。
这种设计的好处在于它完全融入了 React 的声明式编程模型。路由不是脱离于组件之外的特殊配置,而是组件树的一部分。这意味着你可以像组合普通组件一样组合路由,可以在路由组件外面包一层自定义组件来做权限校验,也可以利用 React Context 在路由内部共享数据。
另外一个重要设计是“嵌套路由”成为一等公民。v6 的路由结构是完整的树形结构,父路由通过<Outlet />这个占位组件来渲染子路由的内容。这个设计直接解决了后台管理系统最经典的布局问题:整个系统共用一个侧边栏和顶栏,只有中间内容区域随 URL 变化。
<Route path="/admin" element={<AdminLayout />}> <Route path="dashboard" element={<Dashboard />} /> <Route path="users" element={<Users />} /> </Route>上面这段代码里,访问/admin/dashboard时,AdminLayout组件会被渲染,它的侧边栏和顶栏会保留,而<Outlet />的位置会渲染Dashboard组件。这种“父组件框架 + 子组件内容”的模式,在 v5 时代实现起来非常别扭,v6 算是彻底解决了。
2. 环境准备与基础路由配置:从零跑通第一个例子
2.1 安装与最小示例
安装 React Router 只需要一条命令。我用的是 npm,你用 yarn 或 pnpm 也是一样:
npm install react-router-dom@6注意一点,React Router 在 npm 上有两个相关的包:react-router和react-router-dom。前者是核心库,后者是专门为浏览器 DOM 环境封装的一层,包含了我们常用的BrowserRouter、Link等组件。实际做 Web 开发,直接装react-router-dom就行,它会自动带上react-router作为依赖。
装完之后,最基础的使用方式是在入口文件里用BrowserRouter把应用包起来:
import { BrowserRouter } from 'react-router-dom'; function App() { return ( <BrowserRouter> <MainApp /> </BrowserRouter> ); }然后在你需要定义路由的地方,用Routes和Route声明路径映射:
import { Routes, Route } from 'react-router-dom'; function MainApp() { return ( <Routes> <Route path="/" element={<HomePage />} /> <Route path="/about" element={<AboutPage />} /> <Route path="/contact" element={<ContactPage />} /> </Routes> ); }这个示例很简单,但我第一次实际写的时候踩了一个小坑:Routes组件是 v6 才有的,v5 里对应的组件叫Switch,功能类似但不完全相同。如果你在网上找到的教程里用的是<Switch>,那基本可以确定是 v5 甚至更早的文章,照着写会直接报错说Switch is not defined或Switch不存在。另外,v6 里Route不能再直接写子组件作为内容,必须用element属性传组件元素,这是很多人从 v5 迁移过来最容易改错的地方。
2.2 页面跳转:Link 与 NavLink
路由配置好了,接下来就是页面之间的跳转方式。React 里做跳转有两个选择:一是用<Link>或<NavLink>组件,本质上是渲染一个<a>标签,但拦截了浏览器默认的整页刷新行为,改为通过 history API 切换 URL 并触发路由更新;二是用编程式导航useNavigate,在事件回调或某个异步操作完成后触发跳转。
import { Link, NavLink } from 'react-router-dom'; function Header() { return ( <nav> <Link to="/home">首页</Link> <NavLink to="/about" className={({ isActive }) => (isActive ? 'active-link' : '')} > 关于 </NavLink> </nav> ); }NavLink和Link的区别在于它会在当前路由匹配时自动加上一个激活状态。上面代码里,我通过className传入的函数拿到isActive,动态绑定样式。这个功能在做导航菜单高亮时特别常用。
不过这里有一个容易踩的坑:NavLink默认是“包含匹配”。比如/about这个 NavLink,当你在/about/team这个子路径时,它依然会被标记为激活。如果你只希望完全匹配时才高亮,需要加end属性。我第一次做后台导航时就因为这个,首页导航一直高亮着,排查了半天才发现是匹配规则的问题。
2.3 默认路由与 404 兜底
配置路由时有两个几乎每次都要处理的情况:默认重定向和 404 页面。v6 里实现方式如下:
<Routes> <Route path="/" element={<Navigate to="/home" replace />} /> <Route path="/home" element={<HomePage />} /> <Route path="/about" element={<AboutPage />} /> <Route path="*" element={<NotFoundPage />} /> </Routes>当用户访问/时,<Navigate>组件会帮我们自动跳转到/home,replace属性表示替换当前历史记录,这样用户按返回键不会回到一个空白根路径。path="*"是通配符,匹配所有未定义的路由,用来渲染 404 页面。
提示:实际项目中 404 页面最好不只是显示“页面不存在”这几个字,可以加一个返回首页的按钮,并且最好带上“检查地址是否输入正确”之类的提示文案,能明显降低用户的困惑感。
3. 嵌套路由与布局系统:后台管理的核心玩法
3.1 Outlet 的作用与嵌套路由声明方式
后台管理系统是 React Router 嵌套路由最典型的应用场景。想象一个管理后台的常见布局:左侧是菜单栏,顶部是用户信息栏,中间是内容区域。如果用传统的组件状态控制页面切换,光菜单栏的选中状态就够维护半天。嵌套路由可以直接把整个布局结构固化下来。
声明嵌套路由有两种方式。第一种是在父Route里直接写子Route:
<Routes> <Route path="/admin" element={<AdminLayout />}> <Route index element={<Dashboard />} /> <Route path="users" element={<Users />} /> <Route path="settings" element={<Settings />} /> </Route> </Routes>第二种是把子路由抽取出来定义,再通过属性组合。两种方式效果一样,我习惯用第一种,因为路由结构一眼就能看全。
关键点在于AdminLayout组件里面必须有一个<Outlet />占位:
function AdminLayout() { return ( <div className="admin-layout"> <Sidebar /> <div className="admin-content"> <Outlet /> </div> </div> ); }访问/admin/users时,AdminLayout渲染,Sidebar正常显示,而内容区域的位置由<Outlet />接管,React Router 会在这一行渲染匹配到的Users组件。这就是嵌套路由的魔力:父路由组件负责整体布局,子路由组件负责具体内容。
3.2 使用 index 路由设置默认子页面
上面代码里我写了一个<Route index element={<Dashboard />} />,这个index路由也很关键。它表示当 URL 恰好匹配父路由的路径(即/admin)时,默认渲染哪个子组件。没有这个index路由,你访问/admin时,AdminLayout会渲染,但<Outlet />的位置是空的,页面上只有侧边栏,中间一片空白。
很多新手第一次写嵌套路由都会遇到“为什么父路由的子区域是空白的”这种问题,十有八九就是忘了写 index 路由。
还有一个细节:嵌套子路由的路径不需要写完整路径。比如/admin/users里,子路由只需要写users而不是/admin/users。如果你在子路由的 path 前面多写了/,React Router 会把它当成根路径下的绝对路由去匹配,结果就是访问/admin/users时匹配不到任何子路由,页面照样空白。这个坑我帮同事排查过好几次。
3.3 多个 Outlet:实现动态渲染区域的进阶用法
一个组件里可以放多个Outlet,不过需要给它们起名字。这在做“文章详情页里的相关推荐”或者“弹窗类内容需要独立于主内容区渲染”之类场景时非常有用,React Router v6 通过outlet这个上下文机制来区分:
<Route path="/detail/:id" element={<ArticleLayout />}> <Route path="content" element={<ArticleContent />} /> <Route path="recommend" element={<Recommend />} outlet="sidebar" /> </Route>对应布局组件里:
function ArticleLayout() { return ( <div> {/* 主内容区 */} <Outlet /> {/* 侧边栏区域,专门渲染带 sidebar 标识的子路由 */} <Outlet name="sidebar" /> </div> ); }说实话,多个 Outlet 的功能我在实际项目中用到的次数不多,但一旦遇到就会觉得它特别好用。比如文章页的结构:左侧是正文内容,右侧是“你可能会喜欢”的推荐列表,推荐列表的数据和布局跟正文完全独立,就可以用带名字的 Outlet 来做。每个 Outlet 相当于给子路由内容划分了一个“插槽”,这不只是布局上的便利,还能让路由层直接控制不同区域的展示逻辑,减少组件里的条件判断。
4. 路由传参与导航:从 URL 到组件数据的完整通道
4.1 三种传参方式对比
实际开发中,几乎没有哪个页面是不需要传参的:列表页跳详情页要传 id,搜索结果要传关键词,表单提交后要回跳到来源页……React Router 提供了多种传参方式,各自适合不同场景。
首先是用useParams读取路径参数。这种方式适合“资源的唯一标识”,比如文章详情的 id、用户信息的 userId。定义路由时在 path 里用冒号声明参数名:
<Route path="/article/:id" element={<ArticleDetail />} />在组件中读取:
import { useParams } from 'react-router-dom'; function ArticleDetail() { const { id } = useParams(); // 用 id 去请求文章数据 }第二种是 URL 查询参数(query string),也就是地址栏里?page=2&keyword=react这种形式。用useSearchParams读取和修改:
import { useSearchParams } from 'react-router-dom'; function SearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get('keyword') || ''; const handleSearch = (value) => { setSearchParams({ keyword: value, page: 1 }); }; }第三种是通过 state 传递数据。这种方式不会把数据暴露在 URL 上,适合传递“临时性、非关键”的数据,比如从列表页跳转编辑页时,先把这一行的对象数据通过 state 带过去,减少一次查询:
const navigate = useNavigate(); navigate('/user/edit', { state: { user: rowData } }); // 在编辑页读取 const location = useLocation(); const user = location.state?.user;提示:通过 state 传的数据在刷新页面后会丢失,因为刷新的本质是重新加载整个页面,浏览器的历史记录里不会保存这个 state。所以如果数据是必须的,刷新后不能丢,一定要通过 URL 参数传或者从后端接口重新获取。
4.2 编程式导航:useNavigate 的完整用法
Link组件适用于纯粹的点击跳转。但有时候跳转不是用户直接点击触发的:比如登录成功后自动跳到之前想访问的页面,提交表单成功后跳到列表页,倒计时结束后跳到抽奖页……这些场景都需要用编程式导航。
import { useNavigate } from 'react-router-dom'; function LoginButton() { const navigate = useNavigate(); const handleLogin = () => { // 模拟登录成功 const redirectUrl = location.state?.from || '/home'; navigate(redirectUrl, { replace: true }); }; return <button onClick={handleLogin}>登录</button>; }useNavigate返回一个函数,调用时接受两个参数:目标路径和配置对象。配置对象里常用的有两个字段:replace表示替换当前历史记录,这样用户在登录成功后按返回键不会回到登录页循环;state可以携带数据,跟前面讲的Link的 state 传参是同一个机制。
另外,navigate还可以传入一个数字,比如navigate(-1)表示返回上一页,navigate(1)表示前进一页。这个在实现“返回列表”按钮时很实用,但要注意:如果上一页已经是不存在的历史记录(比如用户直接输入 URL 打开的页面),navigate(-1)会直接跳到一个空白历史页。所以稳妥的写法是先判断一下window.history.length,或者在跳转时通过location.key判断有没有上一条记录。
4.3 路由匹配的完整规则:path 写法大全
v6 的路由匹配规则比 v5 更严格也更直观,但有一些细节不仔细看文档容易弄错。我把常用的 path 写法整理成一个表格:
| 写法 | 匹配的 URL 示例 | 说明 |
|---|---|---|
/ | / | 只匹配根路径 |
about | /about | 相对路径,需在父路由内使用 |
article/:id | /article/123 | 路径参数,用 useParams 读取 |
article/:id? | /article或/article/123 | 参数可选,v6.5 之后支持 |
files/* | /files/a.jpg、/files/src/index.js | 通配符,匹配任意多级路径 |
* | 任意路径 | 通常用于 404 |
/admin/:id(\d+) | /admin/123,不匹配/admin/abc | 正则约束参数格式 |
正则约束是 v6 比较实用的一个特性。比如我的后台项目里文章 id 是纯数字,我就在路由上直接约束:
<Route path="/article/:id(\d+)" element={<ArticleDetail />} />这样用户访问/article/abc时,根本不会匹配到详情页组件,会直接落到 404 上,省去了在组件里判断 id 是否为数字的代码。不过要注意,正则写法在 v6 里有调整,不同小版本行为略有差异,生产环境用之前最好在自己项目的 React Router 版本上实测一下。
5. 路由守卫与鉴权:给每个页面加上访问门槛
5.1 用包装组件实现登录拦截
绝大多数后台管理系统都有权限需求:未登录用户访问任何后台页面,都要跳到登录页;已登录但没有某个角色权限的用户,访问特定页面时应该看到无权限提示。React Router v6 没有专门的“路由守卫”API,实现方式是用包装组件,这个思路其实比配置式的守卫更贴合 React 组件化思维。
核心代码是这样:
function RequireAuth({ children }) { const token = localStorage.getItem('token'); const location = useLocation(); if (!token) { // 把当前要访问的路径保存下来,登录成功后再跳回来 return <Navigate to="/login" state={{ from: location.pathname }} replace />; } return children; }然后在路由声明时,把需要鉴权的路由包进去:
<Routes> <Route path="/login" element={<Login />} /> <Route path="/" element={ <RequireAuth> <AdminLayout /> </RequireAuth> } > <Route index element={<Dashboard />} /> <Route path="users" element={<Users />} /> </Route> </Routes>这样做的效果是:访问/下的任何子路由时,RequireAuth都会先在组件树顶层检查一次 token,没有 token 直接<Navigate>到登录页。相比在每一个页面组件内部各自判断,这种写法把鉴权逻辑收敛到了一个地方,管理和修改都方便得多。
5.2 按角色控制菜单和权限路由
只做登录拦截在真实项目里还不够。系统通常有管理员、编辑、访客等多种角色,不同角色能看到的菜单和页面不一样。我的做法是:用后端返回的权限码列表,动态过滤菜单,再在路由层加一层角色判断。
权限校验组件:
function RequirePermission({ permission, children }) { const { permissions } = useAuth(); if (!permissions.includes(permission)) { return <Result status="403" title="403" subTitle="抱歉,您没有权限访问该页面" />; } return children; }在路由中的用法:
<Route path="users" element={ <RequirePermission permission="user.manage"> <Users /> </RequirePermission> } />菜单部分则根据权限列表做条件渲染:
const menuConfig = [ { path: '/dashboard', label: '工作台', permission: 'dashboard.view' }, { path: '/users', label: '用户管理', permission: 'user.manage' }, { path: '/settings', label: '系统设置', permission: 'system.setting' }, ]; const visibleMenus = menuConfig.filter((item) => permissions.includes(item.permission));这套方案的核心思路是:菜单是动态生成的,路由是静态声明的,权限校验是在进入页面时做的。三者各司其职,菜单只是入口的展示层,真正决定你能不能访问一个页面,是路由层的RequirePermission。这样做避免了“菜单藏起来但手输 URL 还是能进”这种低级漏洞。我见过一些项目为了省事,只做了菜单过滤,结果用户在地址栏直接输入/users就顺畅地进入了管理页面,这属于比较严重的权限漏洞,路由层的拦截一定不能省。
5.3 登录后回跳与防止循环跳转
搭配鉴权组件,有一个同样重要的小逻辑:登录成功后的回跳。
前面RequireAuth里,我把未登录用户想访问的路径存到了state的from字段。登录成功后,应该读取这个字段,如果存在就跳回原页面,否则跳默认首页:
const location = useLocation(); const navigate = useNavigate(); const handleLogin = () => { // 登录验证逻辑... const from = location.state?.from || '/home'; navigate(from, { replace: true }); };这里有几个容易踩的坑。第一个是循环跳转:如果用户直接访问/login,登录后from是/login,跳回去又是登录页,就形成了死循环。所以跳转前最好判断一下from !== '/login'。第二个是from可能包含 query 参数,比如用户访问的是/admin?tab=users,如果只存location.pathname,登录后 query 参数就丢了。更稳妥的做法是存location.pathname + location.search。
if (!token) { return ( <Navigate to="/login" state={{ from: `${location.pathname}${location.search}` }} replace /> ); }这个细节看起来不起眼,但实际项目中真的遇到过:用户没登录点了一个带筛选条件的列表页链接,登录后跳回来筛选条件全部丢失,还得重新设置。存 query 参数的方案就避免了这个问题。
6. 懒加载与代码分割:让路由不再拖慢首屏
6.1 React.lazy 配合 Suspense
单页应用有个天然的问题:打包出来的 JavaScript 文件会随着页面数量增加而膨胀。只要几十个页面,打包文件轻松突破 1MB,用户打开首页就得下载全部页面的代码,网络稍慢就是白屏几秒钟。
解决思路是代码分割:把每个路由页面拆分成独立的 chunk,用户访问哪个路由才下载哪个页面的代码。React Router v6 配合 React 的lazy和Suspense做这事非常顺手:
import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; const HomePage = lazy(() => import('./pages/HomePage')); const AboutPage = lazy(() => import('./pages/AboutPage')); const ArticleDetail = lazy(() => import('./pages/ArticleDetail')); function App() { return ( <Suspense fallback={<LoadingPage />}> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/about" element={<AboutPage />} /> <Route path="/article/:id" element={<ArticleDetail />} /> </Routes> </Suspense> ); }核心原理就一行:lazy(() => import('./pages/HomePage'))。import()是动态导入语法,打包工具(Webpack 或 Vite)看到这个语法会自动把HomePage单独打成一个小 chunk。Suspense的作用是在这个 chunk 还在下载时显示一个 fallback,避免页面直接白屏。
6.2 按路由拆分的实践配置
实际项目中,路由非常多,我不会把每个页面都手写一遍lazy,那样太啰嗦。我一般会抽一层公共的路由懒加载封装:
import { lazy } from 'react'; function lazyLoad(path) { return lazy(() => import(`../pages/${path}`)); } // 路由配置时直接使用 const HomePage = lazyLoad('HomePage'); const AboutPage = lazyLoad('AboutPage'); const NotFoundPage = lazyLoad('NotFoundPage');不过这种动态拼接路径的写法要小心:import()的动态参数必须是静态可分析的路径前缀,打包工具才能找到目标模块。也就是说../pages/这个前缀必须是字面量,不能从变量里拼出整个路径。上面例子里path变量只影响文件名部分,前缀是固定的,这是正确的写法。如果整个路径都是变量,Webpack 会编译不过或者打出一个非常大但内容不明确的 chunk。
还有一点要提醒:开发环境里因为本地编译快、网络开销小,懒加载的优势不明显,但到了生产环境差别非常大。我做过一次优化,把 30 多个页面的后台系统从全部打到一个大包里拆成按路由懒加载,首屏加载时间从原来的 3.4 秒降到了 1.2 秒左右,这个优化性价比极高,强烈建议每个 React 项目都做。
7. 常见问题与排查技巧实录
7.1 部署后刷新页面 404 的问题
这是 React Router 项目上线后碰到的最高频问题:本地开发一切正常,部署到服务器后,从首页点进子页面正常,但一旦在子页面刷新,就出现 404 或者“无法访问”。
问题的根源在于:开发环境下,vite 或 webpack-dev-server 内置了 history fallback,也就是当你请求一个不存在的路径时,开发服务器会自动返回 index.html 给前端路由去处理。但生产环境的静态服务器(Nginx、Tomcat、Apache 等)默认没有这个配置,它会在磁盘上找一个和 URL 路径对应的真实文件,找不到就返回 404。
解决方案是在服务器层面做 try_files 配置。以 Nginx 为例:
location / { try_files $uri $uri/ /index.html; }这一段配置的意思是:先尝试找与请求路径匹配的真实文件,找不到就回退到/index.html,由前端路由接管。这是部署层面最标准的方案。
如果项目部署在子路径下,比如访问地址是https://example.com/my-app/,那还需要做两件事:一是BrowserRouter要传入basename属性:
<BrowserRouter basename="/my-app"> {/* 路由配置 */} </BrowserRouter>二是 Nginx 的 try_files 要放在子路径对应的 location 里。这个坑我踩过一次:basename 漏了,页面跳转后所有资源全部 404,因为脚本和样式的绝对路径指向了域名根目录,找不到my-app下的资源。
7.2 路由变化后页面位置没有回顶部
单页应用的另一个常见体验问题是:用户在一个很长的列表页往下滚了很久,点击进入详情页,再返回列表页时,页面停留在他之前滚动的位置,而不是从顶部开始。这在某些场景下是有用的,比如列表页想恢复之前的位置,但实际上大多数用户的预期是进入新页面时回到顶部。
最简单的解决方案是写一个滚动恢复组件,挂在路由出口的位置:
import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; function ScrollToTop() { const { pathname } = useLocation(); useEffect(() => { window.scrollTo(0, 0); }, [pathname]); return null; } // 放在 Routes 外面或里面都行 function App() { return ( <BrowserRouter> <ScrollToTop /> <Routes> {/* 路由声明 */} </Routes> </BrowserRouter> ); }它的原理很简单:监听pathname变化,一旦路由地址改变,就把滚动条拉回顶部。
当然,如果项目里有部分页面希望保留滚动位置,那就不能一刀切。更精细的做法是在组件内通过useEffect控制自己的滚动行为,或者给ScrollToTop加一个配置项排除某些路径。我目前的经验是:大部分项目一刀切回顶部就够了,如果需要保留位置、再对特定页面做特殊处理,这样最简单不容易产生 bug。
7.3 如何在组件外部获取路由能力
React Router 的 Hook 只能在组件函数里使用,这是 React 的规则。但实际编码中经常会遇到一个场景:在封装的 axios 请求工具里,接口返回 401 时,需要跳转到登录页。而 axios 拦截器不是组件,没法直接调用useNavigate。
这个问题的常规解决方案是创建一个路由跳转的工具模块:
import { createBrowserRouter } from 'react-router-dom'; export const router = createBrowserRouter(routes); // 在任意地方都能使用 export function navigateTo(path) { router.navigate(path); }然后在 axios 拦截器里调用:
import { navigateTo } from '@/utils/router'; // 响应拦截器 instance.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { navigateTo('/login'); } return Promise.reject(error); } );这里我用的是 v6.4 之后提供的createBrowserRouter创建路由实例的方式。这种创建方式返回的 router 对象暴露了navigate方法,可以让我们在任意 JS 模块中触发路由跳转。如果项目用的是传统的<BrowserRouter>声明式方式,没有向外暴露 router 实例,那可以把跳转逻辑抽到事件里面,或者用一个全局变量手动保存 navigate 引用:
// navigateHelper.js let navigateFn = null; export function setNavigate(fn) { navigateFn = fn; } export function navigateTo(path) { if (navigateFn) navigateFn(path); } // 在某个组件里注册 function App() { const navigate = useNavigate(); useEffect(() => { setNavigate(navigate); }, [navigate]); // ... }两种方案我都在项目里用过,前者更干净,后者在不改动整体架构的前提下也能解决问题。没有绝对的好坏,看项目现状。
7.4 版本差异:从 v5 迁移到 v6 的核心变更点
React Router v6 相比 v5 改动非常大。如果你接手的是老项目需要升级,或者看老教程写新代码,下面几个点必须留意:
Switch改成了Routes。Switch在 v6 里直接移除了,没有兼容层,只能全量替换。Route承载组件的方式从component属性改成了element属性。<Route path="/" component={Home} />要写成<Route path="/" element={<Home />} />。- 路由嵌套从“组件内部写子路由”改成了“直接写在 Route 子树里”。v5 里你必须在子组件里再写一个
<Switch>,v6 直接父子嵌套即可,配合<Outlet />使用。 useHistory改名为useNavigate,返回的不再是 history 对象,而是一个函数。- v6 的路由匹配采用自动打分排序,不再有 v5 里“越靠前优先级越高”的说法。这也意味着你不需要手动给路由排顺序,通配符和静态路径之间怎么匹配,React Router 有自己的规则,大多数时候表现是合理的。
Link组件基本没变,但NavLink的激活类名判断逻辑变了,需要用函数模式的className或style来拿到isActive状态。
我做过的 v5 到 v6 迁移项目,最大的坑在于嵌套路由。v5 时代很多项目用“父路由匹配布局 + 子路由在组件内部再定义”的方式,升级时需要把子路由全部提升到父路由的声明里,并且用<Outlet />替换原本子路由渲染位,改动面比较大。如果项目页面多、层层嵌套深,建议用 codemod 工具先做一遍自动替换,再手工处理特殊场景。
7.5 一个容易被忽略的性能细节:不要在组件内部重复渲染路由配置
我在不少项目里见过这样的写法:把<Routes>和<Route>直接放在某个组件的 render 里,而这个组件在页面切换时频繁重新渲染。如果路由配置对象在每次渲染时都是新建的(比如直接写 JSX),React Router 每次都得重新处理整个路由树,页面切换会有可感知的卡顿。
改进方式很简单:
- 把
<Routes>的配置放在组件外部,用常量保存,或者用useMemo缓存。 - 使用
createBrowserRouter集中创建路由实例,这也更有利于配合懒加载和数据加载 API 使用。
// 路由配置独立成文件 export const routes = [ { path: '/', element: <Navigate to="/home" replace /> }, { path: '/home', element: <HomePage /> }, { path: '/about', element: <AboutPage /> }, ]; // 组件内稳定引用 import { routes } from '@/routes'; function App() { return ( <BrowserRouter> <Routes> {routes.map((route) => ( <Route key={route.path} {...route} /> ))} </Routes> </BrowserRouter> ); }这个优化不到十行代码,但对大型项目的路由性能帮助很明显。我在一个四十多个路由的项目里实测过,把动态渲染的路由改成static 配置后,页面切换的响应时间从原先偶尔卡顿变成一直流畅,虽然没有精确到毫秒级的数据统计,但感知上的差异是实实在在的。
8. 实战项目中的完整路由结构示例
写了这么多单点内容,最后还是放一个我在真实后台项目中用过的路由结构模板,综合了嵌套布局、懒加载、权限控制、默认路由和 404。你可以把它当作一个可以直接参考的骨架,在此基础上按自己的业务调整。
// router.jsx import { createBrowserRouter, Navigate } from 'react-router-dom'; import { lazy, Suspense } from 'react'; const Login = lazy(() => import('./pages/Login')); const AdminLayout = lazy(() => import('./layouts/AdminLayout')); const Dashboard = lazy(() => import('./pages/Dashboard')); const Users = lazy(() => import('./pages/Users')); const Settings = lazy(() => import('./pages/Settings')); const NotFound = lazy(() => import('./pages/NotFound')); const router = createBrowserRouter( [ { path: '/login', element: <Login />, }, { path: '/', element: ( <RequireAuth> <AdminLayout /> </RequireAuth> ), children: [ { index: true, element: <Navigate to="/dashboard" replace /> }, { path: 'dashboard', element: <Dashboard /> }, { path: 'users', element: ( <RequirePermission permission="user.manage"> <Users /> </RequirePermission> ), }, { path: 'settings', element: <Settings /> }, ], }, { path: '*', element: <NotFound />, }, ], { basename: '/my-app', } ); export default router;入口文件:
// main.jsx import { RouterProvider } from 'react-router-dom'; import router from './router'; function App() { return ( <Suspense fallback={<div className="page-loading">页面加载中...</div>}> <RouterProvider router={router} /> </Suspense> ); }createBrowserRouter返回的 router 实例直接传给RouterProvider,不需要再手动包一层BrowserRouter。这种写法在 v6.4 之后是官方推荐的方式,尤其适合需要路由级数据预加载和权限控制的场景。组件内的跳转依然可以用useNavigate,没有任何使用差异;而且因为 router 对象是模块级导出的,前面说的“在 axios 拦截器里跳转”也顺手就解决了。
这个结构里还有一个小地方值得注意:我把RequireAuth放在AdminLayout外层,而RequirePermission放在具体的页面组件外层。前者的作用是“没登录就不能进后台体系”,后者是“登录了但没权限就不给看某个页面”。两个鉴权职责是不一样的,分开放比全部堆在一层要清晰得多,后面维护时你只需要按职责定位问题。
我个人在实际项目里的体会是,React Router 的上手门槛真的不高,半天时间看一遍官方文档就能写出能跑的配置。但真正决定一个应用路由层质量高低的,是这些细节:有没有做好嵌套布局抽象、有没有统一的权限拦截方案、有没有考虑部署后的刷新问题、有没有做代码分割优化首屏。这些恰恰是文档里不会手把手教的,也是我在多个项目里反复踩坑总结出来的。
最后再分享一个建议:不管项目大小,都值得花一点时间把路由设计成一个独立模块,统一出口,统一鉴权,统一懒加载。这个设计做在前面,后面每增加一个页面,成本就只是一行路由声明,而不用担心一堆页面各自的跳转逻辑变成一锅粥。路由是单页应用的骨架,骨架稳了,上面的功能才能搭得踏实。