
做前端的应该都遇到过这样的场景一个管理后台、一个数据大屏、一个带多页面的 H5 应用只要它从“一屏展示”变成“多页面切换”路由这块就绕不过去。React 生态里最主流、面试问得最多的就是 React Router。这套库解决的核心问题非常简单直接单页面应用SPA怎么在不刷新浏览器的情况下切换界面同时又让地址栏、前进后退、收藏分享这些浏览器原生行为保持正常。这篇文章我打算从路由的本质讲起覆盖 React Router 的版本选型、模式选择、嵌套路由配置、权限拦截、传参方式再到高频报错和排查思路。基本上你从一个什么都不懂的新手到能独立把一个后台系统的路由体系搭起来就差这一篇的距离。无论你是在学 React 的路上还是准备面试或者是接手老项目被路由问题折磨都应该能在这里找到答案。1. 单页面应用为什么必须有一套导航系统1.1 没有路由之前我们是怎么写页面的早几年写传统多页面应用比如 JSP、PHP 或者简单的 HTML 文件每个页面都是一个独立的地址。用户访问index.html是一个页面点一下“关于我们”浏览器会重新向服务器发起请求返回about.html再渲染一遍。整个过程页面会白屏、会闪烁资源要重新加载体验谈不上好。SPA 出现之后整个应用往往只有一个 HTML 入口界面切换靠的是 JavaScript 动态替换 DOM 节点。React 里就是“根据不同的条件渲染不同的组件”。那问题来了用户点击了“个人中心”页面确实切换了但地址栏可能还停留在首页。这时候如果用户想复制当前页面链接给同事复制出去的是首页地址他按一下 F5 刷新又会跳回第一个页面浏览器自带的前进后退按键也完全失效。路由系统解决的核心问题就是把“界面状态”和“URL 地址”绑定在一起。地址栏里是什么路径应用就渲染什么界面用户主动改地址应用也能正确响应。这才是单页面应用导航系统的本质——它不只是“切换页面”而是把浏览器最基础的导航能力拿回来给 SPA 用。1.2 React Router 与 Vue Router 在设计上的差异网上天天有人对比 Vue 和 React其实单看路由就能看出两个生态的性格差异。Vue Router 是典型的配置式你写一个路由数组每个路由对应一个组件看起来非常规整、约束强。React Router 则更像组件化思维下的产物——路由本身就是一个组件BrowserRouter包裹整个应用Routes声明匹配区间每个Route就是一个映射规则。这种差异最直接的影响是React Router 的使用方式跟 React 本身高度统一。你可以把路由当作组件树的一部分可以做嵌套、可以做布局复用、可以在任意组件里通过 Hooks 拿到路由信息。Vue 开发者上手 React Router 会觉得“怎么没有全局路由配置文件”React 开发者去看 Vue Router 又会觉得“怎么这么死板”。两种思路没有优劣之分但你在 React 项目里就得用 React 的方式来理解路由。1.3 路由和 React 渲染机制的关系还有一个容易被忽略的点路由跳转本质上是一次状态驱动的重新渲染。React Router 内部维护了当前路径的状态路径变化时它通过 React 的 context 把最新的 location 信息传给所有订阅了路由的组件。这也就是为什么 React 官方一直强调“路由也是状态的一部分”以及面试题里常问的“React 为什么每次渲染都返回一个新的 render 函数”这类问题——因为只有保证状态变化时组件能正确拿到最新的值路由跳转后的界面更新才会可靠。理解了这一层后面所有 API 的用法就都不难记忆了。你只需要记住路由在 React 里不是框架魔法它是一套利用 React 本身机制实现的状态同步工具。2. 版本选型与两种路由模式开写之前必须想清楚2.1 还在用 v5先看看 v6 改了什么我不知道你现在接手的项目用的是哪个版本但我的建议很明确新项目一律用 React Router v6别犹豫。v5 和 v6 的 API 差异大得几乎像两个库如果你是 v5 老手第一次碰 v6 一定会觉得“怎么全变了”。几个最主要的变化列个表对比着看就清楚了功能v5v6路由匹配容器SwitchRoutes路由规则传参component{About}element{About /}路由参数读取useParams()仅在类组件/函数组件内同样useParams()但嵌套匹配逻辑更明确编程式跳转useHistory().push()useNavigate()重定向Redirect to/ /Navigate to/ replace /默认路由用path/exact用index属性嵌套路由组件内嵌套Route父路由组件内使用Outlet /v6 把这些改动做得更彻底的一点是v5 里容易踩的坑在 v6 里从设计上就规避了。比如 v5 里Switch匹配多个路由时必须小心翼翼安排路由顺序放前面的先匹配v6 的Routes会基于路径的优先级自动打分匹配/user/list这种具体路径永远比/user/:id优先不用你手动调顺序。这种设计还是很贴心的。2.2 HashRouter 和 BrowserRouter别再傻傻分不清这是新手问得最多的问题之一也是很多线上事故的来源。两种路由模式解决的是同一个问题——“URL 变化了怎么让应用感知到”但实现方式完全不同。HashRouter把路径放在#号后面比如http://example.com/#/user/list。#之后的内容变化不会触发浏览器向服务器发请求而是触发hashchange事件React Router 监听这个事件来更新界面。它的最大优势就是兼容性极好、不需要任何服务器配置你把打包产物放到任何静态服务器上刷新、收藏、分享都不会出问题。BrowserRouter则使用 HTML5 History APIpushState和popstateURL 是干干净净的http://example.com/user/list看起来和传统多页面应用没什么区别。但问题也出在这里用户直接访问/user/list或者刷新页面时请求会真实发到服务器服务器如果不知道该怎么处理这个路径就会返回 404。这个模式必须依赖服务器把未知路径全部重写回index.html。生产环境怎么选我的建议是对内使用的后台管理系统、内部工具直接用 HashRouter省心省力服务器不用特殊处理对外展示的 C 端应用对 URL 美观度有要求的用 BrowserRouter同时必须把服务器配置写进部署文档在 Vite 本地开发环境里用 BrowserRouter还要改一下 dev server 配置否则刷新也会 404// vite.config.js export default defineConfig({ plugins: [react()], server: { historyApiFallback: true, }, });顺便说一句Webpack 环境对应的配置是devServer: { historyApiFallback: true }原理完全一样。2.3 我踩过的一个部署坑有一次我把一个用 BrowserRouter 的 React 应用部署到一台 Nginx 服务器上本地一切正常线上首页也正常但用户一点“登录”再刷新直接 404。排查了半天才发现 Nginx 配置里只写了静态文件服务没有做路径重写。后来加了一行try_files就好了location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }这段配置的意思是先试图访问真实存在的文件访问不到就一律返回index.html把路由解析交给前端。这个坑真的太经典了以后看到 SPA 刷新 404第一反应就应该是服务器配置问题而不是路由代码问题。3. 从零配置一套完整路由系统手把手拆解3.1 推荐使用 createBrowserRouter而不是老一套说完了选型开始真正动手写代码。v6 刚发布时大家用得比较多的是BrowserRouter包裹Routes的写法import { BrowserRouter, Routes, Route } from react-router-dom; function App() { return ( BrowserRouter Routes Route path/ element{Home /} / Route path/about element{About /} / /Routes /BrowserRouter ); }这种写法没问题也能跑。但我更推荐 v6.4 之后引入的createBrowserRouterRouterProvider模式。它让路由配置从“JSX 嵌套”变成“对象数组”更像 Vue Router 那样集中管理而且天然支持数据加载loader和错误处理errorElement工程上一个档次的体验。先安装依赖npm install react-router-dom然后创建一个独立的路由配置文件router.jsximport { createBrowserRouter } from react-router-dom; import App from ./App; import Home from ./pages/Home; import UserList from ./pages/user/List; import UserDetail from ./pages/user/Detail; import NotFound from ./pages/NotFound; const router createBrowserRouter([ { path: /, element: App /, children: [ { index: true, element: Home / }, { path: user, element: UserList / }, { path: user/:id, element: UserDetail / }, ], }, { path: *, element: NotFound / }, ]); export default router;在入口文件main.jsx里挂载import React from react; import ReactDOM from react-dom/client; import { RouterProvider } from react-router-dom; import router from ./router; import ./index.css; ReactDOM.createRoot(document.getElementById(root)).render( React.StrictMode RouterProvider router{router} / /React.StrictMode );这套结构的好处是路由表和页面组件完全解耦将来加页面、做权限控制、做面包屑导航都是改一个配置文件的事不用满项目地找 JSX 里嵌套的Route。3.2 嵌套路由和布局复用Outlet 是关键几乎所有的后台系统都有这样的布局顶部导航栏和侧边菜单是固定的中间内容区域随路由切换。这时嵌套路由就派上大用场。看上面的路由配置App组件作为一级路由的 element内部需要渲染子路由的内容这个渲染出口就是Outlet /// App.jsx import { Outlet, Link } from react-router-dom; export default function App() { return ( div classNameapp-layout header nav Link to/首页/Link Link to/user用户列表/Link /nav /header main classNamecontent Outlet / /main /div ); }子路由里的UserList、UserDetail会被渲染到Outlet /的位置。这是一个非常优雅的设计父组件的布局只写一次子页面只需要关心自己的内容区。我刚开始用 v5 的时候嵌套路由的写法要在子组件里再套一层Route和Switch又啰嗦又容易出错。v6 的Outlet把这个模型简化到了极致。3.3 动态路由、默认路由、404 兜底上面配置里已经覆盖了几个常用场景我再逐个说明index: true表示匹配父路径下的默认入口。访问/时渲染Home不管写path/exact那套东西了。path: user/:id:id是动态路由参数可以匹配/user/1、/user/abc等任意值。path: *通配符任何没有匹配上的路径都会渲染NotFound组件这是 404 兜底的标准做法。再强调一次v6 的路径匹配是有优先级切分的。/user/list和/user/:id同时存在时访问/user/list一定命中前者访问/user/123才命中后者你不用再像 v5 那样靠顺序来保证匹配正确。在组件里读取动态参数也很简单import { useParams } from react-router-dom; export default function UserDetail() { const { id } useParams(); return div当前查看的用户 ID 是{id}/div; }3.4 路由懒加载页面太多不优化会卡项目页面一多把所有页面的 JS 打包成一个文件首屏加载会非常痛苦。用 React 自带的lazy和Suspense做代码分割可以按路由拆分打包访问哪个页面时才加载哪个页面的代码。配合createBrowserRouter可以直接给 element 包一层 lazyimport { lazy, Suspense } from react; const UserList lazy(() import(./pages/user/List)); const UserDetail lazy(() import(./pages/user/Detail)); // 在外层包一个懒加载容器 function LazyLoad({ children }) { return Suspense fallback{div页面加载中.../div}{children}/Suspense; } const router createBrowserRouter([ { path: /, element: App /, children: [ { index: true, element: Home / }, { path: user, element: LazyLoadUserList //LazyLoad }, { path: user/:id, element: LazyLoadUserDetail //LazyLoad }, ], }, { path: *, element: NotFound / }, ]);这里的关键点在于Suspense 必须放在懒加载组件的父级不然会报错。你也可以把Suspense统一放到RouterProvider外面包一层但那样切换路由时整个应用都会显示加载状态体验不好。按布局分块处理才是正确姿势。4. 页面跳转与参数传递工程里的实际操作指南4.1 Link、NavLink、useNavigate各管一摊路由配置好了接下来是页面之间怎么跳。React Router 提供了三种方式各有各的使用场景先看最基础的Link。它会渲染成一个a标签但内部阻止了默认的整页跳转改由路由接管。能用Link的地方尽量用它因为搜索爬虫能识别、用户中键新标签打开也能正常工作这点 React Router 处理得比很多框架都好。NavLink是Link的升级版多了一个“当前激活”的判断能力。做侧边菜单时特别常用import { NavLink } from react-router-dom; NavLink to/user className{({ isActive }) (isActive ? menu-item active : menu-item)} 用户管理 /NavLinkisActive是回调函数里自动传入的参数激活状态会同时影响aria-current属性无障碍也顺便做好了。编程式跳转则用useNavigate。比如表单提交后跳转到成功页、倒计时结束后自动跳转这些场景没法用Link声明式地做import { useNavigate } from react-router-dom; function LoginButton() { const navigate useNavigate(); const handleLogin () { // 登录逻辑... navigate(/dashboard, { replace: true }); }; return button onClick{handleLogin}登录/button; }replace: true表示替换当前历史记录用户按返回键不会回到登录页这个细节做登录跳转时非常重要。4.2 三种传参方式哪个场景用哪个页面跳转经常需要带参数React Router 里常用的有三种方式很多人搞不清楚区别我做了一张对比表传参方式URL 示例读取方式优点缺点params 路径参数/user/123useParams()URL 语义清晰可分享需要定义动态路由query 查询参数/user?id123useSearchParams()灵活可选参数多URL 较长state 隐式传参URL 不变useLocation().state不暴露数据可传对象刷新后丢失params适合标识资源的 ID比如用户 ID、文章 ID这种参数是页面 URL 不可分割的一部分。query适合筛选条件、分页页码这类非结构化参数。state则适合传一些临时数据比如列表页跳转详情页时顺手带个“从哪个列表进来”的标记。配合 useSearchParams 读取 query 参数写法如下import { useSearchParams } from react-router-dom; function UserList() { const [searchParams, setSearchParams] useSearchParams(); const page searchParams.get(page) || 1; const changePage (newPage) { setSearchParams({ page: newPage, size: 20 }); }; return ( div 当前第 {page} 页 button onClick{() changePage(2)}跳到第 2 页/button /div ); }setSearchParams同时也支持传入{ replace: true }避免每次翻页都在历史记录里堆一堆条目。4.3 传参最容易踩的坑state 传参有个致命问题刷新页面就没了。useLocation().state存在内存里浏览器一刷新React Router 重新初始化state 变成undefined组件里如果不做判空直接.xxx直接白屏。我的习惯是URL 里能体现的参数绝不放 state比如详情页的 ID 永远走 params只有那种“列表页 - 详情页”的过渡性信息比如“从编辑页返回时展示提示文案”才用 state。而且读取时一定要给默认值const location useLocation(); const fromEdit location.state?.fromEdit ?? false;另一个容易踩的坑是动态路由/user/:id当用户在/user/1和/user/2之间跳转时React 会觉得这是同一个路由组件实例会被复用不会重新执行挂载逻辑。如果你把数据请求写在useEffect的首次挂载里就会发现 ID 变了但页面数据没更新。解决办法是让副作用依赖路由参数const { id } useParams(); useEffect(() { fetchUser(id); }, [id]);5. 路由守卫与权限拦截后台系统的刚需5.1 为什么 React Router 没有内置的 beforeEach用过 Vue Router 的人都知道它有全局前置守卫可以在跳转前拦截。React Router v6 没有提供等价的 API官方文档里也只字未提“路由守卫”。这是两种框架的设计哲学差异Vue Router 是配置式框架倾向于提供守卫钩子让你在路由层控制React Router 更希望你把拦截逻辑写成组件用“组件包裹”的方式实现。那你可能会问那权限控制怎么做答案是用一个封装组件包住需要鉴权的页面。这也是社区里最主流、最 React 风格的方案。5.2 手写一个 RequireAuth 拦截组件思路很简单这个组件不渲染任何 UI也不负责页面内容它只做一道检查——用户是否已登录。已登录就渲染子路由内容未登录就跳转到登录页并记录下目标路径等登录成功后回跳。import { Navigate, Outlet, useLocation } from react-router-dom; function RequireAuth({ isAuthenticated }) { const location useLocation(); if (!isAuthenticated) { return Navigate to/login replace state{{ from: location.pathname }} /; } return Outlet /; }然后把它挂在路由配置中需要保护的路由外层const router createBrowserRouter([ { path: /, element: App /, children: [ { index: true, element: Home / }, { element: RequireAuth isAuthenticated{isLogin} /, children: [ { path: user, element: UserList / }, { path: user/:id, element: UserDetail / }, ], }, ], }, { path: /login, element: Login / }, ]);登录页拿到location.state.from登录成功后用navigate(from, { replace: true })回跳。这样用户原来想去哪个页面登录完就去哪个页面体验很顺滑。这里有一个必须注意的点判断条件一定要能区分“未登录”和“已登录”两个状态。如果把登录页也包进一个“不允许未登录时访问”的守卫里条件写反了就会出现“未登录去登录页 - 被踢回登录页 - 又被踢回登录页”的死循环。5.3 角色权限控制换汤不换药如果是后台管理系统通常不只分登录和未登录还分管理员、普通用户等角色。这时可以在 RequireAuth 基础上再包一层角色判断或者直接给路由配置扩展一个meta字段const router createBrowserRouter([ { path: /admin, element: RequireRole roles{[admin]} /, children: [ { index: true, element: AdminDashboard / }, ], }, ]);RequireRole组件的实现和RequireAuth几乎一样只是多检查一个角色是否满足条件function RequireRole({ roles, userRole }) { if (!roles.includes(userRole)) { return Navigate to/403 replace /; } return Outlet /; }把鉴权逻辑拆成独立组件的好处是路由配置一目了然哪些页面需要登录、哪些页面需要特定角色看配置就行不用翻业务代码。这也是路由表独立抽成文件的另一个好处。5.4 监听路由变化动态设置页面标题还有一个后台系统几乎必做的需求——切换页面时更新浏览器标签页标题。这本质上也是一种“路由变化监听”。我的做法是在布局组件里写一个 useEffectimport { useEffect } from react; import { useLocation } from react-router-dom; function usePageTitle() { const location useLocation(); const titles { /: 首页, /user: 用户管理, /user/:id: 用户详情, }; useEffect(() { const matched Object.keys(titles).find((path) new URLPattern({ pathname: path }).test(location.pathname) ); document.title matched ? titles[matched] : 默认标题; }, [location.pathname]); }这里用URLPattern做动态路由匹配可能对浏览器有要求实际项目里我更推荐从路由配置的handle字段里取标题社区里也有很多现成方案。核心思路就是路由变化就是 location.pathname 变化用 useEffect 监听它就能同步做各种副作用操作。6. 高频问题实录React Router 排错经验速查6.1 刷新后 404 和空白页先分清楚是谁的锅这个问题发生频率极高而且原因往往不止一个。如果你是 BrowserRouter刷新 404 先去查服务器配置前面 Nginx 那段已经讲过了。如果你用的是 Vite / Webpack dev server先检查 devServer 配置。如果服务器配置没问题还是 404那再检查是不是路由里的path写错了大小写、多一个斜杠、少一个斜杠都可能导致匹配不上。如果刷新后页面空白、控制台没有任何报错优先检查路由组件里有没有用useParams、useLocation之类的 Hook如果组件被包在了没有路由上下文的地方这些 Hook 会直接抛错。比如懒加载组件不小心放到了BrowserRouter外面或者useNavigate在事件处理函数里没问题但在组件初始化时直接调用都会出问题。6.2 React Error #130 这个报错到底是怎么回事网上很多人遇到Minified React error #130这种报错看起来特别吓人其实绝大多数原因只有一个把组件对象本身传给了 element而不是渲染后的组件元素。比如// 错误写法component 是 v5 的写法v6 拿来用就会报错 Route path/user component{UserList} / // 错误写法直接传组件引用 Route path/user element{UserList} /v6 的element属性接收的是 JSX 元素而不是组件构造函数。正确写法是element{UserList /}。每次看到 React Router 相关报错先检查是不是 v5 的 API 混在 v6 里用了。这类报错在生产环境压缩代码之后会变成难懂的 #130 这种错误码本地开发环境打开控制台看完整报错信息一般会直接指向具体的组件和行号。6.3 嵌套路由的路径写错页面死活不渲染v6 的嵌套路由里子路由的path不要以/开头写成相对路径就行。比如父路由path: user子路由应该写path: detail而不是path: /detail。写了绝对路径React Router 会认为你要从根路径开始找结果整个子路由树匹配不上页面自然是空白。还有一种情况是父级组件里忘了写Outlet /。很多人配置了嵌套路由子路由也在正确的位置但父组件就只渲染了自己的内容没有给子路由留出口。检查嵌套路由不渲染第一看路径第二看 Outlet两个都对了基本就能出来。6.4 登录后回跳死循环自定义权限守卫时最怕死循环。我见过一个实际案例有一个页面是RequireAuth拦截的未登录跳/login但/login本身也被同一个RequireAuth包住了只是把判断条件反过来写——已登录才能访问登录页。结果就是未登录用户访问登录页条件不允许跳到首页首页又要登录又跳回登录页无限重定向。解决办法是登录页和 404 页永远放在所有守卫的外面不要参与任何鉴权逻辑。路由嵌套层数越多这种问题越隐蔽排查思路就是从最外层的路由开始逐层检查看每条重定向链路是否都指向一个不会被二次重定向的路径。6.5 从 v5 升到 v6 的迁移陷阱老项目升级时最常见的问题集中在三个地方Switch改Routes、component改element、useHistory改useNavigate。还有一个很容易遗漏的是v5 里Route可以直接包子路由v6 里必须拆成显式的嵌套结构并用Outlet /来承接。我建议升级时先用官方提供的迁移工具跑一遍把能自动替换的都换掉剩下的手工处理部分重点检查动态路由和重定向的逻辑。升级过程中不要想着“顺手优化代码”一次只做一件事否则出了问题根本分不清是迁移引入的还是原逻辑就有问题。6.6 动态路由参数变化后页面数据不更新这个问题前面提过一次但值得再强调一遍。场景是用户从/user/1详情页点“下一个”跳到/user/2结果页面内容和 URL 不匹配还是显示第一个用户的数据。原因就是 React 复用了同一个组件实例useEffect的依赖如果是空数组[]只在挂载时执行一次。解决办法是让副作用依赖动态参数id在 ID 变化时重新拉取数据。如果还伴随 loading 状态没重置的问题那就在 useEffect 开头先主动置一下 loading再发请求。我在实际开发中也习惯在详情页每个数据卡片上加一个key{id}强制 React 在参数变化时重新渲染这部分 UI视觉上更干净也不容易出现旧数据闪烁。最后还有几件小事想聊做了这么多年 React 项目路由这块我最大的体会是它不难但必须重视而且要想清楚再动手。选 HashRouter 还是 BrowserRouter、用 v6 还是 v5、路由表抽不抽独立文件这些决策直接影响后面所有功能的开发效率。如果你现在正要新建一个 React 项目请一定在一开始就把路由的目录结构设计好别等着页面多了再重构。再分享一个我自己的小习惯路由配置文件里一定要加注释说明每个路径的作用和访问权限。这个配置文件是项目的“地图”新人接手时最先看的就是它。地图画得清楚团队协作效率能提升一大截。还有生产环境上线前拿出两分钟把配置过的服务器路由规则验证一遍成本极低能避免上线后才被发现的大麻烦。