Lesson 08:嵌套布局 — Sidebar 侧边栏与动态路由
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:75~120 分钟(首次学习)
- 先修要求:完成 Lesson 07,已配置 React Router 7 的 Data Router
- 学习产出:搭建双层布局,读取动态项目ID并处理不存在的项目
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 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
// 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 菜单。
// 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() 钩子来抓取当前选中的是哪个看板。
// 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 不再参与路由。新建列表文件,后续课程会替换它的内容:
// src/pages/projects/ProjectList.tsx
export default function ProjectList() {
return <p className="mt-10 text-center text-gray-500">请从左侧选择一个项目看板。</p>
}四、全新的完整路由配置
现在我们要把双重嵌套的架构写进 main.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 呢?传统做法是:
组件提交 → useEffect 发请求 → 请求完成 → 更新组件
父组件取回数据后才挂载子组件 → 子组件再请求,可能形成串行等待下面是 API 已存在时的扩展示例,不替换本课的 BOARD_DATA 主线。/api/projects/:id 要由后端提供;Vite 不会自动创建这个接口。
Loader 模式:在导航时加载数据
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 路由配置中绑定:
{
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 处理请求时,会深入体会这种模式。
六、练习
- 添加路由错误边界:在
RootLayout级别和ProjectsLayout级别都可以添加errorElement。试着故意输入一个错误的项目 ID(如/projects/1234xx)并在动态路由中throw new Error(),用useRouteError()读取错误,观察最近的errorElement如何替换对应路由分支。普通异常和未知 URL 是两种情况;不要都标为 404。 - 提取 SideBar 菜单数据:把侧边栏菜单的数据源提出成一个单独的钩子或者配置文件,避免写死在组件里。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 构建了双层带边栏的 UI 布局 | 多重 <Outlet /> 嵌套的作用和用法 |
| 创建了可切换高亮的 Sidebar | NavLink 的 className({ isActive }) 回调 |
| 取出了 URL 里的项目 ID 渲染内容 | useParams() 和 动态路由语法 :id |
| 配置了无匹配时的编程式补救 | <Navigate replace />;index: true 只负责父路径的默认内容 |
| — | v7 中 Loader 的数据预加载架构理念 |