React Router 这个东西,只要你做 React 前端,基本绕不开。它是 React 生态里事实标准的路由方案,解决的是单页应用里最核心的那件事——URL 变了,视图跟着变,同时浏览器的前进、后退、收藏书签这些原生能力还能正常工作。不管你是做中后台管理系统、企业官网还是移动端 H5,只要产品不止一个页面,路由层就是应用的骨架。这篇文章我会从实际项目落地的角度,把 React Router 从选型、核心概念到高频踩坑整个过一遍,包含我在多个项目里实测过的方案和代码,希望能让你少走点弯路。
1. 为什么一个做 React 的人绕不开 React Router
1.1 它解决的最核心问题
先掰扯清楚前端路由到底解决什么问题。传统多页应用里,你点一个链接,浏览器向服务器发起新请求,服务器返回新页面,整个页面刷新。单页应用不一样,它只有一份 HTML,剩下的内容全靠 JavaScript 动态渲染。这时候问题就来了:页面切换是通过 JS 改 DOM 实现的,浏览器地址栏的 URL 根本不会变,那用户怎么知道自己在哪个页面?刷新之后怎么回到原来的位置?刷新按钮一按,直接回到入口了,登录状态、列表页的分页参数、搜索条件全丢。
React Router 做的事情就是把这层关联补上。它监听 URL 变化,把 URL 解析成对应的路由配置,再渲染匹配的组件;同时你在页面上点击跳转时,它更新 URL 并渲染新视图,整个过程页面不刷新,但地址栏、历史记录和视图保持同步。说直白点:它让你的 React 应用看起来像个多页网站,同时保留了单页应用流畅的交互体验。
有一回我帮一个朋友看他们团队自己写的路由方案,大概思路是全局存一个 currentPage 状态,点菜单就 setState 切换页面。这样实现起来确实快,但接着就遇到一连串麻烦:浏览器后退按钮失灵了、刷新永远回到首页、两个页面之间传参不知道放在哪里、想给某个页面增加独立链接分享出去根本做不到。这些还不是最痛苦的,最痛苦的是业务复杂到一定规模,各种页面之间的跳转逻辑互相纠缠,没有统一的路由表,代码根本没法维护。React Router 的价值就在这里:它把 URL 作为唯一数据源,所有页面状态都围绕它组织,组件只需要声明自己的路由位置关系,运行时的匹配和渲染交给库去处理。
1.2 手写路由 vs 成熟方案,差距在哪里
有朋友会问:路由不就是一个 URL 对应一个组件吗,我自己写个 map 映射不行吗?确实,最基础的功能自己写很快,大概长这样:
const routes = { '/home': HomePage, '/about': AboutPage, }; function resolvePath(path) { const Component = routes[path] || NotFoundPage; return <Component />; }这段代码在只有一个层级、不做动态参数、不嵌套布局的时候是够用的。但真实业务一上来就复杂了:路由需要带参数(/user/123),需要嵌套(一级菜单下有二级菜单,页面要有统一的侧边栏和顶栏),需要有默认页和 404 页,需要做权限控制,甚至要支持分页、搜索参数留在 URL 里方便分享和刷新恢复。手写方案要处理动态路径匹配、历史记录管理、组件复用时机、滚动位置恢复……每一项都是细节活,而且要测试且保证不出边界问题,工程量很大。
React Router 把这些能力全部内置了。参数匹配有useParams,查询字符串有useSearchParams,嵌套布局用Outlet就行,历史栈由内部管理好,你只需要声明式地描述页面之间的层级关系。从工程可维护性的角度看,写一套稳定的路由层远比用时一两天的 DIY 方案省心得多。这也解释了为什么这个库能在社区里长期处于统治地位——它把路由这块最难啃的骨头啃完了,项目可以安心把精力放在业务上。
2. 版本选型:v6 与 v7 到底怎么选
2.1 v5 到 v6 的关键变化
每个用过 React Router 的人大概都能感受到 v5 到 v6 的打磨力度,整体 API 思路变化很大。如果你是从老项目升级上来的,有几个改动点必须关注清楚。
v5 时代用的是Switch,选择第一个匹配到的路由:
<Switch> <Route path="/home" component={HomePage} /> <Route path="/user/:id" component={UserDetail} /> </Switch>这写法逻辑上是"从上到下逐个比对,找到第一个符合的",所以路由顺序很敏感,经常要小心把/:id放在静态路径后面,否则会误匹配。而且 v5 匹配的规则比较粗暴,路径完全前缀匹配,/user开头的路径都算匹配上/user这条路由,很容易出现两个路由同时命中但页面渲染错误的问题。
v6 把Switch换成了Routes,匹配逻辑变化很大:
<Routes> <Route path="/home" element={<HomePage />} /> <Route path="/user/:id" element={<UserDetail />} /> </Routes>v6 用的是自动打分排序,不再依赖书写顺序,它会根据路径的精确度自动选出最合适的那条。/user/123会优先匹配/user/:id,不会和/user本身混淆。同时 route 和组件绑定的方式也从 component 属性变成了 element 属性,后者更直白,传 props 也更顺畅。还有个非常明显的变化是导航 API,v5 里是useHistory().push(),v6 改成了useNavigate(),我刚开始升级也改了一阵子才顺手,但新版明显更统一、更贴合 React 的思维模式。
另外 v6 把嵌套路由做了一个非常大的改进:子路由不再通过包含关系靠 React 组件树隐式渲染,而是显式声明嵌套结构,子页面通过Outlet在父组件里占位渲染。这等于把"布局复用"变成了第一等公民,中后台那些"左边菜单 + 右边内容区"的框架页写起来极其顺手。后面我会单独讲这块。
如果你还在维护 v5 老项目,我的建议是不要犹豫,尽早规划升级。v6 的 API 设计明显更经得起构建,社区里新出的库、新写的资料、新出现的 bug 解决方案都默认 v6,长期停在老版本只会越来越孤单。
2.2 v7 的框架模式和适用场景
v7 是最近社区讨论最多的话题。它把路由玩得更大了,不再只是一个路由库,而是加了一套数据加载机制,包括 loader/action、错误处理边界这些,甚至顺势推出了react-router框架模式,可以把它理解成一个轻量级全栈应用的底座。
先说 loader/action。以前我们在路由层只是渲染组件,数据请求全写在组件里,进页面先显示 loading,再请求,再渲染。v7 给出了另一种组织方式:在路由上声明 loader,路由激活之前先执行这个 loader 拿到数据,组件再渲染时数据已经就位了。代码大概长这样:
const router = createBrowserRouter([ { path: '/dashboard', loader: () => fetch('/api/dashboard').then(res => res.json()), Component: DashboardPage, }, ]);这样做的好处是你把"取数据的逻辑"和"渲染数据的组件"解耦了,路由层负责数据依赖,组件只关心怎么展示。加上 action 之后还解决了表单提交这种场景的代码组织问题,提交动作和状态更新都可以统一管理。
但是,我不是说新项目无脑上 v7 就好。如果你的项目就是一个标准的中后台前端,页面彼此独立,没有复杂的数据依赖,也没有服务端渲染的需求,那用 v6 的BrowserRouter配合自己已有的请求封装,完全没问题,还更简单。v7 框架模式更适合那些想做全栈化、需要开发服务端能力、或者统一管理数据侧效果的项目。技术选型不要追新,要看你手里的问题到底需不需要这些能力。
我的建议很简单:新项目如果团队对 v6 已经很熟,先以 v6 起步,学有余力再看 v7 的 loader 思路,不要两头都学结果两头都不熟;如果项目一开始就打算上 v7 的数据路由,那直接一步到位也行,但要对新手队友更友好一点,多留文档和示例。
3. 核心 API 与嵌套路由原理拆解
3.1 声明式路由的基本写法
先给个完整可跑的最小示例,这是我们项目里最开始搭的骨架,基本上一看就知道 React Router 在干嘛:
import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route, Link, } from 'react-router-dom'; function Layout() { return ( <div> <nav> <Link to="/">首页</Link> <Link to="/list">列表页</Link> <Link to="/detail/001">详情页</Link> </nav> <Outlet /> </div> ); } function HomePage() { return <h1>这是首页</h1>; } function ListPage() { return <h1>这是列表页</h1>; } function DetailPage() { const params = useParams(); return <h1>详情:{params.id}</h1>; } createRoot(document.getElementById('root')).render( <BrowserRouter> <Routes> <Route element={<Layout />}> <Route path="/" element={<HomePage />} /> <Route path="/list" element={<ListPage />} /> <Route path="/detail/:id" element={<DetailPage />} /> <Route path="*" element={<h1>404 页面</h1>} /> </Route> </Routes> </BrowserRouter> );这里的层级关系很清晰:BrowserRouter是历史记录的提供者,Routes是做匹配的核心,Route声明路径和组件关系。path="*"表示匹配不上任何路由时的兜底页面,也就是 404。
在实际项目中,我还会推荐直接把路由配置提取成独立文件,用 useRoutes 集中定义,这样路由表一目了然,和菜单配置也能对应起来,后面权限控制的改造也更方便。比如这样:
const routes = [ { path: '/', element: <HomePage /> }, { path: '/list', element: <ListPage /> }, { path: '/detail/:id', element: <DetailPage /> }, { path: '*', element: <NotFoundPage /> }, ]; function AppRoutes() { return useRoutes(routes); }两者在效果上是等价的,但配置式的写法在项目规模上来之后维护性明显更好。路由和菜单的映射关系也能从同一份配置里派生,少维护一套数据。
3.2 Outlet 嵌套:布局复用原来是这么回事
嵌套路由是 React Router 最值得花时间理解的概念之一。以前 v5 做布局复用要自己组合组件,手动判断子路由渲染在哪个容器里,写多了就想吐。v6/v7 用Outlet把所有嵌套逻辑收敛了。
你可以把Outlet想象成父组件里的一个插槽。父路由匹配时,父组件渲染自己的布局部分,遇到<Outlet />的位置,就把匹配到的子路由组件填进去。看下面这个例子:
function UserLayout() { return ( <div style={{ display: 'flex' }}> <aside>用户中心菜单</aside> <main><Outlet /></main> </div> ); } // 路由配置 <Route path="/user" element={<UserLayout />}> <Route index element={<UserProfile />} /> <Route path="orders" element={<UserOrders />} /> <Route path="settings" element={<UserSettings />} /> </Route>这样当你访问/user/settings时,UserLayout会渲染,左侧菜单始终在,右侧<Outlet />所在位置渲染UserSettings。布局组件只写一次,所有子页面共享。这个能力对中后台项目太关键了——侧边栏、顶栏、面包屑,全都放在布局组件里,子页面干干净净只写自己的内容。
注意这里有个index配置,它表示父路径匹配时默认渲染的子路由。访问/user时,父路由匹配了,但没有子路径可匹配,index 就作为默认内容展示。这个和 404 通配符不同,别搞混。
嵌套层级是可以一直向下的,比如/user/settings/security这种三级路径,每层都可以有自己的布局组件。一开始写项目我习惯把所有页面拍平,后来发现嵌套路由合理组织以后,组件复用和工作量完全不是一个量级。强烈建议:拿到需求先画页面层级树,再对应生成嵌套路由结构,这样布局和 URL 天然对应。
3.3 链接、跳转与参数读取的多种姿势
路由不只是渲染,页面上大量交互是"跳转"和"取参"两类事情。跳转最常用的是Link和useNavigate。
Link适合在 JSX 里渲染可点击的链接,它会被渲染成a标签,但因为被 Router 拦截处理,所以点击时不会真的刷新页面:
<Link to="/list">去列表页</Link> <Link to={`/detail/${item.id}`} state={{ from: 'list' }}> 查看详情 </Link>useNavigate适合在事件回调里做编程式跳转:
const navigate = useNavigate(); function handleSubmit() { await saveData(); navigate('/list'); }这里有个小细节经常被忽略:Link的state参数。它可以把一个可序列化的对象塞进历史记录,在跳转后的页面用useLocation读出来,相当于在页面之间额外带了一份临时数据:
const location = useLocation(); const from = location.state?.from;用 state 传参有个好处,就是这份数据不会暴露在 URL 里,对于某些不想被收藏、不想被人看到的中间状态很实用。缺点是你刷新页面之后 state 可能就丢了,所以不要把关键业务数据完全依赖它,该放 URL 的放 URL。
读参数有三个 API 要分清楚:
useParams():读路由路径里的动态段,比如/detail/:id里读id。useSearchParams():读 URL 问号后面的查询参数,比如/list?page=2&keyword=xxx,返回一个类似 state 的读取更新对象,可以用searchParams.get('page')取值。useLocation():读整个 location 对象,包含 pathname、search、hash、state 等。
用得最多的是前两个。我的习惯是:路径里的动态值,比如资源 ID,尽量放 path 参数,语义清晰;而筛选条件、分页页码、搜索关键词这类辅助信息,放 search 参数,因为刷新后它们依然在 URL 里,页面状态能可靠恢复。这个习惯在做一个数据报表项目时尤其受用——用户翻到第 10 页后刷新,还停在原来的页码,体验真的不一样。
4. 路由守卫、权限控制与懒加载落地
4.1 页面鉴权拦截怎么设计最自然
几乎每个后台系统都有登录态的要求:没登录的人访问后台页面,自动踢回登录页。这个需求 React Router 没有内置"守卫"组件,但实现起来很简单,思路是写一个包装组件,拦截子路由渲染。
我的做法是建立一个RequireAuth组件:
function RequireAuth({ children }) { const token = useAuthToken(); // 从 context / store 里拿登录态 const location = useLocation(); if (!token) { return <Navigate to="/login" replace state={{ from: location }} />; } return children; }然后在路由配置里对需要登录的页面统一包一层:
<Route element={<RequireAuth />}> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Route>这里有个很细节的地方:RequireAuth我用了一个嵌套路由元素的形式,它没有path,只是一个拦截壳。访问/dashboard时,先匹配到RequireAuth,它检查通过就渲染子路由,不通过就跳转。这个方法比在每个页面组件里单独判断要干净得多,新增一个需要鉴权的页面只需要往这个分组里加,不会漏判。
replace参数也很好用。登录成功后再跳回原来的页面,你可以把 from 存在登录页里,登录完成后navigate(from, { replace: true }),让用户回到本来要去的地方。这样用户体验很自然,不用每次都从首页重新点。
4.2 懒加载与 Suspense 的配合
中后台项目打包出来动辄几 MB 以上,首屏加载会有明显的白屏时间。路由级别的懒加载是见效最快的优化手段之一:把每个页面组件的代码切分成独立 chunk,访问到哪个路由才加载对应的 JS。
配合 React Router 的写法是:
import { lazy, Suspense } from 'react'; const DashboardPage = lazy(() => import('@/pages/dashboard')); const SettingsPage = lazy(() => import('@/pages/settings')); <Routes> <Route path="/dashboard" element={ <Suspense fallback={<PageLoading />}> <DashboardPage /> </Suspense> } /> </Routes>这里的要点是:lazy组件在渲染过程中如果还没有加载完成,需要有一个Suspense包裹,提供加载期间的占位 UI。如果不包,React 会直接报错。
项目里组件多了以后,我习惯把懒加载逻辑封装成一个公共函数,统一处理 Suspense 和加载动画:
function lazyLoad(importFn) { const LazyComponent = lazy(importFn); return ( <Suspense fallback={<PageLoading />}> <LazyComponent /> </Suspense> ); } // 使用 { path: '/dashboard', element: lazyLoad(() => import('@/pages/dashboard')) }这样所有路由的 import 都走同一个入口,加载动画样式统一,也不用每个路由都写一遍 Suspense。有一点要提醒:懒加载不要切得颗粒度太细,一个页面组件就是一个 chunk 就够了。如果连弹窗、工具函数都按需加载,HTTP 请求的数量蹭蹭往上涨,局域网开发还好,生产环境弱网下一堆小请求反而更慢。
4.3 大型项目的路由表如何组织
项目规模一大,路由表的组织就会变成影响开发效率的隐形因素。我参与过的一些项目最终都形成了比较一致的规范,这里分享一下。
第一个原则是路由配置和页面目录一一对应。比如企业管理系统,按业务域分目录:
src/router/ index.js # 汇总入口 routes.js # 根路由总表 modules/ user.js # 用户中心路由 order.js # 订单模块路由 report.js # 报表模块路由每个模块导出自己的子路由数组,根路由汇总时拼接起来。这样多人协作时每个人维护自己模块的路由文件,冲突概率低,路由职责也清晰。
第二个原则是统一用配置式useRoutes,不散落Route标签在各组件里。有人喜欢把路由写在各个业务组件的顶层,看起来内聚,时间长了整个项目的路由入口分散得到处都是,新同学接手时掏心窝子难受。集中在一个入口,想切换、加权限、查嵌套结构都很方便。
第三个原则是善用统一布局的路由分组。侧边栏框架、空白页、全屏页这些不同类型的页面,用不同的父路由分组隔开:
const routes = [ { element: <MainLayout />, children: [ { path: '/dashboard', element: <DashboardPage /> }, { path: '/order', element: <OrderPage /> }, ], }, { path: '/login', element: <LoginPage />, }, ];这样做的好处是布局和业务之间界限分明,也不用每个页面单独引入MainLayout。凡是页面顶栏侧边栏组合样式统一的,全都归到同一个布局分组里。
5. 高频踩坑记录:从刷新 404 到参数不更新
5.1 上线后一刷新就 404,是服务器该背的锅
这应该是 React Router 用户遇到最多的生产事故之一。本地开发一切正常,部署上线后,用户从首页点进子页面,然后按 F5 刷新,直接 404。原因其实不复杂:BrowserRouter用的是 History API,路由切换只是前端改了 URL,不会请求服务器。但用户一旦在/list页面刷新,浏览器会以/list这个完整路径去请求服务器静态资源,服务器找不到这个路径对应的文件,自然返回 404。
解决办法是让服务器把所有路径的回退都指向应用的 index.html。以 Nginx 为例:
location / { try_files $uri $uri/ /index.html; }这段配置的意思就是:如果请求的文件不存在,就回退到 index.html,把路由决策交给前端。
部署的时候这里还能看到一些玻璃渣。如果应用不是部署在域名根路径,而是在子路径下,比如http://example.com/admin/,React Router 需要知道基础路径:
<BrowserRouter basename="/admin">同时 Nginx 配置也要针对这个子路径做回退,否则还是 404。我踩过一次坑:忘了配 basename,只配了服务器回退,结果本地好好的,部署到子目录后刷新全挂。其实这里两步缺一不可,basename 让路由知道自己的根,服务器回退让刷新请求有地方落地。
如果实在嫌服务器配置麻烦,也可以用HashRouter,把路由放在 URL 的 hash 里,比如http://example.com/#/list。hash 部分不会发到服务器,所以刷新一定不会 404。代价是 URL 丑一些,且在 SEO 场景下有一定劣势。我的看法是:只做内部管理系统,或者没有服务端配置权限的时候,HashRouter 是很稳妥的方案;面向公网、对分享链接和用户感知有要求的场景,还是用 BrowserRouter 多费点心思配服务器,体验更正规。
5.2 同一路由参数变化,组件为什么不重新加载
这种情况非常隐蔽但几乎人人都会掉进去:页面上有一个列表,点每条数据跳转到同一个详情页组件,只是路径参数不同。第一次点 A,详情页正常刷新;返回列表,再点 B,发现页面内容没变,还是 A 的数据。
原理是:React Router 复用组件实例。从/detail/a跳到/detail/b,匹配到的是同一个路由配置、同一个组件类型,React 为了性能会复用这个组件,不会走重新挂载的流程,所以组件内部的 state、请求逻辑都不会自动重置。
解决办法有两个方向。首选是在业务组件里监听参数变化,重新拉数据:
function DetailPage() { const { id } = useParams(); useEffect(() => { fetchDetail(id); }, [id]); }useEffect的依赖数组里包含id,它一变,请求逻辑重新执行,页面数据自然更新。但如果组件里有很多一次性初始化逻辑,比如进入页面时清空表单、滚动到顶部这种,只靠 effect 重置会很啰嗦。
另一种方案更粗暴直接,给路由组件加 key,强制每次参数变了就重新挂载:
<Route path="/detail/:id" element={<DetailPage key={id} />} />不过 Route 的 element 是在渲染时确定 key 的,这种写法能否在参数变化时更新 key 要看具体版本和写法。更可靠的挂载点是在页面内部,对组件加 key 或者在外层包一层带 key 的容器。我实际项目里更常用 effect 监听 params 的方式,因为业务上参数变化后通常只是数据变化,界面状态不至于全部重置,重新挂载反而可能丢用户的输入状态。两个思路没有绝对优劣,看你业务里到底希望保留什么状态。
5.3 鉴权守卫的 Loading 闪烁问题
这个坑在权限路由方案里最常见。用户刷新后台页面时要先确认登录态,而登录态可能是异步从接口或本地缓存里读取的。一开始我的错误写法是这样:
function RequireAuth({ children }) { const user = useUser(); if (!user) { return <Navigate to="/login" replace />; } return children; }问题来了:页面首次加载时,user 还没有从接口取回来,值是 null,组件直接判定"未登录",把用户踢到了登录页。等接口返回了真实登录态,一切已经来不及了——登录页都闪过了。
正确做法是必须把"还在读取中"的状态单独处理:
function RequireAuth({ children }) { const { user, loading } = useUser(); if (loading) { return <PageLoading />; } if (!user) { return <Navigate to="/login" replace />; } return children; }loading 为 true 时先展示加载动画,什么都不判断;loading 结束后才决定放行或踢回登录页。这个改动看起来很小,在真实用户体感里却是从"登录页一闪而过"到"正常进后台"的区别。类似的道理也适用于全局数据初始化、路由懒加载等场景:凡是冷启动阶段有异步依赖的,都必须先把加载态拦截下来,否则初始化内容未就绪时,所有判断都不可信。
5.4 其他几个容易记不住的小问题
再补几个我常碰到的小问题和排查思路,整理成表格方便查阅。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 页面跳转后滚动条停留在旧位置 | 路由不处理滚动恢复,组件复用 | 在路由切换时手动window.scrollTo(0, 0),或给页面容器挂 key |
| 首页能访问,子路由刷新 404 | BrowserRouter 部署缺少回退 | 配置 Nginx try_files 或服务端 fallback |
| 组件间用 Location.state 传参刷新丢失 | state 只存在于当前历史记录 | 关键参数放 path 或 search 参数 |
| 菜单 NavLink 高亮样式不对 | 用错了 Link,或 path 匹配不准确 | 用 NavLink,通过 isActive 回调判断高亮 |
| useRoutes 后面包了空父路由 | 嵌套结构声明不对 | 检查父路由是否有子路由以及 Outlet 是否书写 |
| 路由表很大时渲染有延迟 | 所有页面同步加载,chunk 过大 | 页面级懒加载 + Suspense |
NavLink 这块我多说一句。最初我用Link做菜单,然后靠 location.pathname 手动算高亮,不仅代码烦,路径前缀匹配的边界还经常算错。换成NavLink之后清爽多了:
<NavLink to="/dashboard" className={({ isActive }) => (isActive ? 'menu-item active' : 'menu-item')} > 工作台 </NavLink>它会自动判断当前路由,渲染的时候给激活状态一个回调标记,放置到样式上就完成了高亮。URL 与菜单的同步问题归它管,自己就不用再瞎猜。
6. 关于 React Router 我的一些实操体会
用了几年下来,最大的一个感受是:路由层不是应用里最有技术含量的部分,但它是整个应用的骨架,骨架歪了,上层页面写再多都是徒劳。我在好几个项目里见过一开始图省事拍平所有页面、不做嵌套布局、路由表随便堆在组件里的情况,等业务复杂起来要重构,成本实在让人恼火。反过来,凡是动手前先把页面层级树画一遍、把路由结构跟着设计一遍的项目,后面加页面、加权限、加嵌套布局都顺滑得多。
还有一个体会是关于版本心智的。React Router 的文档更新速度并不慢,但社区大量文章和视频还停留在旧版本,新手查资料很容易被旧写法带偏。我的建议是:打开官方文档看一眼当前的 API 示例,比搜旧博客更快更准。遇到拿不准的 API,先确认版本,再查资料,不要看到一个写法就抄。
最后分享一个小技巧:如果你刚接手一个老项目,想快速理清路由关系,直接在路由表入口文件全局搜Route和path=就能列出一张页面清单,再对比页面目录结构,应用的骨架基本就掌握了。反过来,如果你想做一个新项目,不要直接写代码,先在纸上画出 URL 层级和布局关系,React Router 的嵌套路由完全可以 1:1 对应到页面层级上,这一步想清楚了,写路由代码就是填表格的活,一天内就能搭起一个像样的基础架构。