🙋 后台管理系统需要侧边栏保持不变,只更新内容区。电商网站需要面包屑导航。这些复杂布局如何实现?
嵌套路由基础#
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. 状态保持#
布局在路由切换时不会重新挂载:
// 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. 请求去重#
布局中的数据请求会自动去重:
// 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 │ ← 不重新渲染│ ┌───────────────────┐ ││ │ 变化的内容区域 │ │ ← 重新渲染│ └───────────────────┘ │└─────────────────────────┘布局模式#
根布局(必需)#
// Next.js 15.ximport 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> 标签。
区域布局#
// Next.js 15.ximport { 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> )}带参数的布局#
动态路由的布局可以接收参数:
// 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 类似,但每次导航都会重新挂载:
// 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#
| 特性 | Layout | Template |
|---|---|---|
| 路由切换时 | 保持挂载 | 重新挂载 |
| 状态保持 | ✅ | ❌ |
| useEffect | 只执行一次 | 每次执行 |
| 适用场景 | 共享 UI、保持状态 | 动画、页面统计 |
状态共享#
Context 方式#
// 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> )}// 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 参数管理状态,便于分享和刷新保持:
// 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.ximport { 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-