各位好,欢迎来到今天的“前端架构师的秘密花园”讲座。
我是你们的向导,今天我们要聊的东西,听起来可能有点枯燥——路由。但在 React 的世界里,路由不仅仅是“跳转”,它是整个单页应用(SPA)的心跳,是 URL 与 UI 之间的翻译官,更是连接用户意图与屏幕渲染的桥梁。
特别是当我们谈到嵌套路由和动态参数时,这简直就是一场精心编排的俄罗斯套娃游戏。如果玩不好,你的应用就会变成一团乱麻;如果玩得溜,你的应用就会像瑞士钟表一样精密。
废话不多说,让我们直接进入正题,看看 React Router 到底是如何在这个单页架构中施展魔法的。
第一章:为什么我们需要 React Router?(SPA 的哲学)
首先,我们要搞清楚一个基本概念:传统的网页(多页应用,MPA)和现代的 React 应用(单页应用,SPA)的区别。
在传统的网页里,当你点击一个链接,浏览器会向服务器发一个请求,服务器给你发一个新的 HTML 文件,然后浏览器刷新。就像你坐火车,每到一个站都要重新上车下车。
但在 SPA 里,一切都在一个 HTML 文件里完成。当你点击链接时,浏览器不会刷新。那么,页面上的内容怎么变呢?这就需要路由。
React Router 是 React 生态里最著名的库,它的作用就是告诉 React:“嘿,如果用户访问的是 /home,你就给我渲染 Home 组件;如果是 /about,你就渲染 About 组件。”
它就像是 GPS 导航系统。你输入目的地(URL),它就决定你该看哪张地图(UI)。
第二章:从 <a> 标签到 <Link> 标签的进化
很多新手,尤其是从 jQuery 或者原生 JS 转过来的同学,会犯一个经典错误:
import React from 'react';
const MyComponent = () => {
return (
<div>
<h1>欢迎来到我的页面</h1>
{/* 危险!这是错误的写法! */}
<a href="/about">去关于我们</a>
</div>
);
};
为什么说这是错的?因为 <a> 标签默认行为是导航到新页面。这意味着浏览器会重新加载整个页面,你的 React 应用会重新初始化,所有在内存里的状态(比如购物车里的商品、输入框里的文字)都会瞬间消失。
这就像你在玩一个 RPG 游戏,突然游戏弹出了“是否重新开始”的窗口,你还没存盘呢!
React Router 提供了 <Link> 组件来解决这个问题。<Link> 组件看起来像 <a>,但它不会触发页面刷新,它只是改变 URL,然后触发 React Router 的内部逻辑来重新渲染对应的组件。
import React from 'react';
import { Link } from 'react-router-dom'; // 假设我们使用 v6/v7
const MyComponent = () => {
return (
<div>
<h1>欢迎来到我的页面</h1>
{/* 正确的做法 */}
<Link to="/about">去关于我们</Link>
</div>
);
};
专家提示: 在现代 React Router 中,Link 组件默认会渲染为带有 href 属性的 <a> 标签,这是为了 SEO 和无障碍访问,但它的点击行为已经被拦截了。
第三章:路由的骨架——<Routes> 与 <Route>
有了 URL 想要改变 UI,光有 <Link> 还不够,我们得告诉 React Router:“到底谁匹配谁”。
这就是 <Routes> 和 <Route> 的用武之地。它们就像是一个严苛的安检员,手里拿着一份名单(路由配置),看着每一个通过 Link 进来的 URL,决定放谁过去。
1. 基础路由定义
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
const Home = () => <h1>我是首页</h1>;
const About = () => <h1>我是关于页</h1>;
const Contact = () => <h1>我是联系页</h1>;
const App = () => {
return (
<div>
<nav>
<Link to="/">首页</Link> |
<Link to="/about">关于</Link> |
<Link to="/contact">联系</Link>
</nav>
{/* 路由容器 */}
<Routes>
{/* 这里的 path 就是 URL 路径 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</div>
);
};
注意: 在 React Router v6 之前,我们使用的是 Switch,现在统一改成了 Routes。而且,element 属性直接接受 JSX,不需要像以前那样用 component={Home},这更符合 React 的直觉。
2. 匹配逻辑:精确匹配与模糊匹配
这里有一个非常重要的细节。<Route> 默认是模糊匹配。
如果你定义了 <Route path="/about" element={<About />} />,那么 /about、/about-us、/about/team 都会匹配到这个路由。React Router 会一直往深处找,直到找到第一个匹配的组件。
如果你想要精确匹配(只有 /about 匹配,/about/anything 不匹配),你需要加一个 * 符号:
<Route path="/about*" element={<About />} />
或者,如果你想要匹配 /about 结尾的路径:
<Route path="/about" end element={<About />} /> {/* v6.4+ 新特性 */}
第四章:动态路由参数——URL 里的变量
现实世界从来不是静态的。你不能只有一个“用户列表页”,你需要有“用户详情页”。你不能只有一个“文章列表”,你需要有“文章详情页”。
这时候,动态路由参数 就登场了。
1. 定义动态参数
在路由的 path 中,使用冒号 : 来表示一个变量。
比如,我们要获取用户 ID。URL 可能是 /users/123,也可能是 /users/456。123 和 456 是动态变化的。
// 定义一个获取用户详情的路由
<Route path="/users/:userId" element={<UserDetail />} />
2. 获取参数值
组件内部怎么拿到这个 userId 呢?我们需要一个钩子函数:useParams。
import React from 'react';
import { useParams } from 'react-router-dom';
const UserDetail = () => {
// 这个 hook 会返回一个对象,里面包含所有匹配到的动态参数
const { userId } = useParams();
return (
<div>
<h1>用户详情页</h1>
<p>你正在查看用户 ID: {userId}</p>
{/* 假设我们要调用 API 获取数据 */}
<button onClick={() => console.log(`正在获取 ID 为 ${userId} 的用户数据...`)}>
获取数据
</button>
</div>
);
};
深度解析: useParams 返回的对象是响应式的。如果你在 URL 中修改了参数(比如从 /users/123 改成 /users/456),这个组件会重新渲染,userId 也会随之更新。这比监听 window.location.hash 要优雅得多。
3. 动态参数的进阶应用:文章 Slug
除了数字 ID,更多时候我们用的是“斜杠型”动态参数,也就是所谓的 Slug。比如博客文章的 URL:/blog/how-to-react。
<Route path="/blog/:slug" element={<BlogPost />} />
// 组件内部
const BlogPost = () => {
const { slug } = useParams();
// 这里的 slug 可能是 "how-to-react", "react-hooks-guide" 等
return <h1>文章标题:{slug}</h1>;
};
第五章:嵌套路由——俄罗斯套娃的艺术
这是今天讲座的重头戏。也是很多开发者觉得 React Router 难懂的地方。
想象一下,一个典型的网站结构:
- 首页
- 关于我们
- 团队介绍
- 职位招聘
- 历史沿革
- 产品
- 产品列表
- 产品详情
在 React Router 中,路由是可以嵌套的。父路由可以包含子路由。这就像俄罗斯套娃,或者像俄罗斯套娃里的俄罗斯套娃。
1. <Outlet> 组件:子组件的容器
当一个路由匹配时,它可能会渲染一个布局组件(比如侧边栏、顶部导航),同时,它还需要在某个位置渲染子路由的内容。
这个“位置”就是由 <Outlet> 组件提供的。
// 父组件
const AboutLayout = () => {
return (
<div style={{ border: '1px solid red' }}>
<h2>关于我们(布局容器)</h2>
{/* 这里是子路由渲染的地方 */}
<Outlet />
</div>
);
};
// 子组件
const Team = () => <div>这是团队介绍</div>;
const Careers = () => <div>这是职位招聘</div>;
2. 嵌套路由的配置
在路由定义上,我们需要使用嵌套的 <Route> 标签。
<Routes>
{/* 根路径 */}
<Route path="/" element={<Home />} />
{/* 关于页面(父路由) */}
<Route path="/about" element={<AboutLayout />}>
{/* 子路由:团队介绍 */}
<Route path="team" element={<Team />} />
{/* 子路由:招聘 */}
<Route path="careers" element={<Careers />} />
</Route>
{/* 产品页面(父路由) */}
<Route path="/products" element={<ProductLayout />}>
<Route path="list" element={<ProductList />} />
<Route path="detail" element={<ProductDetail />} />
</Route>
</Routes>
逻辑解析:
- 当 URL 是
/about时,<AboutLayout />会被渲染。 - 因为
AboutLayout里有一个<Outlet />,React Router 会继续检查当前路径是否有子路由匹配。 about下没有子路径匹配,所以<Outlet />为空。- 当 URL 是
/about/team时,<AboutLayout />会被渲染。 - React Router 发现
about匹配了,并且team匹配了子路由。 AboutLayout渲染,<Outlet />处渲染<Team />。
3. 路径的拼接
在嵌套路由中,子路由的 path 可以省略前面的斜杠。
<Route path="/about" element={<AboutLayout />}>
{/* 注意这里没有 /team,它是相对于 /about 的 */}
<Route path="team" element={<Team />} />
</Route>
最终渲染的 URL 会是 /about/team。
第六章:实战演练——构建一个“Zai 电商帝国”
为了让大家彻底搞懂,我们来手搓一个电商网站的路由结构。
场景:
- 首页
- 商品列表页(带分类筛选)
- 商品详情页(动态参数:
/product/:id) - 购物车页面
- 购物车内的结算页面(嵌套路由:
/cart/checkout)
代码实现:
import React from 'react';
import { Routes, Route, Link, Outlet, useParams, useNavigate } from 'react-router-dom';
// --- 模拟组件 ---
const Navbar = () => (
<nav style={{ background: '#333', color: '#fff', padding: '10px' }}>
<Link to="/">首页</Link> |
<Link to="/products">商品列表</Link> |
<Link to="/cart">购物车 ({Math.floor(Math.random() * 10)})</Link>
</nav>
);
const Home = () => <div style={{ padding: '20px' }}>欢迎来到 Zai 电商!</div>;
const ProductList = () => {
const products = [
{ id: 1, name: '超级键盘' },
{ id: 2, name: '机械鼠标' },
{ id: 3, name: '4K 显示器' },
];
return (
<div style={{ padding: '20px' }}>
<h2>所有商品</h2>
<ul>
{products.map(p => (
<li key={p.id} style={{ marginBottom: '10px' }}>
{/* 动态链接:点击跳转到详情页,并传递 ID */}
<Link to={`/products/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</div>
);
};
const ProductDetail = () => {
const { id } = useParams();
// 模拟根据 ID 获取数据
const product = { id, name: '商品详情 #' + id, price: 999 };
return (
<div style={{ padding: '20px' }}>
<h2>{product.name}</h2>
<p>价格: ¥{product.price}</p>
<button onClick={() => alert('加入购物车成功!')}>加入购物车</button>
</div>
);
};
// --- 布局组件 ---
const CartLayout = () => {
return (
<div style={{ padding: '20px' }}>
<h2>我的购物车</h2>
{/* 这里是子路由的渲染位置 */}
<Outlet />
</div>
);
};
const CartItems = () => <div>这里显示购物车里的商品...</div>;
const Checkout = () => {
return (
<div style={{ border: '1px dashed blue', padding: '20px', marginTop: '20px' }}>
<h3>结算页面</h3>
<p>正在处理支付...</p>
</div>
);
};
// --- 主应用组件 ---
const App = () => {
return (
<div>
<Navbar />
<Routes>
<Route path="/" element={<Home />} />
{/* 商品路由 */}
<Route path="/products" element={<ProductList />} />
<Route path="/products/:id" element={<ProductDetail />} />
{/* 购物车路由(嵌套结构) */}
<Route path="/cart" element={<CartLayout />}>
<Route index element={<CartItems />} /> {/* 默认子路由,匹配 /cart */}
<Route path="checkout" element={<Checkout />} />
</Route>
</Routes>
</div>
);
};
export default App;
这段代码的魔力在于:
当你点击“商品列表”里的“超级键盘”时,URL 变成了 /products/1。React Router 检测到这匹配了 <Route path="/products/:id" ...>。
但是,/products/1 也匹配了父路由 <Route path="/products" ...> 吗?不匹配,因为 :id 是动态的,它不是 /products。
所以,只有详情页组件被渲染。
当你点击购物车里的“去结算”时,URL 变成了 /cart/checkout。父路由 /cart 匹配,子路由 checkout 匹配。于是,CartLayout(布局)渲染了,<Outlet /> 处渲染了 Checkout(结算页)。
第七章:编程式导航——用代码控制 URL
有时候,你不能用 <Link>。比如,用户点击了一个按钮,或者是表单提交成功后,你需要跳转到另一个页面。这时候,我们需要使用 useNavigate 钩子。
import { useNavigate } from 'react-router-dom';
const LoginForm = () => {
const navigate = useNavigate();
const handleSubmit = (e) => {
e.preventDefault();
// 模拟登录成功
console.log('登录成功');
// 编程式导航:跳转到首页
navigate('/');
// 或者带参数跳转
// navigate('/profile/123');
};
return (
<form onSubmit={handleSubmit}>
<button type="submit">登录</button>
</form>
);
};
navigate 函数就像一个指挥官,它可以执行多种操作:
navigate('/'):跳转到首页。navigate(-1):后退一步(类似浏览器的后退按钮)。navigate(1):前进一步。navigate('/about', { replace: true }):跳转并替换当前历史记录。这意味着用户按“后退”键时,不会回到刚才登录前的那个页面,而是直接回到登录前两页(防止用户误操作)。
第八章:404 处理与通配符路由
如果用户输入了一个不存在的 URL,比如 /unknown-page,会发生什么?
React Router 默认会什么都不渲染。页面会留白。这对于用户体验来说,简直是一场灾难。就像你走进一家店,结果发现门是关着的,而且里面黑洞洞的。
我们需要定义一个“兜底”路由。
在 React Router v6 中,使用 * 来表示通配符路由。
import { NotFound } from './NotFound';
<Routes>
{/* ... 其他路由 ... */}
{/* 这是一个兜底路由,放在最后 */}
<Route path="*" element={<NotFound />} />
</Routes>
逻辑:
<Route path="*"> 会匹配任何路径。因为它是最后定义的,React Router 会从上往下匹配,一旦发现前面的路由都不匹配,就会把这个通配符路由拿出来渲染。
第九章:性能优化——懒加载
随着路由越来越多,整个应用打包成一个巨大的 bundle.js 文件,首屏加载会非常慢。
为了解决这个问题,我们可以使用 React Router 的懒加载功能。这就像你开了个图书馆,只有当有人走进“文学区”时,你才去搬那一排书出来,而不是一开始就把整个图书馆的书都搬出来堆在门口。
使用 React.lazy 和 Suspense:
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
// 懒加载组件
const About = lazy(() => import('./About'));
const Contact = lazy(() => import('./Contact'));
const App = () => {
return (
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
);
};
这样,About 和 Contact 的代码会被分离成单独的文件,只有当你点击进入这些页面时,浏览器才会去请求这些文件。这极大地提升了首屏加载速度。
第十章:总结与进阶思考
好了,各位同学,今天我们聊了很多。
我们从最基础的 <Link> 和 <Routes> 开始,一步步深入到了动态参数 :id 的解析,最后在嵌套路由的迷宫里找到了出口。
React Router 的核心思想就是声明式。你不需要手动去 window.location.href = ...,你只需要声明:“如果 URL 是这样,我就渲染那个组件。”
嵌套路由不仅仅是结构上的嵌套,它是组件树与URL 树的完美映射。父组件控制布局,子组件控制内容。
专家的进阶建议:
- 理解
<Outlet>的优先级:<Outlet>是一个容器,它渲染的是当前匹配的子路由组件。如果你想在一个路由里渲染多个<Outlet>(比如侧边栏一个,主内容一个,底部一个),你需要使用命名 Outlet (<Outlet name="sidebar" />)。 - 路由守卫: 虽然这不在今天的代码演示里,但这是实战中最重要的部分。利用嵌套路由,你可以轻易实现“未登录用户不能访问 /profile”的逻辑。只要在
/profile的父级路由里加一个判断,如果不满足条件,就重定向到/login。 - Location State: 有时候你想跳转页面,但不想把参数写在 URL 里(为了美观或者安全性)。你可以使用
navigate('/detail', { state: { from: '/list' } })。在目标页面,使用useLocation().state就能拿到这个数据。
路由是前端开发的基石。当你能像呼吸一样自然地使用嵌套路由和动态参数时,你就真正掌握了单页应用的脉搏。
希望今天的讲座能让你对 React Router 有更深的理解。不要害怕复杂的 URL 结构,那是通往强大架构的阶梯。下课!
(注:代码示例基于 React Router v6/v7 标准,老版本 v5 的语法略有不同,但核心思想依然通用。)