React Router 嵌套路由:一个 `Outlet` 引发的布局思考

React Router 嵌套路由:一个 Outlet 引发的布局思考

目录


开篇:意料之外的标题

你在做一个电商后台。产品模块有两个页面:产品详情新增产品。你自然地写了这样的路由:

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 的匹配过程是这样的:

graph TD A[&#34;URL: /products/123&#34;] --> B[&#34;匹配 /products Route&#34;] B --> C[&#34;渲染 Products 组件(父)&#34;] C --> D[&#34;父组件包含 h1 + Outlet&#34;] D --> E[&#34;继续匹配 /products/:productId&#34;] E --> F[&#34;渲染 ProductDetail 组件&#34;] F --> G[&#34;ProductDetail 替换 Outlet 位置&#34;] G --> H[&#34;最终输出:h1 产品列表 + h3 产品详情-123&#34;]

当你访问 /products/new 时:

graph TD A[&#34;URL: /products/new&#34;] --> B[&#34;匹配 /products Route&#34;] B --> C[&#34;渲染 Products 组件(父)&#34;] C --> D[&#34;父组件包含 h1 + Outlet&#34;] D --> E[&#34;继续匹配 /products/new&#34;] E --> F[&#34;渲染 NewProduct 组件&#34;] F --> G[&#34;NewProduct 替换 Outlet 位置&#34;] G --> H[&#34;最终输出:h1 产品列表 + h1 NewProduct<br/>⚠️ 标题不该出现在这!&#34;]

两张图一对比,问题一目了然:父路由的 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

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 个不需要------你会把筛选栏放在哪? 欢迎在评论区聊聊你的方案。


相关推荐
烬羽1 小时前
从"整个页面刷新"到"丝滑切换":手写一个 HashRouter 彻底搞懂前端路由
javascript·前端框架·全栈
kyriewen4 小时前
别再这样写条件渲染了——你的React组件里藏着这5种定时炸弹
前端·javascript·react.js
Loveyourself6 小时前
你知道吗?详细揭秘cc中api层微压缩-microcompact
面试·agent
mONESY7 小时前
🎨 从零搭建 React + TypeScript + Vite 项目:以 Color Picker 为例,聊聊前端工程化的 model/api 分层架构
react.js
神奇小汤圆8 小时前
LLM Agent 底层揭秘:大模型如何通过 JSON-RPC 2.0 协议跨进程调工具?
面试
飛行艇10 小时前
08 千人千面:Agent用户记忆与上下文管理
面试
小高00710 小时前
🔥🔥🔥TypeScript 7 正式版来了:别只看 10 倍速度,这 4 个迁移坑更值得注意
前端·javascript·面试
众人皆醒我独醉10 小时前
KServe:Kubernetes 原生的模型推理平台——把 vLLM/TGI/Triton 变成 Serverless
人工智能·ci/cd·面试
阿黎梨梨13 小时前
从“能用”到“好维护”:React + TypeScript 实战指南
react.js