1. 为什么是 React Router:单页应用绕不开的"导航中枢"
如果你是从零开始接触 React,可能第一反应是:我用useState就能控制组件显示,为什么非要引入一个路由库?
我用一个真实场景来回答。假设你正在做一个后台管理系统,左侧菜单点一下"用户管理",右侧内容区要切换成用户列表;再点一下"订单管理",右侧又要切过去。很多初学者会想:这不就是一个currentPage状态加几个条件渲染吗?确实,在小 demo 里这么干没问题,但一旦项目进入真实开发,你会发现几个躲不开的痛点:浏览器的前进后退按钮完全失效,用户没法把某个页面存成书签直接访问,刷新之后状态全丢。换句话说,你的应用变成了一个"没有历史记忆的黑盒"。
React Router 解决的核心问题就是这件事:它把"组件状态"和"URL地址"绑定在了一起。URL 变成应用状态的唯一事实来源,组件根据 URL 渲染对应内容。用户能收藏、能分享、能刷新不丢状态,浏览器的前进后退也能正常走。
这个库从 React Router 3 一路演进到如今的 V6/V7,API 变化挺大。如果你在网上搜到老教程,可能会被Switch、useHistory这些已经淘汰的写法搞懵。我这篇总结全部基于React Router V6 及以上的现代写法,这也是目前 create-react-app、Vite 脚手架默认的生态。
适合看这篇文章的人,不只是刚从"组件渲染"阶段跨入"需要多页面协作"的初学者。就算你已经在项目里用了一段时间 React Router,我后面讲到的嵌套路由、路由守卫、懒加载边界处理,大概率也能帮你在项目里少踩几个坑。
2. 先把路由的核心 API 和运行机制理清楚
2.1 路由模式:BrowserRouter 与 HashRouter 的取舍
在 React Router 里,第一层要选的就是用哪种"路由模式"。它决定了 URL 长什么样,也影响服务端的配置方式。
BrowserRouter用的是 HTML5 History API,URL 看起来是https://example.com/users/123,干净清爽,没有多余符号。它背后的原理是pushState和popstate事件,React Router 监听popstate来感知地址变化,从而重新渲染页面。但这里有个致命前提:服务端必须把所有未匹配到的路径都重定向到入口 HTML 文件。否则用户直接访问/users/123刷新时,服务器找不到这个路径的资源,会返回 404。本地开发时 dev server 会处理好,生产环境部署时就得专门配置 nginx 或后端路由兜底。
HashRouter则是在 URL 里带一个#,比如https://example.com/#/users/123。它监听的是hashchange事件。服务端永远只看到https://example.com/,后面的路径全部是浏览器端处理的。好处是——静态文件服务器零配置就能用,随便扔到哪个静态资源托管平台都不会出现刷新 404 的问题。缺点也明显:URL 丑,SEO 不友好,而且部分第三方登录回调对带 hash 的 URL 处理起来有点别扭。
我的选型建议:
| 场景 | 推荐方案 |
|---|---|
| 有服务端控制权的正式项目 | BrowserRouter |
| 纯静态托管、演示用、没权限改服务端 | HashRouter |
| 纯客户端渲染且不回退 SEO 的内部系统 | 两者皆可,看部署环境 |
2.2 Route 匹配的底层逻辑:路径匹配不是"完整相等"
V6 的路由匹配逻辑跟早期版本有微妙差别。V6 用的是排名匹配算法(rank route),它给每个 Route 定义算出一个优先级分数,路径越具体、分越高,最后按照分数高低从上到下匹配,而不是简单地按代码顺序"先到先得"。
举个例子:
<Routes> <Route path="users" element={<Users />} /> <Route path="users/new" element={<NewUser />} /> <Route path="users/:id" element={<UserDetail />} /> </Routes>在 V6 里,users/new这个路由会精确匹配到NewUser组件,而不是被users/:id抢走,尽管两者在表面上都能匹配。这套机制省去了大量手动排序的麻烦。早期版本的 React Router 需要你把更具体的路由写在前面,不然动态参数会"吃掉"静态路径,现在不用了。
不过有一点要注意:V6 的 Route 匹配默认是"前缀匹配"而非"绝对匹配"。这是什么意思?当 Route 没有配置end属性时,users/*会默认匹配所有以/users开头的路径。这跟 V5 的exact概念完全不同——V5 要求写exact才能精确匹配,V6 则是只有父级路由(嵌套场景)才做前缀匹配,叶子路由默认精确匹配。
2.3 Link 与 NavLink:导航不是 a 标签那么简单的替换
源码层面来说,Link组件渲染出来的最终 DOM 确实是<a>标签,但它在这个<a>上拦截了默认跳转行为,然后调用history.pushState来更新地址栏,再触发 React Router 内部的更新机制去渲染新页面。这就解释了为什么你在 React Router 应用里点链接,页面不会整页刷新。
新手最容易忽略的是NavLink。它在Link的基础上增加了"当前激活状态"的感知能力。你写导航菜单时,用这个组件可以免去大量判断当前路由的手动操作:
<NavLink to="/users" className={({ isActive }) => (isActive ? 'menu-item active' : 'menu-item')} > 用户管理 </NavLink>回调参数里拿到的isActive是 React Router 根据当前 URL 和to属性自动算出来的。菜单高亮这种看似简单实则繁琐的需求,一个NavLink就解决了。
3. 第一步实操:从零搭一个三层路由结构
3.1 环境准备与依赖安装
用 Vite 来初始化项目,速度和配置体验都比 CRA 好太多:
npm create vite@latest react-router-demo -- --template react cd react-router-demo npm install react-router-dom需要提醒的是,你装的是react-router-dom而不是react-router。这俩的关系:react-router是核心库,react-router-dom是专门为浏览器环境封装的版本,自带BrowserRouter、Link、NavLink这些 DOM 相关组件。在普通 React 项目里,直接装react-router-dom就对了,它会自动带上react-router作为依赖。专业应用如果做了 React Native 端,才需要单独处理react-router-native。
3.2 最小可运行的路由配置
main.jsx里包上路由容器:
import { BrowserRouter } from 'react-router-dom'; createRoot(document.getElementById('root')).render( <BrowserRouter> <App /> </BrowserRouter> );App.jsx里定义路由表:
import { Routes, Route, Link } from 'react-router-dom'; import Home from './pages/Home'; import About from './pages/About'; import NotFound from './pages/NotFound'; function App() { return ( <div> <nav> <Link to="/">首页</Link> <Link to="/about">关于</Link> </nav> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> <Route path="*" element={<NotFound />} /> </Routes> </div> ); }这份代码里有两个关键点值得注意。
第一个是Route element属性而不是component或render属性。这是 V6 的重要 API 变化:你传入的是一个 React 元素(也就是 JSX),而不是组件类型。好处是 props 传递变得非常直白,想给路由组件传额外参数,直接在 JSX 上写即可:
<Route path="/dashboard" element={<Dashboard userRole="admin" />} />第二个是path="*"兜底路由。这表示匹配所有未命中前面 Route 的路径,常用于 404 页面。它必须写成一个独立 Route,不能像旧版本那样直接在 Routes 外面用Redirect或match处理。这里的*本质上是"通配符",能让 Not Found 页面兜住一切无效路径。
3.3 路由表抽离的工程化思路
项目稍微一大,把所有 Route 全写在App.jsx里就会变得很臃肿。我习惯把路由表统一抽到一个独立文件里,然后通过useRoutes这个 Hook 来渲染。它的效果和手写<Routes>一样,但配置是纯对象结构,方便统一管理:
// router/index.jsx import { useRoutes, Navigate } from 'react-router-dom'; import Home from '../pages/Home'; import About from '../pages/About'; import UserLayout from '../layouts/UserLayout'; import UserList from '../pages/user/List'; import UserDetail from '../pages/user/Detail'; export default function AppRouter() { return useRoutes([ { path: '/', element: <Home /> }, { path: '/about', element: <About /> }, { path: '/user', element: <UserLayout />, children: [ { path: '', element: <UserList /> }, { path: ':id', element: <UserDetail /> }, ], }, { path: '*', element: <NotFound /> }, ]); }很多人刚接触useRoutes会觉得多此一举,但当你需要做权限判断、动态生成路由表的时候,用对象数组比写 JSX 更方便操作。比如在后端返回菜单配置的动态路由场景里,你肯定不想在 JSX 里做循环嵌套,处理对象数组会让你轻松得多。
4. 动态路由与参数管理:从传参到取参的完整链路
4.1 路径参数::id背后的匹配法则
路径参数是动态路由的核心,典型场景是详情页:/users/1、/users/2,用户点不同列表项时 URL 变化,组件读取参数渲染不同内容。
定义方式是在 path 里写:name形式的占位符:
<Route path="/users/:id" element={<UserDetail />} />在对应的组件里,通过useParams拿到参数对象:
import { useParams } from 'react-router-dom'; function UserDetail() { const { id } = useParams(); // id 就是 URL 里对应位置的值 return <div>当前用户ID:{id}</div>; }核心技术点在于"同名占位符"绑定:path里的变量名和useParams解构出来的变量名必须一致。URL/users/123中123会赋给id,如果 route 定义的是:userId,取的时候就要const { userId } = useParams()。
还有一个细节经常被忽略:useParams返回的值一定是字符串。URL 天然是字符串,即使你传的是数字,出来也是一串可转数字的文本。如果你需要数字类型的 id 去后端查询,记得Number(id)转换一下,否则某些严格模式的后端接口会返回 400。
4.2 查询参数:useSearchParams 和 useLocation 的正确用法
查询参数也叫 query string,就是 URL 里?后面的部分:/search?keyword=react&page=2。
V6 推荐直接用useSearchParams,它的用法跟 React 自带的useState几乎一样:
import { useSearchParams } from 'react-router-dom'; function SearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get('keyword') || ''; const page = Number(searchParams.get('page')) || 1; const handleSearch = (newKeyword) => { setSearchParams({ keyword: newKeyword, page: 1 }); }; return ( <div> <input value={keyword} onChange={(e) => handleSearch(e.target.value)} /> <span>当前页:{page}</span> </div> ); }setSearchParams传入一个对象,它会自动序列化成 query string 拼到当前 URL 后面。这里的useSearchParams内部其实是用URLSearchParamsAPI 实现的,所以你能用get、set、delete、has这些方法操作。
第二种方式是useLocation:
import { useLocation } from 'react-router-dom'; function SomeComponent() { const location = useLocation(); console.log(location.pathname); // "/users/123" console.log(location.search); // "?tab=profile" console.log(location.state); // 编程式导航传的 state }useLocation拿到的是"完整的当前位置对象",适合需要同时感知 hash、pathname、search 的场景。值得注意的是,useLocation放在任意层级组件里都能拿到当前路由信息,不要求组件本身是被 Route 直接渲染的。
4.3 页面传参的三种方式和它们的边界
这是面试和日常需求里都高频出现的话题。React Router 里给目标页面传数据一共有三种通道:
| 方式 | 实现 | 适用场景 | 注意点 |
|---|---|---|---|
| 路径参数 | /users/:id | 资源 ID 类,详情页 | 会暴露在 URL,刷新保留 |
| 查询参数 | ?keyword=xx | 筛选条件、分页 | 会暴露在 URL,可分享 |
| state 参数 | navigate('/users', { state: { from: 'list' } }) | 临时性数据、来源记录 | 刷新后可能丢失,不适合关键数据 |
第三种state在日常开发里很容易踩坑。它确实是编程式导航才能传的,Link也一样能传:
<Link to="/users" state={{ fromPage: 'home' }}> 去用户页 </Link>目标页面里:
const location = useLocation(); const fromPage = location.state?.fromPage;但请注意:state 只存在于内存层面,不反映在 URL 中。用户刷新页面时,浏览器只保留地址栏中的 URL,location.state会被浏览器恢复成初始值null。所以,关键的业务数据千万不要依赖 state 传参,它只适合传"从哪来、去哪回"这类不敏感的状态信息。
5. 嵌套路由与布局复用:告别"每个页面都写一遍菜单栏"
5.1 嵌套路由解决的真实痛点
先想象一个后台管理系统的页面结构:顶部是品牌区,左侧是菜单栏,右侧是内容区。切换菜单时,顶部和左侧完全不变,只有右侧内容区在变。
如果不用嵌套路由,你只能选择:要么每个页面都手动引入一个Layout组件包起来,要么在父组件里根据子路由手动做条件渲染。前者的问题是重复代码多,后者的问题是路由配置和页面逻辑耦合,维护起来相当难受。
嵌套路由就是为此设计的。它允许你为"共享同一套布局"的页面定义一组父子级的路由关系。父路由负责渲染布局,子路由在布局里的"插槽"位置渲染。
5.2 Outlet:子路由的渲染出口
V6 中父路由组件里通过Outlet占位,这其实就是一个"子路由渲染锚点"。父组件定义了统一框架,子组件渲染在<Outlet />所在的位置:
// layouts/UserLayout.jsx import { Outlet, NavLink } from 'react-router-dom'; export default function UserLayout() { return ( <div className="user-layout"> <aside> <NavLink to="/user/list">用户列表</NavLink> <NavLink to="/user/roles">角色管理</NavLink> </aside> <section className="content"> <Outlet /> </section> </div> ); }路由配置:
<Route path="/user" element={<UserLayout />}> <Route path="list" element={<UserList />} /> <Route path="roles" element={<RoleList />} /> </Route>注意这里子路由的 path 不需要加/,这是相对路径写法,表示相对于父路由的路径继续拼接。如果子路由写成/list,那它会被当成根路径,直接跳出父路由,反而匹配不了。
结果就是:访问/user/list时,渲染的是UserLayout包裹的UserList组件;菜单的激活态因为用了NavLink自动管理,完全不需要手动去比对路径。
5.3 嵌套路由的 index 路由
嵌套路由下还有一个非常实用的设定:index路由。它解决的是"父路由路径本身被访问时,该渲染什么"的问题。
比如访问/user但不带任何子路径时,上面的配置会只渲染UserLayout,右侧<Outlet />区域一片空白。加一行 index 路由就好了:
<Route path="/user" element={<UserLayout />}> <Route index element={<UserList />} /> <Route path="roles" element={<RoleList />} /> </Route>index就代表"这是父路由路径的默认子页面"。现在访问/user时,右侧内容区会直接渲染用户列表。注意,index路由的index是固定关键字,不是路径,不需要也不能写path属性。
6. 编程式导航:按钮提交之后的页面跳转
6.1 useNavigate 的基本使用
比用户点击<Link>更常见的一个场景是:表单提交成功、接口返回数据、定时操作完成这类"程序内部触发"的跳转。此时不能依赖<Link>,要用编程式导航。V6 提供了useNavigate这个 Hook:
import { useNavigate } from 'react-router-dom'; function LoginPage() { const navigate = useNavigate(); const handleLogin = async () => { const res = await loginApi(); if (res.code === 0) { navigate('/dashboard'); } else { alert('登录失败'); } }; return <button onClick={handleLogin}>登录</button>; }navigate函数支持多种调用形式:
navigate('/dashboard'); // 跳转 navigate('/dashboard', { replace: true }); // 跳转并替换历史记录 navigate(-1); // 后退一页 navigate(1); // 前进一页这里重点说replace: true。登录成功后跳转首页,如果不用replace,用户按浏览器后退会回到登录页,体验很怪。用replace后,登录页的这条历史记录会被目标页面替换掉,后退直接回到登录前的页面。
6.2 在非 React 组件中使用 navigate
一个常见的坑是:在 axios 拦截器、工具函数、redux 异步逻辑里想跳转页面。因为那些地方没法直接用useNavigate,因为这是只能在函数组件顶层调用的 Hook。
最常见的解决方式是把navigate实例暴露到全局:
// router/index.jsx import { createBrowserRouter } from 'react-router-dom'; // 其实更推荐用 createBrowserRouter 创建 router 实例但在使用BrowserRouter包裹的场景下,一个成熟的做法是把 navigate 存到一个导出的变量里:
// navigation.js import { createContext, useContext } from 'react'; export const NavigationContext = createContext(null); export function NavigationProvider({ children }) { const navigate = useNavigate(); return ( <NavigationContext.Provider value={navigate}> {children} </NavigationContext.Provider> ); } export function useGlobalNavigate() { return useContext(NavigationContext); }然后在入口处包一层:
<BrowserRouter> <NavigationProvider> <App /> </NavigationProvider> </BrowserRouter>这样你的全局模块只把useGlobalNavigate()当成"跳转工具"用就行。实际项目中,axios 拦截器里经常需要判断登录过期后直接跳登录页,用这个方案就能把路由感知能力带出组件树。
7. 路由守卫与权限控制:前端防线应该如何设计
7.1 一个内行的权限判断模型
很多教程把"路由守卫"说成是 React Router 内置功能,其实 V6 根本没有这个 API。它希望你通过组件嵌套和条件渲染自己组合出这套能力。
我把权限判断总结成三层模型,方便你理解:
- 应用级守卫:判断是否登录。未登录则全部跳转到登录页。
- 角色级守卫:判断当前用户角色是否有权访问某个路由模块。
- 页面级守卫:判断该角色是否可执行某个具体页面的操作。
前两层在路由层面做,第三层通常放页面组件内部(控制按钮显隐)。我们主要处理前两层。
7.2 实现一个可复用的 AuthGuard 组件
核心思路:封装一个组件,读取当前的登录状态,有权限就渲染<Outlet />,没权限就<Navigate>跳走:
// components/AuthGuard.jsx import { Navigate, Outlet, useLocation } from 'react-router-dom'; export default function AuthGuard({ allowedRoles = [] }) { const user = useAuth(); // 从全局状态或 Context 拿用户 const location = useLocation(); if (!user) { // 未登录,跳登录页并记录来源路径 return <Navigate to="/login" state={{ from: location.pathname }} replace />; } if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) { // 已登录但角色不对,跳 403 页面 return <Navigate to="/403" replace />; } return <Outlet />; }路由表组合使用:
<Route element={<AuthGuard />}> <Route path="/user" element={<UserLayout />}> <Route index element={<UserList />} /> </Route> </Route> <Route element={<AuthGuard allowedRoles={['admin']} />}> <Route path="/settings" element={<Settings />} /> </Route>注意这套方案的精妙之处:AuthGuard包裹的子路由由<Outlet />渲染,所以守卫逻辑执行完后,子路由自然继续渲染。而未被允许的页面直接通过Navigate组件重定向,URL 会发生变化,也就是"路径级拦截"。
7.3 登录后回跳的实现细节
在上面的逻辑里,我们把未登录的来源路径通过location.pathname塞进了state。登录页拿到后,登录成功就可以跳回去:
// pages/Login.jsx import { useLocation, useNavigate, Navigate } from 'react-router-dom'; function Login() { const location = useLocation(); const navigate = useNavigate(); // location.state 可能为 null,要防御 const from = location.state?.from || '/'; const handleLogin = async () => { await doLogin(); navigate(from, { replace: true }); }; return <button onClick={handleLogin}>登录</button>; }这样用户从/users/123访问被拦截跳登录,登录成功后自动回到/users/123,体验非常顺滑。
8. 懒加载与代码分割:首屏性能别再靠"全部加载"硬扛
8.1 为什么需要路由级懒加载
一个不做任何优化的 React 打包结果,会把所有页面代码打包进一个 bundle 文件。项目页面一多,首屏加载的文件体积可能就几百 KB 甚至上 MB,用户打开网页要白屏好几秒。
路由级代码分割的思路是:按路由拆包。用户访问哪个页面,才加载那个页面的 JS 模块。这样首屏只需要加载登录页和公共依赖,其他页面代码在网络请求时才动态加载。
8.2 React.lazy 与 Suspense 的标准组合
V6 里做路由懒加载非常简单,利用 React 自带的lazy和Suspense:
import { lazy, Suspense } from 'react'; import { useRoutes, Navigate } from 'react-router-dom'; // 这里 lazy 接收一个动态 import 函数 const Home = lazy(() => import('../pages/Home')); const About = lazy(() => import('../pages/About')); const UserLayout = lazy(() => import('../layouts/UserLayout')); const UserList = lazy(() => import('../pages/user/List')); const UserDetail = lazy(() => import('../pages/user/Detail')); export default function AppRouter() { return ( <Suspense fallback={<div className="page-loading">页面加载中...</div>}> <AppRoutes /> </Suspense> ); } function AppRoutes() { return useRoutes([ { path: '/', element: <Home /> }, { path: '/about', element: <About /> }, { path: '/user', element: <UserLayout />, children: [ { path: '', element: <UserList /> }, { path: ':id', element: <UserDetail /> }, ], }, { path: '*', element: <NotFound /> }, ]); }核心要点:
lazy()必须搭配Suspense,否则组件加载完成前 React 不知道如何渲染占位内容。Suspense放在useRoutes外层,因为动态 import 的组件可能在任意一层路由里触发加载。fallback是一个 React 元素,可以是加载动画、骨架屏,甚至是一行文字。
8.3 懒加载可能出现的问题和优化
刷新时的白屏闪烁。用户第一次访问某个懒加载页面时,需要额外下载 chunk 文件,如果网速慢,fallback时间会较长。这个问题的优化思路不是放弃懒加载,而是要提升fallback的感知体验:用一个全局的 loading bar 或者骨架屏,而不是空白页。
用 Suspense 包不住的错误。如果懒加载的 chunk 加载失败(比如用户断网、CDN 文件被更新下架),React 会抛错。这时候需要一个 Error Boundary 来捕获:
import { Component } from 'react'; class RouteErrorBoundary extends Component { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } render() { if (this.state.hasError) { return <div>页面加载失败,请刷新重试</div>; } return this.props.children; } }使用时把Suspense包在 Error Boundary 内部或外部均可,但一般建议外层包上错误边界,保证任何子页面抛错都不会让整个应用白屏。
9. 踩坑实录:React Router 开发里最容易被绊倒的 6 个细节
这部分是我自己在多个项目里实际踩过,又在社区里见了无数次的高频问题,列出来供你对照排查。
9.1 刷新后 404:服务端没配 fallback
这是一个被问烂的问题。用 BrowserRouter 部署到 Nginx 后,用户访问首页没问题,一刷新子路由页面就 404。
Nginx 配置加一行:
location / { try_files $uri $uri/ /index.html; }这行配置含义是:如果请求的 URI 对应文件不存在,就回退到/index.html,让前端路由接管。也就是说,服务端永远返回入口 HTML,前端再根据 URL 渲染对应组件。
如果你用的是其他静态托管服务(比如某些对象存储),看看有没有"SPA 回退"或"自定义 404 页面"之类的开关,本质是同一个思路。HashRouter 则完全没有这个问题,但相对地牺牲了 URL 美观度,取舍看项目。
9.2 location.key 在什么时候会变
location.key是 React Router 为每一次路由跳转生成的唯一标识。看起来无足轻重,但如果你要用它做"页面切换时滚动条位置恢复",或者用它作为某个组件的key来强制重置状态,就得理解它的变化时机。
当你用navigate('/a')跳到一个新路径时,生成新location.key;如果直接替换为当前路径,key 也会变。所以如果你在某处做了"key 不变就不重置组件状态"的逻辑,要时刻记得路由参数变化可能不会触发 key 变化,这时可以用useParams的结果做依赖。
9.3 路由参数变化导致组件不重新渲染
经典问题:列表详情页/users/1跳到/users/2,组件状态还是上一个用户的数据。因为 React Router 只会让同一个组件实例复用,而useParams返回的对象每次变化时确实会让组件重新渲染,但组件内useState并不会自动重置。
解决方法就是让"数据请求"跟随路由参数:
import { useParams } from 'react-router-dom'; import { useEffect } from 'react'; function UserDetail() { const { id } = useParams(); useEffect(() => { // 每次 id 变化都会重新请求 fetchUser(id); }, [id]); return <div>User {id}</div>; }简单说:数据获取的 Effect 依赖里必须包含路由参数,一切依赖路由参数的状态初始化也要放在 Effect 里处理,而不是组件顶层。
9.4 不要直接给 Route 的子组件外再套一层空的 Routes
有些老教程或旧习惯,会写:
<Routes> <Route path="/" element={<Home />} /> {isLoggedIn && ( <Route path="/dashboard" element={<Dashboard />} /> )} </Routes>这种写法在 V6 里是合法的,因为 Route 可以按条件渲染。但要注意:不要在Routes内部直接写{条件 && <Route>}之后再包一层<>...</>,空标签并不会影响合法性,但是会破坏 React Router 对其 children 的检索逻辑,可能导致组件报 "A is only ever to be used as the child of " 警告。
如果你想做条件路由,直接像上面这样写就行,不需要也不能引入多余的包裹层。
9.5 多个 Routes 并列使用时的路径冲突
同一个页面里完全可以使用多个Routes,React Router 会独立匹配每一组。但如果你不明确它们的"职责边界",很容易互相干扰。
比较推荐的做法是:全应用只保留一个顶层Routes,页面内部的局部视图切换用子路由嵌套解决,而不是再拆一组平行Routes。多组Routes只适合"绝对独立的区域"(比如弹窗内容、Tab 面板)确实需要独立路由匹配的情况。
9.6 路由切换后滚动条位置没有归零
SPA 的特点就是页面不刷新,所以切到新页面时浏览器不会自动重置滚动位置。用户跳到下一个页面时还停留在上一个页面滚动到的位置,体验非常割裂。
我的实践方案是写一个ScrollToTop组件:
import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; export default function ScrollToTop() { const { pathname } = useLocation(); useEffect(() => { window.scrollTo(0, 0); }, [pathname]); return null; }然后放在BrowserRouter内部、路由表外层:
<BrowserRouter> <ScrollToTop /> <App /> </BrowserRouter>这样每次路径变化,滚动条都会回到顶部。注意:如果你有"返回列表页时希望记住上个滚动位置"这种更高级的需求,上面的方案会导致它失效,需要配合sessionStorage做滚动位置存储,这就属于另一套方案了。
10. 从基础到工程化:一套可直接套用的进阶架构
用 React Router 做中大型项目时,只掌握 API 还不够,重要的还有路由在工程中所处的位置、收益和取舍。我在这里基于多次项目实践,给你一个可直接模仿的进阶架构。
10.1 路由配置与页面目录的结构划分
按功能域组织目录,而不是按类型堆文件。推荐结构:
src/ router/ index.jsx # 路由总表 useRoutes routes.js # 路由对象配置,可 JSON 化 AuthGuard.jsx # 权限守卫 layouts/ BasicLayout.jsx # 带菜单的布局 pages/ Login/ Dashboard/ User/ List/ Detail/ NotFound/ hooks/ useAuth.js # 拉取登录状态路由配置和 pages 一一对应,新增页面时同时改两个位置。如果团队规模较大,还可以引入"文件式路由"工具(如 Vite 插件vite-plugin-pages),约定pages目录下的文件路径自动生成路由表。这个方案能减少手写路由的数量,但要求团队严格遵守目录命名规范。
10.2 动态路由:根据后端返回配置路由表
在一些权限系统里,菜单不是前端写死的,而是后端根据用户角色返回可访问的菜单和路由配置。这时候useRoutes配合对象数组就大放异彩了。
伪代码思路:
const [remoteRoutes, setRemoteRoutes] = useState([]); useEffect(() => { fetch('/api/user/menus') .then((res) => res.json()) .then((data) => { // data: [{ path: '/report', component: 'ReportPage' }] const loaded = data.map((item) => ({ path: item.path, element: loadComponent(item.component), })); setRemoteRoutes(loaded); }); }, []); const finalRoutes = useMemo(() => [ { path: '/', element: <BasicLayout />, children: [ ...baseRoutes, // 所有用户都有的基础路由 ...remoteRoutes, // 按权限加载的路由 ]}, { path: '*', element: <NotFound /> }, ], [remoteRoutes]); return useRoutes(finalRoutes);动态路由需要注意几个坑:
- 动态加载的组件要用
lazy+ 组件名映射表,不能让用户输入直接作为组件路径拼接。 - 权限路由最好在用户信息加载完成后再渲染,避免出现闪跳。
- 动态路由变化后,React 会重新匹配所有路由,用户当前页可能因此跳到一个不存在的路径,要做好兜底。
10.3 数据预取:路由进入前加载数据
React Router 不直接提供"进入路由前执行钩子"的能力,但通过loader函数可以做到类似效果。如果你使用createBrowserRouter(V6.4+ 新增的数据路由 API),可以在路由配置里定义loader:
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; const router = createBrowserRouter([ { path: '/users/:id', loader: async ({ params }) => { const res = await fetch(`/api/users/${params.id}`); return res.json(); }, element: <UserDetail />, }, ]); function UserDetail() { const data = useLoaderData(); return <div>{data.name}</div>; }loader在路由渲染前执行,返回的数据通过useLoaderData在组件里取。这样可以减少组件挂载后的"加载中"状态,让数据请求和页面渲染天然串行。注意:使用这种写法后,BrowserRouter不再适用,要用RouterProvider。createBrowserRouter内部封装了更多数据管理的 API,适合对性能和用户体验要求更高的项目,但也引入了新的学习成本。
11. 由浅入深的最后一步:选型建议与学习路径参考
React Router 本身不难,难的是在不同项目阶段做出合适选择。根据我自己的经验,可以这么参考:
如果是课程作业或个人练手项目:直接BrowserRouter+Routes+Route手写路由表,配合useNavigate、useParams就能覆盖 80% 场景,不需要引入额外库。
如果是正式商业项目:建议直接用createBrowserRouter数据路由 API。它虽然多了一些概念(loader、action、useLoaderData),但能从一开始就把数据请求、路由懒加载、错误边界做在一起,项目规模变大后收益非常明显。
如果项目特别复杂(大型中台):可以考虑在 React Router 之上再引入一套路由状态管理方案,或者干脆用 Next.js、Remix 这种约定式路由框架。它们把文件路由、SSR、数据获取全打包了,React Router 在这类场景里往往不如框架自带路由好使。
我个人的体会是:路由层是 React 应用里最容易'想当然'但又最影响体验的一层。很多人写业务代码很溜,但一遇到"这个页面分享给别人打开是 404""刷新后退不回来了""角色一变菜单崩了"这类问题,往往会花很长时间排查,最终发现都是路由层的边界条件没处理好。
所以如果你在做 React 项目,值得花一个下午把上面这些场景全部亲手跑一遍。路由看起来只是"路径到组件的映射",但真正用顺手了,你会发现它在页面组织、权限控制、性能优化、异常兜底里无处不在。