Skip to content

Lesson 08:嵌套布局 — Sidebar 侧边栏与动态路由 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:75~120 分钟(首次学习)
  • 先修要求:完成 Lesson 07,已配置 React Router 7 的 Data Router
  • 学习产出:搭建双层布局,读取动态项目ID并处理不存在的项目
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:实现带有常驻侧边栏的嵌套布局结构,掌握动态路由的使用。

📦 本节产出:一个看起来很像真实应用的后台布局环境,并且能通过 /projects/:id 读取不同看板。

一、真实世界的嵌套布局设计 ​

在任务管理系统中,我们不仅要有顶部的通用导航仪,还需要在某个区域提供特定的侧边栏。比如,在“项目管理”大模块下,左侧需要罗列出所有项目。

这就是路由的高级玩法:多重嵌套 Outlet。


二、重构代码结构 ​

我们来创建一个更专业的布局包。

src/
├── layouts/
│   ├── RootLayout.tsx      ← 外层骨架 (Header + Main)
│   └── ProjectsLayout.tsx  ← 内层骨架 (Sidebar + Content)
├── pages/
│   ├── Home.tsx            
│   ├── projects/
│   │   ├── ProjectList.tsx ← 项目主页
│   │   └── Board.tsx       ← 动态看板
...

2.1 外壳 RootLayout.tsx ​

tsx
// src/layouts/RootLayout.tsx
import { NavLink, Outlet } from 'react-router'

export default function RootLayout() {
  return (
    <div className="h-dvh flex flex-col bg-gray-50">
      <header className="h-14 bg-indigo-600 px-6 flex items-center shadow-md shrink-0">
        <div className="font-bold text-lg text-white mr-8">🚀 TaskMaster</div>
        <nav className="flex gap-4">
          <NavLink 
            to="/" 
            className={({ isActive }) => 
              `px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
                isActive ? 'bg-indigo-700 text-white' : 'text-indigo-100 hover:bg-indigo-500'
              }`
            }
          >
            首页看板
          </NavLink>
          <NavLink 
            to="/projects" 
            className={({ isActive }) => 
              `px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
                isActive ? 'bg-indigo-700 text-white' : 'text-indigo-100 hover:bg-indigo-500'
              }`
            }
          >
            我的项目
          </NavLink>
          <NavLink to="/settings" className="px-3 py-1.5 text-sm text-white">设置</NavLink>
        </nav>
      </header>

      {/* 留给下层页面的插槽 */}
      <main className="min-h-0 flex-1 overflow-auto">
        <Outlet />
      </main>
    </div>
  )
}

2.2 内壳带边栏 ProjectsLayout.tsx ​

当你处于 /projects 路径或其子路径下时,这里会被加载,并提供左侧 Sidebar 菜单。

tsx
// src/layouts/ProjectsLayout.tsx
import { NavLink, Outlet } from 'react-router'

// 模拟的分类数据(后期可以通过 API 获取)
const MOCK_PROJECTS = [
  { id: 'app-rebuild', name: 'App 重构计划', icon: '📱' },
  { id: 'marketing-q3', name: 'Q3 营销活动', icon: '🎯' },
  { id: 'web-design', name: '官网重新设计', icon: '🎨' },
]

export default function ProjectsLayout() {
  return (
    <div className="flex h-full"> {/* 父级是 <main flex-1> */}
      {/* Sidebar 侧边栏 */}
      <aside className="w-64 bg-white border-r border-gray-200 shrink-0 flex flex-col py-4">
        <h2 className="px-6 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">
          近期项目
        </h2>
        
        <nav className="flex-1 px-3 space-y-1">
          {MOCK_PROJECTS.map(proj => (
            <NavLink
              key={proj.id}
              to={`/projects/${proj.id}`}
              className={({ isActive }) => 
                `flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
                  isActive 
                    ? 'bg-indigo-50 text-indigo-700' 
                    : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
                }`
              }
            >
              <span>{proj.icon}</span>
              {proj.name}
            </NavLink>
          ))}
        </nav>
      </aside>

      {/* 内容区域 (再次放出 Outlet) */}
      <div className="min-w-0 flex-1 overflow-auto bg-gray-50/50 p-8">
        <Outlet />
      </div>
    </div>
  )
}

三、动态路由解析与数据组装 ​

现在的重点是 /projects/:id 指向的 Board.tsx。

我们需要使用 useParams() 钩子来抓取当前选中的是哪个看板。

tsx
// src/pages/projects/Board.tsx
import { useParams, Navigate } from 'react-router'

// 假设这是我们的数据源
const BOARD_DATA: Record<string, { title: string, tasks: number }> = {
  'app-rebuild': { title: 'App 重构计划', tasks: 12 },
  'marketing-q3': { title: 'Q3 营销活动', tasks: 5 },
  'web-design': { title: '官网重新设计', tasks: 8 },
}

export default function Board() {
  // 1. 获取 URL 中的动态参数 (即 /projects/xxx 里的 xxx)
  const params = useParams<{ id: string }>()
  const projectId = params.id
  
  if (!projectId) return <div>未选择项目</div>
  
  // 2. 模拟从数据库或 API 查询当前数据
  const project = BOARD_DATA[projectId]

  // 3. 处理错误:如果 URL 里的 id 查不到项目,重定向或提示错误
  if (!project) {
    return (
      <div className="text-center py-20">
        <h2 className="text-xl text-gray-500 mb-4">找不到这个看板</h2>
        {/* 用 <Navigate> 组件做强制编程式跳转 */}
        <Navigate to="/projects" replace />
      </div>
    )
  }

  // 4. 正常渲染
  return (
    <div>
      <header className="mb-8">
        <h1 className="text-3xl font-extrabold text-gray-900">{project.title}</h1>
        <p className="text-gray-500 mt-2">当前共有 {project.tasks} 个活跃任务</p>
      </header>

      {/* 假装这里有一个复杂的拖拽看板 */}
      <div className="grid grid-cols-3 gap-6">
        <div className="bg-gray-100 rounded-xl p-4 min-h-[400px]">待处理</div>
        <div className="bg-gray-100 rounded-xl p-4 min-h-[400px]">进行中</div>
        <div className="bg-gray-100 rounded-xl p-4 min-h-[400px]">已完成</div>
      </div>
    </div>
  )
}

3.1 保留项目列表页 ​

Lesson 07 的 Projects.tsx 和 ProjectBoard.tsx 不再参与路由。新建列表文件,后续课程会替换它的内容:

tsx
// src/pages/projects/ProjectList.tsx
export default function ProjectList() {
  return <p className="mt-10 text-center text-gray-500">请从左侧选择一个项目看板。</p>
}

四、全新的完整路由配置 ​

现在我们要把双重嵌套的架构写进 main.tsx。

tsx
// src/main.tsx(替换整个文件;保留 Lesson 07 的 Home / Settings / NotFound)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter } from 'react-router'
import { RouterProvider } from 'react-router/dom'
import RootLayout from './layouts/RootLayout'
import ProjectsLayout from './layouts/ProjectsLayout'
import Home from './pages/Home'
import Settings from './pages/Settings'
import NotFound from './pages/NotFound'
import ProjectList from './pages/projects/ProjectList'
import Board from './pages/projects/Board'
import './index.css'

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,  // 最外层包含顶部导航
    children: [
      { 
        index: true, 
        element: <Home /> 
      },
      {
        path: 'projects',
        element: <ProjectsLayout />, // 带有侧边栏的中层
        children: [
          { 
            index: true, 
            element: <ProjectList />
          },
          { 
            path: ':id',  // 动态参数
            element: <Board /> 
          }
        ]
      },
      { path: 'settings', element: <Settings /> },
      { path: '*', element: <NotFound /> }
    ]
  }
])

createRoot(document.getElementById('root')!).render(
  <StrictMode><RouterProvider router={router} /></StrictMode>,
)

注意: index: true 意味着当用户直接访问上一级对应的路径(如精确命中 /projects 而不是 /projects/xxx)时,在 Outlet 的位置填充的默认组件。它保证了即便没选项目,右侧也有内容(而不是空白)。


五、🧠 深度专题:Loader 与 Action 模式 ​

React Router v6.4+ 引入了重大的架构变革——把数据获取和组件渲染解耦,并在进入组件渲染之前,提前并发加载数据。

在目前的 Board.tsx 里,我们是在组件内利用 BOARD_DATA "同步"获取的。 如果在真实项目中,我们需要 fetch API 呢?传统做法是:

text
组件提交 → useEffect 发请求 → 请求完成 → 更新组件
父组件取回数据后才挂载子组件 → 子组件再请求,可能形成串行等待

下面是 API 已存在时的扩展示例,不替换本课的 BOARD_DATA 主线。/api/projects/:id 要由后端提供;Vite 不会自动创建这个接口。

Loader 模式:在导航时加载数据 ​

tsx
import { useLoaderData, type LoaderFunctionArgs } from 'react-router'

type ProjectDetail = { id: string; title: string }

// 1. 在单独的 loader 函数中获取数据
export async function boardLoader({ params, request }: LoaderFunctionArgs) {
  if (!params.id) throw new Response('Missing project ID', { status: 400 })
  const res = await fetch(`/api/projects/${encodeURIComponent(params.id)}`, {
    signal: request.signal,
  })
  if (!res.ok) throw new Response('加载项目失败', { status: res.status })
  return await res.json() as ProjectDetail // 示例按已约定的 API 格式读取
}

// 2. loader 成功返回后,组件读取它的数据

export default function Board() {
  const project = useLoaderData<typeof boardLoader>()
  return <div>{project.title}</div>
}

而在 main.tsx 路由配置中绑定:

tsx
{ 
  path: ':id',
  element: <Board />,
  loader: boardLoader, // 绑定加载器,阻塞渲染直到请求完成
}

路由器在导航时加载匹配路由的数据,通常可以并行运行父子 loader,减少依赖组件挂载才开始请求的瀑布。它不会在用户刚输入 URL 时自动请求,也不会消除 API 自身的数据依赖。等待和失败状态仍需处理:导航期间可用 useNavigation 显示进度,loader 抛错由 errorElement 接住。参见 React Router 数据加载。

Action 模式:处理写入 ​

路由 action 接收表单提交;在 Data Mode 中,可用 React Router 的 <Form method="post"> 或 useFetcher 调用它。成功后路由器会重新验证相关 loader 数据。这里的路由 action 在客户端运行,不是 React Server Action,也不能直接访问数据库或隐藏密钥;它仍需调用后端接口。

本阶段核心目标是构建页面架构和逻辑状态。我们在之后的 Lesson 中结合 TanStack Query 处理请求时,会深入体会这种模式。


六、练习 ​

  1. 添加路由错误边界:在 RootLayout 级别和 ProjectsLayout 级别都可以添加 errorElement。试着故意输入一个错误的项目 ID(如 /projects/1234xx)并在动态路由中 throw new Error(),用 useRouteError() 读取错误,观察最近的 errorElement 如何替换对应路由分支。普通异常和未知 URL 是两种情况;不要都标为 404。
  2. 提取 SideBar 菜单数据:把侧边栏菜单的数据源提出成一个单独的钩子或者配置文件,避免写死在组件里。

📌 本节小结 ​

你做了什么你学到了什么
构建了双层带边栏的 UI 布局多重 <Outlet /> 嵌套的作用和用法
创建了可切换高亮的 SidebarNavLink 的 className({ isActive }) 回调
取出了 URL 里的项目 ID 渲染内容useParams() 和 动态路由语法 :id
配置了无匹配时的编程式补救<Navigate replace />;index: true 只负责父路径的默认内容
—v7 中 Loader 的数据预加载架构理念

项目驱动 · 边写边学 · React 19