陌上人如玉
公子世无双

6.3 嵌套路由与布局

6.3 嵌套路由与布局

一句话:嵌套路由让你把「布局」抽取到父路由组件里(用 <Outlet /> 标记子路由的渲染位置),从而实现「导航栏不动、只切换内容区」;index 路由表示「父路径本身的默认页面」。

一、为什么需要嵌套路由

// ❌ 每个页面都重复写一遍布局 → 大量重复代码
function Home() {
  return (
    <div>
      <TopNav />
      <Sidebar />
      <main>首页内容</main>
    </div>
  );
}

function Settings() {
  return (
    <div>
      <TopNav />              {/* 重复 */}
      <Sidebar />             {/* 重复 */}
      <main>设置内容</main>
    </div>
  );
}
// ✅ 用嵌套路由:布局只写一次
const router = [
  {
    path: '/',
    element: <AppLayout />,          {/* ⭐ 布局组件 */}
    children: [
      { index: true, element: <Home /> },        // 渲染在 <Outlet /> 处
      { path: 'settings', element: <Settings /> },
      { path: 'users/:id', element: <UserDetail /> },
    ],
  },
];

function AppLayout() {
  return (
    <div>
      <TopNav />
      <Sidebar />
      <main>
        <Outlet />                  {/* ⭐ 子路由渲染在这里 */}
      </main>
    </div>
  );
}
关键收益:
① 布局复用(导航栏、侧栏、页脚只写一次)
② 导航栏「不重新挂载」——切换页面时它保持稳定(不闪、不重置状态)
③ 可以「逐层叠加」布局(App 布局 → 模块布局 → 页面内容)
④ 路由级错误边界、懒加载可以「分层配置」

二、Outlet 的渲染机制

// React Router 的渲染链
<Route path="/" element={<AppLayout />}>
  <Route path="dashboard" element={<DashboardLayout />}>
    <Route path="settings" element={<Settings />} />
  </Route>
</Route>
访问 /dashboard/settings 时的渲染结构:

<AppLayout>                    ← 第一层
  <TopNav />
  <Outlet>                     ← 这里是 DashboardLayout
    <DashboardLayout>          ← 第二层
      <Sidebar />
      <Outlet>                 ← 这里是 Settings
        <Settings />           ← 第三层(叶子)
      </Outlet>
    </DashboardLayout>
  </Outlet>
</AppLayout>

⚠️ 每一层的「布局组件」都会渲染,同时在它的 <Outlet /> 处渲染下一层。
   因此「上层组件不会因路由切换而重新挂载」。
// 对比:不用嵌套路由时(所有页面平铺)
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/settings" element={<Settings />} />
</Routes>
// → 从 / 切到 /settings 时,Home 卸载、Settings 挂载,
//   如果两边都渲染 <TopNav />,导航栏会被「卸载重建」(状态丢失、可能有闪烁)

// ✅ 用嵌套路由后,从 / 切到 /settings 时,AppLayout 保持挂载
//   → 只有 <Outlet /> 里的内容被替换 → 导航栏状态保留

三、index 路由

{
  path: '/dashboard',
  element: <DashboardLayout />,
  children: [
    { index: true, element: <Overview /> },        // ⭐ 匹配 /dashboard
    { path: 'settings', element: <Settings /> },   // 匹配 /dashboard/settings
  ],
}
index 路由的含义:
  「父路径本身的默认子路由」(相当于 v5 的 `<Route exact path="/dashboard">`)

⚠️ 三个要点:
① index 路由【不能有 children】(它就是叶子)
② index 路由【不能有 path】(它的路径就是父路径)
③ 一个父路由只能有【一个】index 子路由
// ✅ 常见的「重定向到默认子页」写法
{
  path: '/dashboard',
  element: <DashboardLayout />,
  children: [
    { index: true, element: <Navigate to="overview" replace /> },   // ⭐ 重定向
    { path: 'overview', element: <Overview /> },
    { path: 'settings', element: <Settings /> },
  ],
}
// 访问 /dashboard → 自动跳到 /dashboard/overview

四、多级嵌套

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,                        // 全局:顶部导航 + 页脚
    errorElement: <RootError />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'admin',                              // 一级:管理模块
        element: <AdminLayout />,                   // 管理侧栏
        errorElement: <AdminError />,
        children: [
          { index: true, element: <Navigate to="users" replace /> },
          { path: 'users', element: <UserManage /> },
          {
            path: 'users/:id',                       // 二级
            element: <UserDetailLayout />,           // 详情内的子标签页
            children: [
              { index: true, element: <UserInfo /> },
              { path: 'orders', element: <UserOrders /> },
              { path: 'logs', element: <UserLogs /> },
            ],
          },
        ],
      },
    ],
  },
]);
访问 /admin/users/123/orders 的渲染链:

<RootLayout>
  <AdminLayout>
    <UserDetailLayout>
      <UserOrders />          ← 叶子
多级嵌套的建议:
① 一般 2~3 层足够,太深会让「数据获取 + 错误处理」变复杂
② 每层的「布局职责」要清晰(全局 / 模块 / 页面内)
③ 错误边界要「分层」——模块级的错误不应该让整个应用崩掉
④ 路径层级与「UI 层级」保持一致(便于理解)

五、404 兜底

// ✅ 方式一:放在「最外层」的 children 里(推荐)
{
  path: '/',
  element: <RootLayout />,
  children: [
    { index: true, element: <Home /> },
    { path: 'about', element: <About /> },
    { path: '*', element: <NotFound /> },          // ⭐ 放在最后
  ],
}
// 访问不存在的路径时:RootLayout 依然渲染(导航栏还在),只有内容区显示 404
// ⚠️ 方式二:放在最外层(不推荐)
{
  path: '*',
  element: <NotFound />,                            // ⚠️ 整页 404(没有导航栏)
}
// 好处:绝对兜底(包括「根路径拼错」的情况)
// 坏处:用户失去导航能力(连「返回首页」都要自己加)→ 体验差
// ✅ 最佳实践:两者都配
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <RootError />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      { path: '*', element: <NotFound /> },       // ⭐ 应用内 404(保留布局)
    ],
  },
  { path: '*', element: <GlobalNotFound /> },     // ⭐ 绝对兜底(如 html 里的路径拼错)
]);
// 404 页面建议包含:
function NotFound() {
  return (
    <div className="not-found">
      <h1>404</h1>
      页面不存在或已被移除
      <Link to="/">返回首页</Link>                  {/* ⭐ 给出路 */}
      <button onClick={() => navigate(-1)}>返回上一页</button>
    </div>
  );
}

六、布局路由(无 path 的 Route)

// ✅ 「布局路由」——只提供布局与上下文,不占用路径段
{
  path: '/',
  element: <AppProviders>,            // ⭐ 只包 Provider,不加路径段
  children: [
    { index: true, element: <Home /> },
    { path: 'about', element: <About /> },
  ],
}
// 访问 / 与 /about 都会经过 AppProviders

// ⚠️ 前提:AppProviders 内部必须渲染 children 或 <Outlet />
function AppProviders() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <Outlet />                    {/* ⭐ 必须有 */}
      </AuthProvider>
    </QueryClientProvider>
  );
}
布局路由的常见用途:
① 注入全局 Provider(QueryClient、主题、用户)
② 统一的「页面骨架」(顶部导航 + 页脚)
③ 统一的「权限校验」(<RequireAuth><Outlet /></RequireAuth>)
④ 统一的「滚动位置重置」(切页面回到顶部)
// ③ 权限校验的布局路由写法
function RequireAuth() {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  return <Outlet />;                  {/* ⭐ 通过校验后渲染子路由 */}
}

const router = createBrowserRouter([
  { path: '/login', element: <Login /> },
  {
    path: '/',
    element: <RequireAuth />,         {/* ⭐ 所有子路由都需要登录 */}
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
    ],
  },
]);

七、Outlet 传上下文

// 父布局通过 Outlet 给子路由传值(避免重复请求)
function UserDetailLayout() {
  const { id } = useParams<{ id: string }>();
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id!),
  });

  if (isLoading) return <Skeleton />;

  return (
    <div>
      <UserHeader user={user} />
      <TabNav />
      <Outlet context={{ user }} />       {/* ⭐ 传上下文 */}
    </div>
  );
}

// 子路由读取
function UserOrders() {
  const { user } = useOutletContext<{ user: User }>();    // ⭐ 直接用,不用再请求
  return <div>{user.name} 的订单</div>;
}

八、常见坑

// ① 父布局组件忘记写 <Outlet />
function AppLayout() {
  return <div><TopNav /></div>;              // ❌ 子路由无处渲染(页面空白)
}

// ② index 路由写了 path
{ path: '/dashboard', children: [{ index: true, path: 'x', element: <X /> }] }   // ❌

// ③ 一个父路由配了多个 index 子路由
children: [
  { index: true, element: <A /> },
  { index: true, element: <B /> },            // ❌ 只有第一个生效
]

// ④ 子路由的 path 写成了「绝对路径」
{ path: 'admin', children: [{ path: '/admin/users', element: <U /> }] }   // ⚠️ 会拼错
// ✅ { path: 'users', element: <U /> }

// ⑤ 404 写成了「整页 404」(用户失去导航)
// ⑥ 嵌套过深(4 层以上)导致「数据获取与错误处理」复杂
// ⑦ 每层都请求同一份数据(应该用 Outlet context 或 Query 缓存)
// ⑧ 布局路由忘了渲染 Outlet
// ⑨ 认为「嵌套路由会自动传递数据」(需要 Outlet context 或 Query 缓存)
// ⑩ 在父布局里用 useParams 但该参数属于「子路由」(取不到)
// ⑪ 切换子路由时「滚动位置不重置」(应该在布局里处理)
// ⑫ 选项卡状态放在父路由(切换子路由时保留,可能不符合预期)

// ⑪ 的修复:在布局里监听 location 变化并重置滚动
function AppLayout() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);                    // ⭐ 路由变化回到顶部
  }, [pathname]);

  return (
    <div>
      <TopNav />
      <main><Outlet /></main>
    </div>
  );
}
// ⑩ 的说明:useParams 只返回「当前路由链」上的参数,所以父布局能拿到子路由的参数
//    其实是可以取到的(params 是「累积」的)——但「该不该取」是设计问题:
//    ✅ 父布局只关心「自己这一层」的参数
//    ✅ 子路由的参数由子路由自己读取

九、面试延伸

  1. 「嵌套路由是怎么工作的?」
    用「布局组件 + <Outlet />」:父路由的 element 是布局组件(导航栏、侧栏等),其中用 <Outlet /> 标记「子路由的渲染位置」。子路由的 path 是相对的(相对于父路由)。匹配到深层路径时,React Router 会逐层渲染:外层布局 → 中层布局 → … → 叶子组件,每层在自己的 Outlet 处渲染下一层。关键收益是「上层布局不会因路由切换而重新挂载」(导航栏状态保留、不闪烁)。

  2. 「index 路由是什么?」
    index 路由表示「父路径本身的默认子路由」(相当于 v5 的 <Route exact path="/parent">)。三个要点:① 不能有 path(路径就是父路径);② 不能有 children(它是叶子);③ 一个父路由只能有一个 index 子路由。常见用法是「index 路由 + <Navigate>」做「访问 /dashboard 自动跳到 /dashboard/overview」的重定向。

  3. 「为什么嵌套路由能让导航栏『不重新挂载』?」
    因为嵌套路由把「导航栏」放进了父路由的布局组件里,而父布局在子路由切换时不会被卸载——React Router 只是替换 <Outlet /> 处渲染的内容。对比「不用嵌套路由」的写法(每个页面各自渲染一遍 <TopNav />):从 / 切到 /settings 时,两个页面的组件树完全不同 → React 卸载一个、挂载另一个 → 导航栏被「卸载重建」(滚动位置、动画、输入状态全部重置,还可能有视觉闪烁)。

  4. 「404 应该配在哪里?」
    最佳实践是配两处:① 应用内 404(放在「根布局的 children 末尾」,path="*")——这样 404 页面保留导航栏,用户还能用导航;② 绝对兜底(放在最外层路由数组的末尾)——处理「连根布局都匹配不到」的极端情况。只配「整页 404」的问题是用户失去导航能力(看到一个孤零零的 404 页面),体验较差。此外 404 页面应该给出「返回首页」「返回上一页」等出路。

  5. 「什么是布局路由(无 path 的 Route)?」
    指「只有 element、没有 path 的路由节点」——它不占用路径段,只用来「包一层」:渲染 Provider(QueryClient、主题、用户)、统一骨架、统一权限校验。关键前提是「它的 element 里必须渲染 children 或 <Outlet />」,否则子路由无处渲染(页面空白)。它的典型用途是把「登录校验」做成一个布局路由:<RequireAuth><Outlet /></RequireAuth>,让所有子路由自动受保护。

  6. 「嵌套路由里的数据应该放在哪一层获取?」
    原则是「放在真正需要它的那一层」——但要注意「父子都需要同一份数据」时的处理:① 用 Outlet context——父路由请求一次,通过 <Outlet context={{ data }} /> 传给子路由(子路由用 useOutletContext 读取);② 用查询库的缓存——父子都用同一个 queryKey(TanStack Query 会自动去重、共享缓存,不会发两次请求)。避免的做法是「父组件用 useEffect 请求、子组件也请求一次」(重复请求 + 状态不一致)。

  7. 「嵌套路由有什么缺点?」
    三点:① 过度嵌套会复杂化——4 层以上的嵌套会让「数据获取、错误处理、状态归属」都变难;② 生命周期不好控制——因为父布局不重新挂载,「切页面重置状态」需要显式处理(如滚动位置、Tab 选中态);③ 路径与 UI 强耦合——如果 UI 层级变化(如把侧栏从「布局」改成「可折叠的抽屉」),路由结构可能也要跟着调整。所以建议「2~3 层为宜、每层职责清晰(全局/模块/页面内)」。

  8. 「切换子路由时怎么重置滚动位置?」
    在「布局组件」里监听 pathname 变化并重置:useEffect(() => { window.scrollTo(0, 0); }, [pathname])(用 useLocation().pathname)。如果要「返回时恢复滚动位置」(如从详情页返回列表页),需要额外记录「每个路径的滚动位置」并在返回时恢复。也可以用 ScrollRestoration 组件(数据路由提供,会自动处理「前进/后退时恢复位置、普通导航时回到顶部」)。

一句话速记

嵌套路由 = 「父路由放布局 + <Outlet /> 标记子路由渲染位置」,收益是「导航栏不重新挂载」;index 路由表示「父路径本身的默认子页」(不能有 path、不能有 children、每层只能一个),常配 <Navigate> 做默认重定向;布局路由(无 path)用于「统一包 Provider / 权限校验」,前提是内部必须渲染 <Outlet />;404 配两处(应用内保留布局 + 最外层绝对兜底),并给出「返回首页/上一页」;子路由 path 用相对路径;数据放真正需要的那一层(父子共享用 Outlet context 或同一个 queryKey);切换路由要显式重置滚动位置;嵌套 2~3 层为宜。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.3 嵌套路由与布局

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫