Skip to content

嵌套路由与布局

🙋 后台管理系统需要侧边栏保持不变,只更新内容区。电商网站需要面包屑导航。这些复杂布局如何实现?

嵌套路由基础#

App Router 的文件夹结构天然支持嵌套:

app/
├── layout.tsx # 根布局
├── page.tsx # 首页 /
└── dashboard/
├── layout.tsx # Dashboard 布局
├── page.tsx # /dashboard
├── analytics/
│ └── page.tsx # /dashboard/analytics
└── settings/
├── layout.tsx # Settings 布局
├── page.tsx # /dashboard/settings
└── profile/
└── page.tsx # /dashboard/settings/profile

渲染层级:

<RootLayout>
<DashboardLayout>
<SettingsLayout>
<ProfilePage />
</SettingsLayout>
</DashboardLayout>
</RootLayout>

布局的核心特性#

1. 状态保持#

布局在路由切换时不会重新挂载:

app/dashboard/layout.tsx
// Next.js 15.x
'use client'
import { useState } from 'react'
import Link from 'next/link'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// 这个状态在子路由切换时保持
const [sidebarOpen, setSidebarOpen] = useState(true)
return (
<div className="flex">
<aside className={sidebarOpen ? 'w-64' : 'w-16'}>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? '收起' : '展开'}
</button>
<nav>
<Link href="/dashboard">概览</Link>
<Link href="/dashboard/analytics">分析</Link>
<Link href="/dashboard/settings">设置</Link>
</nav>
</aside>
<main className="flex-1">{children}</main>
</div>
)
}

🎯 效果:从 /dashboard 切换到 /dashboard/analytics,侧边栏状态不会丢失。

2. 请求去重#

布局中的数据请求会自动去重:

app/dashboard/layout.tsx
// Next.js 15.x
async function getUser() {
// 即使多个组件调用,也只会发送一次请求
const res = await fetch('https://api.example.com/user')
return res.json()
}
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await getUser()
return (
<div>
<header>欢迎, {user.name}</header>
{children}
</div>
)
}

3. 部分渲染#

路由切换时,只有变化的部分会重新渲染:

/dashboard → /dashboard/analytics
┌─────────────────────────┐
│ DashboardLayout │ ← 不重新渲染
│ ┌───────────────────┐ │
│ │ 变化的内容区域 │ │ ← 重新渲染
│ └───────────────────┘ │
└─────────────────────────┘

布局模式#

根布局(必需)#

app/layout.tsx
// Next.js 15.x
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>{children}</body>
</html>
)
}

🔶 必须:根布局必须定义 <html><body> 标签。

区域布局#

app/dashboard/layout.tsx
// Next.js 15.x
import { Sidebar } from '@/components/Sidebar'
import { Header } from '@/components/Header'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
)
}

带参数的布局#

动态路由的布局可以接收参数:

app/shop/[category]/layout.tsx
// Next.js 15.x
interface LayoutProps {
children: React.ReactNode
params: Promise<{ category: string }>
}
export default async function CategoryLayout({
children,
params,
}: LayoutProps) {
const { category } = await params
return (
<div>
<nav className="bg-gray-100 p-4">
<h2 className="text-lg font-bold">{category.toUpperCase()} 分类</h2>
</nav>
{children}
</div>
)
}

Template 模板#

Template 和 Layout 类似,但每次导航都会重新挂载:

app/dashboard/template.tsx
// Next.js 15.x
'use client'
import { useEffect } from 'react'
import { motion } from 'framer-motion'
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode
}) {
useEffect(() => {
// 每次导航都会执行
console.log('页面进入')
return () => console.log('页面离开')
}, [])
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}

Layout vs Template#

特性LayoutTemplate
路由切换时保持挂载重新挂载
状态保持
useEffect只执行一次每次执行
适用场景共享 UI、保持状态动画、页面统计

状态共享#

Context 方式#

app/dashboard/layout.tsx
// Next.js 15.x
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
interface DashboardContextType {
selectedProject: string | null
setSelectedProject: (id: string | null) => void
}
const DashboardContext = createContext<DashboardContextType | null>(null)
export function useDashboard() {
const context = useContext(DashboardContext)
if (!context) {
throw new Error('useDashboard must be used within DashboardLayout')
}
return context
}
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [selectedProject, setSelectedProject] = useState<string | null>(null)
return (
<DashboardContext.Provider value={{ selectedProject, setSelectedProject }}>
<div className="flex">
<aside className="w-64">{/* 侧边栏可以使用 context */}</aside>
<main className="flex-1">{children}</main>
</div>
</DashboardContext.Provider>
)
}
app/dashboard/page.tsx
// Next.js 15.x
'use client'
import { useDashboard } from './layout'
export default function DashboardPage() {
const { selectedProject, setSelectedProject } = useDashboard()
return (
<div>
<p>当前项目: {selectedProject ?? '未选择'}</p>
<button onClick={() => setSelectedProject('project-1')}>
选择项目 1
</button>
</div>
)
}

URL 状态方式#

更推荐使用 URL 参数管理状态,便于分享和刷新保持:

app/dashboard/page.tsx
// Next.js 15.x
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
export default function DashboardPage() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const tab = searchParams.get('tab') ?? 'overview'
const setTab = (newTab: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('tab', newTab)
router.push(`${pathname}?${params.toString()}`)
}
return (
<div>
<nav className="flex gap-4 mb-4">
{['overview', 'analytics', 'reports'].map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={tab === t ? 'font-bold' : ''}
>
{t}
</button>
))}
</nav>
<div>当前标签: {tab}</div>
</div>
)
}

实战:后台管理系统#

app/
├── layout.tsx # 根布局
├── (auth)/ # 认证路由组
│ ├── login/
│ │ └── page.tsx
│ └── register/
│ └── page.tsx
└── (dashboard)/ # 后台路由组
├── layout.tsx # 后台布局(侧边栏 + 顶栏)
├── page.tsx # 首页重定向
├── overview/
│ └── page.tsx # /overview
├── projects/
│ ├── layout.tsx # 项目布局
│ ├── page.tsx # /projects
│ └── [id]/
│ ├── page.tsx # /projects/:id
│ └── settings/
│ └── page.tsx # /projects/:id/settings
└── settings/
├── layout.tsx # 设置布局(选项卡)
├── page.tsx # /settings
├── profile/
│ └── page.tsx # /settings/profile
└── team/
└── page.tsx # /settings/team
// app/(dashboard)/layout.tsx
// Next.js 15.x
import { Sidebar } from '@/components/Sidebar'
import { TopBar } from '@/components/TopBar'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex">
<Sidebar />
<div className="flex-1 flex flex-col">
<TopBar />
<main className="flex-1 overflow-auto p-6 bg-gray-50">{children}</main>
</div>
</div>
)
}
// app/(dashboard)/settings/layout.tsx
// Next.js 15.x
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const tabs = [
{ href: '/settings', label: '通用' },
{ href: '/settings/profile', label: '个人资料' },
{ href: '/settings/team', label: '团队' },
]
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
const pathname = usePathname()
return (
<div>
<h1 className="text-2xl font-bold mb-6">设置</h1>
<nav className="flex gap-4 border-b mb-6">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={`pb-2 ${
pathname === tab.href
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500'
}`}
>
{tab.label}
</Link>
))}
</nav>
{children}
</div>
)
}

常见问题#

🤔 Q: 如何在布局中获取当前路径?

'use client'
import { usePathname } from 'next/navigation'
export default function Layout({ children }) {
const pathname = usePathname()
// ...
}

🤔 Q: 布局能访问子页面的数据吗?

不能直接访问。可以通过 Context、URL 参数或服务端数据共享。

🤔 Q: 如何让某些子路由不使用父布局?

使用路由组来创建不同的布局分支(下一篇详解)。


下一篇将介绍路由组,学习如何用 (folder) 语法组织路由而不影响 URL。

-EOF-