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

本章小结

  • 根布局:唯一必需,定义 &lt;html&gt;/&lt;body&gt;,挂载全局 Providers
  • 嵌套布局:每一层 &#123;children&#125; 嵌入下一层,自然形成页面结构
  • Route Groups() 分组不同布局,不影响 URL
  • Layout vs Template:Layout 保持状态,Template 每次重新挂载
  • 性能:Layout 只在需要时渲染,子路由变化不影响父 Layout

下一章讲 React 19 新特性实战。