React Router 嵌套路由:一个 Outlet 引发的布局思考
目录
- 开篇:意料之外的标题
- 核心概念:嵌套路由的心智模型
- [图解:URL 到组件的映射过程](#图解:URL 到组件的映射过程 "#%E5%9B%BE%E8%A7%A3url-%E5%88%B0%E7%BB%84%E4%BB%B6%E7%9A%84%E6%98%A0%E5%B0%84%E8%BF%87%E7%A8%8B")
- 逐层拆解:从路由配置到页面渲染
- 布局决策:什么放父路由,什么放子路由
- 配套能力:让路由"活"起来
- 结尾:记住一个原则
开篇:意料之外的标题
你在做一个电商后台。产品模块有两个页面:产品详情 和 新增产品。你自然地写了这样的路由:
jsx
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
Products 组件里放了个标题:
jsx
const Products = () => (
<>
<h1>产品列表</h1>
<Outlet />
</>
)
看起来没毛病。结果一跑------
/products/123显示:产品列表 + 产品详情 ✅/products/new显示:产品列表 + 新增产品 ❌
新增产品页也顶着"产品列表"四个大字。你只想在 NewProduct 里加个表单,怎么多了个标题?
这就是嵌套路由里最经典的问题:父路由的 UI 会"遗传"给所有子路由。 这篇文章就是解决这个问题的。
核心概念:嵌套路由的心智模型
<Outlet />是 React Router 在父组件里开的一个"插槽",当前匹配到的子路由组件会自动渲染到这个位置。
先看一个生活中完全一样的场景:
你有一个展厅 (父路由 /products),墙上挂着一个电子屏 (<Outlet />)。展厅本身有一块固定的招牌写着"产品列表"。
- 访客来看"产品 A 详情" → 电子屏播放产品 A 的信息。招牌还在。
- 访客来"新增产品" → 电子屏显示新增表单。招牌也还在------因为招牌是展厅的,不是电子屏的。
问题就出在这:招牌应该属于展厅吗? 如果不是------它属于"产品详情"这个展区------就不该挂在展厅的墙上。
翻译回 React 的世界:
| 展厅(父路由) | 电子屏(Outlet) | 招牌(UI 归属) |
|---|---|---|
| 放所有子页面都需要的东西 | 放当前子路由的内容 | 谁需要,放谁那 |
图解:URL 到组件的映射过程
当你访问 /products/123 时,React Router 的匹配过程是这样的:
当你访问 /products/new 时:
两张图一对比,问题一目了然:父路由的 h1 对两个子路由"一视同仁",它不知道哪个孩子需要它、哪个不需要。
逐层拆解:从路由配置到页面渲染
第一层:路由配置
jsx
// App.jsx --- 路由就像一棵树
<HashRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
{/* 🔑 嵌套路由:products 是父,里面两个是子 */}
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</HashRouter>
注意子路由的 path 是相对路径 (":productId" 而不是 "/products/:productId"),因为它们是写在父 <Route> 标签内部的,React Router 会自动拼接完整路径。
第二层:父组件(布局容器)
jsx
// pages/products/index.jsx
import { Outlet } from 'react-router-dom'
const Products = () => <Outlet />
// 🔑 什么都不放,只做路由容器。公共 UI 为 0,所有子页面自由发挥。
这是布局组件最极简的形态------纯容器,零侵入。子路由完全独立,互不影响。
如果未来产品模块需要一个侧边栏,而且每个子页面都需要 ,那就把它加回 Products 里:
jsx
const Products = () => (
<div className="products-layout">
<Sidebar /> {/* 所有产品子页面都有 */}
<Outlet /> {/* 子页面内容换这里 */}
</div>
)
判断标准 :加进去之后问自己------"这个 UI 出现在 /products/new 上,合理吗?" 不合理就往下挪。
第三层:子组件
jsx
// pages/products/Detail/index.jsx --- 产品详情页需要"产品列表"标题
const ProductDetail = () => {
const { productId } = useParams()
return (
<>
<h1>产品列表</h1>
<h3>产品详情-{productId}</h3>
</>
)
}
jsx
// pages/New/index.jsx --- 新增页不需要
const NewProduct = () => <h1>NewProduct</h1>
区别在于:"产品列表"标题放在了真正需要它的组件里,而不是父路由里。
布局决策:什么放父路由,什么放子路由
这是每个 React 项目都会遇到的决策。一张表讲清楚:
| 场景 | 放父路由(Layout) | 放子路由(Page) |
|---|---|---|
| 导航栏/面包屑 | ✅ 所有子页面都需要 | ❌ 只有部分页面要 |
| 页面标题 | ✅ 所有页面同一个标题 | ✅ 标题因页面而异 |
| 侧边栏 | ✅ 整个模块统一 | ❌ 不同页面不同侧边栏 |
| 表单内容 | ❌ 和父路由不相干 | ✅ 页面自己的事 |
| 数据请求 | ❌ 每个页面数据不同 | ✅ useParams 获取 id 再请求 |
核心原则:
父路由放"框架",子路由放"内容"。如果一段 UI 不是所有子路由都需要的,它就不该在父路由里。
实践方法:先全放子路由,发现有重复了再往上提。 这和 React 的 state 提升是同一个思路------只不过提升的是 UI 而不是状态。
配套能力:让路由"活"起来
嵌套路由是这个 demo 的主干,但还有几个同样重要的枝叶能力,它们让路由变得"能用"。
路由懒加载:不访问不下载
jsx
// App.jsx
import { lazy, Suspense } from 'react'
const Home = lazy(() => import('./pages/Home'))
const ProductDetail = lazy(() => import('./pages/products/Detail'))
// 所有 Route 包裹在 Suspense 里
<Suspense fallback={<div>Loading...</div>}>
<Routes>{/* ... */}</Routes>
</Suspense>
lazy()让每个页面组件按需下载。用户访问首页时,About、Products 等页面的代码压根没加载。对于有几十个页面的中大型应用,这是首屏速度的关键优化。
动态路由参数:拿到 URL 里的变量
jsx
// /user/123 → id = "123"
const { id } = useParams()
console.log(id) // "123"
配合嵌套路由使用时,useParams 能拿到当前匹配层级 的参数。如果 /products/:productId 下面还有子路由,子组件同样可以用 useParams 拿到 productId。
编程式导航:不用 Link 也能跳转
jsx
// 404 页面 3 秒后自动跳回首页
const navigate = useNavigate()
useEffect(() => {
setTimeout(() => navigate('/'), 3000)
}, [])
404 兜底:path="*" 放在最后
jsx
<Route path="*" element={<NotFound />} />
⚠️ 这个路由必须放在
<Routes>的最后面,因为 React Router 是从上到下 匹配的,*放在前面会把所有请求都吃掉。
结尾:记住一个原则
回到开篇的问题:"产品列表"标题出现在新增页,因为把它放在了父路由里,而父路由的 UI 会无差别渲染给所有子路由。
修起来只有一步:把标题从 <Products /> 挪到 <ProductDetail />。但真正重要的是背后的判断力。
父路由放框架,子路由放内容。不确定的,先放子路由------重复了再往上提。
下次你写嵌套路由时,每加一段 UI 到父组件,就问自己一句话:"这段 UI 出现在每一个子页面,都合理吗?"
问完这句,大部分布局错误就不会发生了。
最后一个思考题留给你:如果你的产品模块有 5 个子页面,其中 3 个需要同一个筛选栏,另外 2 个不需要------你会把筛选栏放在哪? 欢迎在评论区聊聊你的方案。