3.2 路由管理 本节摘要:React Router 是 React 生态事实上的路由标准,用声明式组件把 URL 映射到界面。本节从 Router 组件选型讲起,覆盖 Routes/Route 定义、Link/NavLink 导航、动态参数 useParams、嵌套路由 Outlet、编程式导航 useNavigate,最后给出路由拆分的工程实践。 你能学到什么 阅读完本节,你应当能够: 说出 BrowserRouter、HashRouter、MemoryRouter 的区别与适用场景 用 Routes/Route 定义路由表,理解匹配规则 用 Link、NavLink 实现导航,处理激活态样式 用 useParams 读取动态路由参数 用嵌套路由 + Outlet 组织页面布局 用
本节摘要:React Router 是 React 生态事实上的路由标准,用声明式组件把 URL 映射到界面。本节从 Router 组件选型讲起,覆盖 Routes/Route 定义、Link/NavLink 导航、动态参数 useParams、嵌套路由 Outlet、编程式导航 useNavigate,最后给出路由拆分的工程实践。
阅读完本节,你应当能够:
SPA 的困境在第 1.1 节提过:只有一个 HTML 入口,页面切换不刷新。但用户要前进后退、要收藏链接、要直接打开某个深层页面。这些都需要「URL 能代表界面状态」。
React Router 就是干这个的:把 URL 路径映射到组件,URL 变了,界面跟着变。 它是社区维护的第三方库,并非 React 官方提供——但已经事实成为标准。SOURCE 原文强调它「声明式」定义路由:路由规则本身就是 JSX,可读、可维护。
React Router 的数据流:用户点击 Link → URL 变化 → Router 匹配路由表 → 渲染对应组件。
React Router 的 Web 版本包名是 react-router-dom。安装后在应用入口用 Router 包裹整棵应用树:
import { BrowserRouter } from 'react-router-dom'; // 入口处: <BrowserRouter> <App /> </BrowserRouter>
Router 必须在任何使用路由 API 的组件之上。App 内部的所有组件都能用 Link、useNavigate、useParams 这些能力——这正是「路由状态全局可用」的体现。习惯上把 Router 放在入口文件的最外层,让路由配置、组件树、路由状态三者界限清晰。
有一点要注意:Router 组件本身不产生可见界面,它只是建立「URL 与界面同步」的上下文。真正渲染什么,由内部的路由表和当前 URL 共同决定。所以 Router 放多外层都不影响视觉,只影响「谁能在组件树里用到路由能力」。
Router 是路由的根组件,有三种:
| Router | URL 形态 | 服务器要求 | 适用 |
|---|---|---|---|
| BrowserRouter | 无 #,干净 | 需回退配置 | 生产标准 |
| HashRouter | 带 # | 无要求 | 静态托管 |
| MemoryRouter | 不显示 | 无要求 | 测试/原生 |
Routes 包裹一组 Route,取第一个匹配当前 URL 的路由渲染:
import { Routes, Route } from 'react-router-dom'; function App() { return ( <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> ); }
Route 的 path 指定 URL 模式,element 指定渲染的组件。Routes 保证「只有一个匹配被渲染」——多个路由匹配时,取第一个。
理解 path 匹配是路由调试的关键。几条重要规则:
/about 不匹配 /about/team。/* 匹配任意子路径。/dashboard/* 匹配 /dashboard 及其下所有路径。:参数名 占位。/user/:id 匹配 /user/42,但只匹配一层,不跨 /。这些规则组合起来能表达绝大多数 URL 结构。遇到「路由匹配不上」的问题,先核对:是不是子路径没加 /*,是不是动态参数层数对不上。
Link 渲染成 a 标签,点击更新 URL 不刷新页面。NavLink 是 Link 的增强,激活时自动加类:
import { NavLink } from 'react-router-dom'; function Navigation() { return ( <nav> <ul> <li> <NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''} > 首页 </NavLink> </li> <li> <NavLink to="/about">关于</NavLink> </li> </ul> </nav> ); }
isActive 是 NavLink 传入渲染函数的当前激活状态,用它做高亮样式。这是导航栏最常见的样子。
URL 里的一部分是变量,比如商品详情页 /products/123 的 123。路由定义:
<Route path="/products/:id" element={<ProductDetail />} />
组件里用 useParams 读取:
import { useParams } from 'react-router-dom'; function ProductDetail() { const { id } = useParams(); return ( <div> <h1>商品详情</h1> <p>商品 ID:{id}</p> </div> ); }
:id 是参数占位符,useParams 返回 { id: '123' }。动态参数是列表页到详情页导航的标配。
把动态参数串成一个完整的场景:列表页点击某项,跳到详情页,详情页按参数请求数据。
import { Link, useParams } from 'react-router-dom'; function ProductList({ products }) { return ( <ul> {products.map(p => ( <li key={p.id}> <Link to={`/products/${p.id}`}>{p.name}</Link> </li> ))} </ul> ); } function ProductDetail() { const { id } = useParams(); // 这里可以用 id 请求详情数据 return <h1>正在查看商品 {id}</h1>; }
注意 Link 的 to 用模板字符串拼出 /products/${id}——这是动态导航的标准写法。整个流程是「列表页携带 id 导航 → URL 记录 id → 详情页从 URL 拿 id」。id 不在组件 state 里,而在 URL 里,所以刷新后仍能定位到同一个商品,这也是 URL 状态优于组件状态的原因。
很多页面有公共布局:顶部导航 + 侧边栏 + 内容区。嵌套路由把「布局组件」和「子页面」分开:
import { Routes, Route, Link, Outlet } from 'react-router-dom'; function Products() { return ( <div> <h1>产品列表</h1> <ul> <li><Link to="/products/1">产品 1</Link></li> <li><Link to="/products/2">产品 2</Link></li> </ul> <Outlet /> {/* 子路由在这里渲染 */} </div> ); } function ProductDetail() { const { id } = useParams(); return <p>产品 {id} 的详情</p>; } function App() { return ( <Routes> <Route path="/" element={<Home />} /> <Route path="/products" element={<Products />}> <Route path=":id" element={<ProductDetail />} /> </Route> </Routes> ); }
父路由的 element(Products)渲染公共部分,子路由的 element(ProductDetail)渲染进父组件的 Outlet 位置。布局只写一次,子页面各管各的。
💡 关键直觉:嵌套路由是「布局复用」的声明式表达。别把每个页面都写成独立顶层路由——有公共导航/侧边栏/页脚的页面群,用嵌套路由把布局提出来,改动一处全站生效。
URL 的问号参数(如 /products?sort=price&page=2)在列表筛选、分页场景里很常用。用 useSearchParams 读取和更新:
import { useSearchParams } from 'react-router-dom'; function ProductList() { const [searchParams, setSearchParams] = useSearchParams(); const sort = searchParams.get('sort') ?? 'default'; const page = Number(searchParams.get('page')) || 1; const changeSort = (newSort) => { setSearchParams({ sort: newSort, page: '1' }); }; return ( <div> <p>当前排序:{sort},第 {page} 页</p> <button onClick={() => changeSort('price')}>按价格排序</button> </div> ); }
查询参数的价值在于可分享、可刷新:用户把带筛选条件的 URL 发给别人,对方打开看到的是同样的结果。这正是上一节讲的「URL 是应用状态持久化层」的具体应用。
表单提交成功、登录成功后要跳转,不能靠用户点击 Link。用 useNavigate:
import { useNavigate } from 'react-router-dom'; function LoginPage() { const navigate = useNavigate(); const handleSubmit = async (e) => { e.preventDefault(); // 登录逻辑... navigate('/dashboard'); // 跳转 // navigate('/dashboard', { replace: true }); // 替换历史记录 }; return <form onSubmit={handleSubmit}>{/* 表单 */}</form>; }
navigate 函数的第二个参数可传选项:replace 替换当前历史记录(返回不会回到本页),state 传递状态数据到目标页。
需要把用户强制带到另一个路由(比如登录页访问受保护页时跳回登录页),用 Navigate 组件:
import { Navigate } from 'react-router-dom'; function RequireAuth({ user, children }) { if (!user) { return <Navigate to="/login" replace />; } return children; }
navigate 还可以传入相对数字:navigate(-1) 返回上一页,navigate(1) 前进一页。这在「详情页返回列表页并保持滚动位置」这类需求里有用,但要注意历史栈可能为空——生产环境里加个兜底判断更稳。
路由表别堆成一个巨型组件。按模块拆分:每个功能模块导出自己的路由片段,根组件聚合。这也为代码分割提供挂钩——懒加载的路由组件只在访问时加载,第 3.1 节的 lazy 配合路由是减小首屏的主流方案。
import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); function App() { return ( <Suspense fallback={<div>页面加载中...</div>}> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> </Routes> </Suspense> ); }
每个页面组件单独打包,用户访问 /about 时才下载 About 的代码块。首页体积小了,首屏快了,这是路由与代码分割组合的标准姿势。
用户访问一个不存在的路径,渲染一个 404 页面:
<Route path="*" element={<NotFound />} />
path 为 * 匹配所有未匹配路径。别让用户撞进空白页——兜底路由是基本礼貌。
受保护页面(个人中心、后台)需要登录才能访问。标准套路是用包装组件判断登录态,未登录重定向到登录页,登录后放行——上一节的 RequireAuth 就是雏形。再配合路由级 lazy,未登录用户根本不会下载受保护页面的代码,兼顾安全与性能。
⚠️ 常见坑:在组件内直接改 window.location。SPA 需要 React Router 接管导航来保持状态与渲染同步,直接改地址栏会导致整页刷新、状态丢失。所有导航都走 Link、NavLink 或 useNavigate。
路由变化本身也是状态变化——当前 URL 就是一组全局状态。所以路由能驱动界面:URL 里的参数可以作为数据源,页面刷新后从 URL 恢复状态。这带来一个习惯:需要「可分享、可刷新」的界面状态,放进 URL 参数;不需要的,留在组件 state。 比如筛选条件放进 URL 方便分享,临时展开状态留在 state。这条分界线让路由从「导航工具」升级为「应用状态的持久化层」。
切换路由时组件会卸载、新组件挂载。这个「路由生命周期」和状态管理直接相关:如果每个页面都要在进入时请求数据,惯用写法是 useEffect 里依赖路由参数:
function ProductDetail() { const { id } = useParams(); const [product, setProduct] = useState(null); useEffect(() => { // 路由参数变化时重新请求 fetchProduct(id).then(setProduct); }, [id]); // id 变了,重新拉数据 return <div>{product ? product.name : '加载中...'}</div>; }
从 /products/1 切到 /products/2 时,组件并没有卸载重挂——它复用了,只是参数变了。依赖 [id] 的 effect 检测到变化,重新请求。这个「参数驱动数据」的模式是详情页的标准写法,比每次导航都强制重挂组件优雅得多。
遇到路由不工作的灵异现象,按顺序排查:
| 症状 | 优先检查 |
|---|---|
| 路由不渲染 | Router 是否在最外层包裹 |
| 刷新 404 | 服务器没配 BrowserRouter 回退 |
| 路径匹配错 | 子路径是否加 /*,参数层数对不对 |
| 激活态不生效 | NavLink 的 className 函数是否用了 isActive |
| 跳转后界面没变 | 是否直接改了 location 而不是用导航 API |
「BrowserRouter 刷新就 404,怎么解决?」 这是 SPA 部署最常见的问题。BrowserRouter 用 History API,URL 是「干净路径」,刷新时浏览器会向服务器请求这个真实路径——服务器没有这个资源,就返回 404。解法是让服务器「把所有路径都回退到入口页」:Nginx 配置 try_files、Node 服务器做个兜底路由、托管平台(Vercel/Netlify)有现成的 rewrite 配置。如果你用静态托管又不想配服务器,可以换 HashRouter——URL 带 # 号,刷新时请求的永远是根路径,服务器零配置。代价是 URL 不美观、SEO 差。理解「服务器要把路径交还给前端」这个原理,不管用哪个托管平台都能举一反三。
「useParams 拿到的参数一定是字符串吗?」 是,URL 里的参数天生是字符串。/products/123 的 id 是 '123',不是数字 123。要当数字用,得手动转换——Number(id) 或用工具转换。这是个隐蔽的小坑:拿字符串 id 去查对象数组,'123' !== 123 会查不到;拿字符串做数字比较,'9' > '10' 的字符串比较结果是 true(字典序),逻辑完全错。转换时还要处理 NaN——URL 可能是 /products/abc,Number 之后是 NaN,查询和渲染都会出错。工程上「路由参数统一当字符串处理,用到类型转换时显式转换并兜底」是最稳妥的。
「嵌套路由的 Outlet 每次切换都重新挂载子组件吗?」 分情况。同一个父路由下切换不同的子路由,子组件会按需挂载/卸载——这是正常的。但「同一位置复用组件实例」的场景要留意:从 /products/1 切到 /products/2,如果这两个路径映射的是同一个 ProductDetail 组件(只是参数不同),React Router 默认会复用组件实例、只改参数——所以组件里的 useEffect 依赖数组要写上 [id],参数变了才能触发重新取数。这是个高频 bug:忘写依赖,切到 2 号商品还显示 1 号的数据。
「路由守卫和鉴权怎么做最干净?」 最干净的做法是「包装组件」:写一个 RequireAuth 组件,内部读登录态,未登录就 Navigate 重定向到登录页,已登录就渲染 children。然后把受保护路由的 element 包上它。这个模式的好处是「鉴权逻辑集中在路由声明层」,每个受保护路由的权限一目了然。权限细分的项目还可以在 RequireAuth 里加角色判断——比在页面组件内部到处判断登录态干净得多。第 2.5 节的 Context 在这里配合:登录态放 Context,RequireAuth 用 useContext 读取,登录/退出时全站路由自动响应。
React Router 目前主流是第 6 版(以及过渡到第 7 版的框架形态)。第 5 版及更早的写法(Route 的 component/render 属性、Switch 组件、useHistory 钩子)在存量代码里依然常见,读旧项目时要能识别。
两个关键的版本差异值得记住:第 6 版用 Routes 取代 Switch,路由匹配从「包容」变为「排他」——Routes 只渲染第一个匹配,Switch 渲染第一个匹配但在嵌套语义上不同;第 6 版用 useNavigate 取代 useHistory 的 push。如果你在维护旧项目,升级到第 6 版的迁移主要是这两处机械替换加路由结构重排。
新项目直接用当前稳定版即可,这些历史版本知识留作读旧代码的备胎。
下一节做状态管理选型——路由管「走到哪」,状态管「数据放哪」。Context、Zustand、Redux 三套方案各有什么代价,什么时候该升级,这是中大型应用的必经决策。别被「XX 必学」的声音带偏,选型应该从你的状态复杂度出发,而不是从某个库的热度出发。