Layout 与模板系统
Layout 是 App Router 的核心概念之一——它定义了页面的共享结构(导航栏、侧边栏),在路由切换时保持状态不重置。Template 则相反——每次导航都重新挂载。本章讲清两者的区别、嵌套规则、以及如何为 AI SaaS 设计多层布局体系。
1. 根布局
1.1 必需文件
app/layout.tsx 是唯一必需的布局文件,它定义 <html> 和 <body> 标签:
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Providers } from '@/components/providers'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: { default: 'AI Chat', template: '%s | AI Chat' },
description: 'AI SaaS 智能对话平台',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
)
}1.2 根布局的特殊性
根布局的规则:
├── 必须包含 <html> 和 <body> 标签
├── 不能被其他布局替换(只能有一个根布局)
├── 不能是 Client Component(但可以包含 Client 子组件)
├── 整个应用的所有页面都会经过根布局
└── metadata 在根布局中定义的是全站默认值
2. 嵌套布局
2.1 AI SaaS 的多层布局
app/
├── layout.tsx # 根布局:html/body/Providers
├── (marketing)/
│ ├── layout.tsx # 营销布局:顶部导航 + Footer
│ ├── page.tsx # 首页
│ ├── pricing/page.tsx # 定价页
│ └── about/page.tsx # 关于页
├── (auth)/
│ ├── layout.tsx # 认证布局:居中卡片
│ ├── login/page.tsx
│ └── register/page.tsx
└── (dashboard)/
├── layout.tsx # 仪表盘布局:侧边栏 + 主区域
├── page.tsx # 仪表盘首页
├── chat/
│ ├── layout.tsx # 对话布局:对话列表 + 对话区域
│ ├── page.tsx # 对话列表
│ └── [id]/page.tsx # 对话详情
└── settings/
├── layout.tsx # 设置布局:左侧导航
├── page.tsx # 通用设置
├── billing/page.tsx # 账单
└── api-keys/page.tsx # API Key
2.2 各层布局实现
// (marketing)/layout.tsx — 营销页布局
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
<header className="border-b">
<nav className="mx-auto max-w-[1400px] px-4 py-4 flex justify-between">
<Logo />
<div className="flex gap-4">
<Link href="/pricing">定价</Link>
<Link href="/about">关于</Link>
<Link href="/login">登录</Link>
</div>
</nav>
</header>
<main className="flex-1">{children}</main>
<Footer />
</div>
)
}// (auth)/layout.tsx — 认证页布局
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Logo />
</div>
{children}
</div>
</div>
)
}// (dashboard)/layout.tsx — 仪表盘布局
import { auth } from '@/lib/auth/config'
import { redirect } from 'next/navigation'
import { AppSidebar } from '@/components/app-sidebar'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session?.user) redirect('/login')
return (
<div className="flex h-screen">
<AppSidebar user={session.user} />
<main className="flex-1 overflow-auto">{children}</main>
</div>
)
}2.3 嵌套渲染顺序
访问 /chat/abc123 时的布局嵌套:
RootLayout (html/body/Providers)
└── DashboardLayout (侧边栏 + 主区域)
└── ChatLayout (对话列表 + 对话区域)
└── ChatPage (对话详情)
每层 layout 的 {children} 就是下一层 layout 或 page
3. Layout vs Template
3.1 核心区别
| 维度 | Layout (layout.tsx) | Template (template.tsx) |
|---|---|---|
| 状态保持 | ✅ 导航时保持 state | ❌ 每次导航重新挂载 |
| DOM 重建 | ❌ 不重建 | ✅ 每次重建 |
| useEffect | 不会重新执行 | 每次导航都执行 |
| 动画 | 不触发进入/退出动画 | 触发进入/退出动画 |
| 使用场景 | 导航栏、侧边栏 | 页面过渡动画、访问日志 |
3.2 Layout 的状态保持
// (dashboard)/layout.tsx — 侧边栏折叠状态在路由切换时保持
'use client'
export default function DashboardLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
// 从 /chat 导航到 /settings 时,collapsed 状态不变
return (
<div className="flex">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
<main>{children}</main>
</div>
)
}3.3 Template 的重新挂载
// (dashboard)/template.tsx — 每次导航都执行
'use client'
import { useEffect } from 'react'
import { motion } from 'framer-motion'
export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
useEffect(() => {
// 每次导航到 dashboard 下的任何页面都会执行
analytics.track('page_view', { path: window.location.pathname })
})
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}3.4 选择指南
需要跨页面保持状态?(侧边栏折叠、滚动位置)
├── 是 → Layout
└── 否 → 需要每次导航都触发效果?
├── 页面过渡动画 → Template
├── 每次导航记录日志 → Template
└── 其他 → Layout(默认选择)
4. 条件布局
4.1 Route Groups 实现不同布局
app/
├── (marketing)/ # 不需要登录,有 Header + Footer
│ └── layout.tsx
├── (auth)/ # 不需要登录,居中卡片
│ └── layout.tsx
└── (dashboard)/ # 需要登录,有侧边栏
└── layout.tsx
Route Groups () 不影响 URL,只影响布局嵌套。
4.2 基于用户角色的条件布局
// (dashboard)/layout.tsx
import { auth } from '@/lib/auth/config'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session?.user) redirect('/login')
const isAdmin = session.user.role === 'admin'
return (
<div className="flex h-screen">
<AppSidebar
user={session.user}
navItems={isAdmin ? adminNavItems : userNavItems}
/>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
)
}4.3 generateMetadata 的布局配合
// (dashboard)/chat/[id]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const chat = await getChat(id)
return {
title: chat?.title ?? '对话',
}
}结合根布局的 title.template:
页面标题 = "对话标题 | AI Chat"
= chat.title + " | " + metadata.title.default
5. 布局性能
5.1 Layout 只在需要时渲染
访问 /chat/abc 然后导航到 /chat/def:
├── RootLayout — 不重新渲染(子路由没变到不同 layout)
├── DashboardLayout — 不重新渲染
├── ChatLayout — 不重新渲染
└── ChatPage — 重新渲染([id] 参数变了)
5.2 布局中的数据获取
// Layout 中获取数据 = 只在布局首次渲染时获取一次
export default async function DashboardLayout({ children }) {
const user = await getUser() // 不会因为子页面变化而重新获取
return (
<div>
<Header user={user} />
{children}
</div>
)
}
// 如果需要每次路由变化都重新获取 → 用 Template本章小结
- 根布局:唯一必需,定义
<html>/<body>,挂载全局 Providers - 嵌套布局:每一层
{children}嵌入下一层,自然形成页面结构 - Route Groups:
()分组不同布局,不影响 URL - Layout vs Template:Layout 保持状态,Template 每次重新挂载
- 性能:Layout 只在需要时渲染,子路由变化不影响父 Layout
下一章讲 React 19 新特性实战。