Next.js 布局与模板系统设计
引言:布局是应用的骨架
在 Next.js App Router 中,布局(Layout)不仅仅是"包裹页面内容的容器"——它是整个应用架构的骨架。理解布局系统的设计理念和使用方式,直接影响到应用的性能、可维护性和用户体验。
这篇文章将深入探讨 Next.js 的布局系统,从基础概念到高级模式,帮助你构建结构清晰、性能优良的应用架构。
第一部分:布局系统基础
1.1 Layout vs Template
Next.js App Router 提供了两种包装组件的方式:
// layout.tsx - 持久化布局
// 特点:跨页面导航时保持状态,不重新渲染
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
// template.tsx - 模板
// 特点:每次导航都会重新挂载,状态重置
export default function DashboardTemplate({ children }) {
return (
<div className="dashboard">
<AnimatePresence>
{children}
</AnimatePresence>
</div>
);
}
选择指南:
┌────────────────────────────────────────────────────────────┐
│ Layout vs Template 选择 │
├────────────────────────────────────────────────────────────┤
│ │
│ 使用 Layout: │
│ ├── 需要保持状态(侧边栏展开状态、滚动位置) │
│ ├── 共享数据获取结果 │
│ ├── 减少重复渲染,提升性能 │
│ └── 适用:导航栏、侧边栏、页脚 │
│ │
│ 使用 Template: │
│ ├── 需要页面过渡动画 │
│ ├── 每次导航重置状态 │
│ ├── 需要重新执行 useEffect │
│ └── 适用:页面动画、进入动效、状态重置场景 │
│ │
└────────────────────────────────────────────────────────────┘
1.2 嵌套布局
布局可以嵌套,形成层次结构:
app/
├── layout.tsx # 根布局(必需)
├── page.tsx # 首页
├── dashboard/
│ ├── layout.tsx # 仪表盘布局
│ ├── page.tsx # /dashboard
│ ├── analytics/
│ │ └── page.tsx # /dashboard/analytics
│ └── settings/
│ ├── layout.tsx # 设置布局(三级嵌套)
│ ├── page.tsx # /dashboard/settings
│ └── profile/
│ └── page.tsx # /dashboard/settings/profile
// app/layout.tsx - 根布局
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx - 仪表盘布局
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<DashboardSidebar />
<div className="flex-1">
<DashboardHeader />
{children}
</div>
</div>
);
}
// app/dashboard/settings/layout.tsx - 设置布局
export default function SettingsLayout({ children }) {
return (
<div className="settings-container">
<SettingsNav />
<div className="settings-content">
{children}
</div>
</div>
);
}
渲染结果(访问 /dashboard/settings/profile):
<html>
<body>
<Header />
<div class="flex">
<DashboardSidebar />
<div class="flex-1">
<DashboardHeader />
<div class="settings-container">
<SettingsNav />
<div class="settings-content">
<!-- Profile Page Content -->
</div>
</div>
</div>
</div>
<Footer />
</body>
</html>
1.3 布局数据获取
布局可以是 async 组件,直接获取数据:
// app/dashboard/layout.tsx
import { getUser } from '@/lib/auth'
export default async function DashboardLayout({ children }) {
// 布局级别的数据获取
const user = await getUser();
if (!user) {
redirect('/login');
}
return (
<div className="dashboard">
<Sidebar user={user} />
<main>{children}</main>
</div>
);
}
// 关键点:
// 1. 布局数据获取只在首次渲染时执行
// 2. 子页面导航不会重新执行布局的数据获取
// 3. 这是优化,但也可能是陷阱(数据可能过时)
第二部分:高级布局模式
2.1 路由组(Route Groups)
路由组允许在不影响 URL 的情况下组织布局:
app/
├── (marketing)/ # 不出现在 URL 中
│ ├── layout.tsx # 营销页面布局
│ ├── page.tsx # /
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
│
├── (dashboard)/ # 不出现在 URL 中
│ ├── layout.tsx # 仪表盘布局
│ ├── dashboard/
│ │ └── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /settings
│
└── (auth)/ # 认证相关
├── layout.tsx # 认证页面布局
├── login/
│ └── page.tsx # /login
└── register/
└── page.tsx # /register
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
return (
<div className="marketing">
<MarketingNav />
{children}
<MarketingFooter />
</div>
);
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<DashboardSidebar />
{children}
</div>
);
}
// app/(auth)/layout.tsx
export default function AuthLayout({ children }) {
return (
<div className="auth-container">
<div className="auth-card">
{children}
</div>
</div>
);
}
使用场景:
- 不同页面使用不同布局(营销页 vs 应用页)
- 按功能模块组织代码
- 共享布局而不影响 URL 结构
2.2 并行路由(Parallel Routes)
并行路由允许在同一布局中同时渲染多个页面:
app/
├── layout.tsx
├── page.tsx
├── @modal/ # 插槽:模态框
│ ├── default.tsx
│ └── (.)photo/[id]/
│ └── page.tsx
├── @sidebar/ # 插槽:侧边栏
│ ├── default.tsx
│ └── page.tsx
└── photo/[id]/
└── page.tsx
// app/layout.tsx
export default function Layout({
children,
modal, // 对应 @modal 目录
sidebar, // 对应 @sidebar 目录
}) {
return (
<div className="app">
<div className="sidebar">{sidebar}</div>
<div className="main">{children}</div>
{modal} {/* 模态框覆盖层 */}
</div>
);
}
// app/@modal/default.tsx
// 当模态框不应显示时的默认内容
export default function ModalDefault() {
return null; // 不渲染任何内容
}
// app/@modal/(.)photo/[id]/page.tsx
// 拦截路由 - 在模态框中显示照片
export default function PhotoModal({ params }) {
return (
<Modal>
<PhotoDetail id={params.id} />
</Modal>
);
}
并行路由的妙用:
// 场景:仪表盘多面板布局
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats, // @stats
activity, // @activity
team, // @team
}) {
return (
<div className="dashboard-grid">
<div className="main-content">{children}</div>
<div className="stats-panel">{stats}</div>
<div className="activity-panel">{activity}</div>
<div className="team-panel">{team}</div>
</div>
);
}
// 每个面板可以独立加载,有自己的 loading 状态
// app/@stats/loading.tsx
export default function StatsLoading() {
return <StatsPlaceholder />;
}
2.3 拦截路由(Intercepting Routes)
拦截路由允许在当前布局中"拦截"并显示另一个路由的内容:
约定:
(.) - 拦截同级路由
(..) - 拦截上一级路由
(..)(..) - 拦截上两级路由
(...) - 拦截根路由
经典场景:Instagram 式图片预览
app/
├── layout.tsx
├── page.tsx # 首页(图片列表)
├── @modal/
│ ├── default.tsx
│ └── (.)photo/[id]/ # 拦截 /photo/[id]
│ └── page.tsx # 模态框显示
└── photo/[id]/
└── page.tsx # 完整页面
// 首页点击图片
// 1. URL 变为 /photo/123
// 2. 但由于 @modal/(.)photo/[id] 拦截
// 3. 实际显示的是模态框,而不是完整页面
// app/@modal/(.)photo/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
export default function PhotoModal({ params }) {
const router = useRouter();
return (
<div className="modal-overlay" onClick={() => router.back()}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<PhotoDetail id={params.id} />
</div>
</div>
);
}
// 如果用户直接访问 /photo/123(刷新或直接链接)
// 拦截不生效,显示完整页面
// app/photo/[id]/page.tsx
export default async function PhotoPage({ params }) {
const photo = await getPhoto(params.id);
return (
<div className="photo-full-page">
<PhotoDetail photo={photo} />
<Comments photoId={params.id} />
</div>
);
}
第三部分:布局与性能
3.1 布局的缓存行为
// 布局不会在导航时重新渲染
// 这是关键的性能优化
// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }) {
console.log('Dashboard layout rendered'); // 只在首次打印
const user = await getUser();
return (
<div>
<UserInfo user={user} />
{children}
</div>
);
}
// 场景:从 /dashboard 导航到 /dashboard/settings
// - 布局不重新渲染
// - getUser() 不会重新调用
// - UserInfo 保持不变
// - 只有 children 部分更新
潜在问题与解决方案:
// 问题:布局数据可能过时
// 用户信息在其他地方更新了,但布局不会自动刷新
// 解决方案 1:使用 revalidate
export const revalidate = 60; // 60秒后重新验证
// 解决方案 2:客户端状态
'use client'
function UserInfoClient() {
const { data: user } = useSWR('/api/me');
return <UserInfo user={user} />;
}
// 解决方案 3:Server Actions + revalidatePath
'use server'
async function updateUser(formData) {
await updateUserInDB(formData);
revalidatePath('/dashboard'); // 强制重新渲染布局
}
3.2 流式渲染与 Suspense
// 布局中使用 Suspense 实现流式加载
// app/dashboard/layout.tsx
import { Suspense } from 'react'
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 异步加载侧边栏 */}
</Suspense>
<main>
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader /> {/* 异步加载头部 */}
</Suspense>
{children}
</main>
</div>
);
}
// Sidebar 组件可以是 async
async function Sidebar() {
const menuItems = await getMenuItems();
const notifications = await getNotifications();
return (
<aside>
<Menu items={menuItems} />
<NotificationBadge count={notifications.unread} />
</aside>
);
}
3.3 条件布局
// 根据条件渲染不同布局
// app/(dashboard)/layout.tsx
import { headers } from 'next/headers'
export default function DashboardLayout({ children }) {
const headersList = headers();
const isMobile = headersList.get('user-agent')?.includes('Mobile');
if (isMobile) {
return (
<div className="mobile-dashboard">
<MobileNav />
{children}
<MobileBottomBar />
</div>
);
}
return (
<div className="desktop-dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
// 注意:这种方式不如 CSS 媒体查询灵活
// 建议:使用响应式 CSS,或客户端检测 + 条件渲染
第四部分:实战模式
4.1 认证布局模式
// app/(authenticated)/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function AuthenticatedLayout({ children }) {
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<SessionProvider session={session}>
<div className="authenticated-app">
<AppHeader user={session.user} />
{children}
</div>
</SessionProvider>
);
}
// app/(public)/layout.tsx
export default function PublicLayout({ children }) {
return (
<div className="public-site">
<PublicHeader />
{children}
<PublicFooter />
</div>
);
}
4.2 多主题布局
// app/[theme]/layout.tsx
import { notFound } from 'next/navigation'
const themes = ['light', 'dark', 'system'];
export default function ThemeLayout({ children, params }) {
if (!themes.includes(params.theme)) {
notFound();
}
return (
<div className={`theme-${params.theme}`}>
{children}
</div>
);
}
// 生成静态参数
export function generateStaticParams() {
return themes.map(theme => ({ theme }));
}
4.3 多租户/多语言布局
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation'
import { getLocaleData } from '@/lib/i18n'
const locales = ['en', 'zh', 'ja'];
export default async function LocaleLayout({ children, params }) {
if (!locales.includes(params.locale)) {
notFound();
}
const messages = await getLocaleData(params.locale);
return (
<html lang={params.locale}>
<body>
<IntlProvider locale={params.locale} messages={messages}>
<Header />
{children}
<Footer />
</IntlProvider>
</body>
</html>
);
}
export function generateStaticParams() {
return locales.map(locale => ({ locale }));
}
4.4 仪表盘布局完整示例
// 完整的仪表盘布局架构
// app/(dashboard)/layout.tsx
export default async function DashboardRootLayout({ children }) {
const user = await getUser();
return (
<div className="dashboard-root">
<GlobalNav user={user} />
{children}
</div>
);
}
// app/(dashboard)/[workspace]/layout.tsx
export default async function WorkspaceLayout({ children, params }) {
const workspace = await getWorkspace(params.workspace);
if (!workspace) {
notFound();
}
return (
<WorkspaceProvider workspace={workspace}>
<div className="workspace-layout">
<WorkspaceSidebar />
<div className="workspace-main">
<WorkspaceHeader />
{children}
</div>
</div>
</WorkspaceProvider>
);
}
// app/(dashboard)/[workspace]/projects/layout.tsx
export default function ProjectsLayout({ children }) {
return (
<div className="projects-layout">
<ProjectsNav />
{children}
</div>
);
}
// 最终结构:
// /acme-corp/projects/web-app
// - DashboardRootLayout (全局导航)
// - WorkspaceLayout (工作区侧边栏)
// - ProjectsLayout (项目导航)
// - ProjectPage (项目内容)
第五部分:常见问题与解决方案
5.1 布局状态共享
// 问题:布局中的数据如何与页面共享?
// 方案 1:Context(客户端状态)
// app/layout.tsx
'use client'
export default function Layout({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 方案 2:Server 数据通过 props(不推荐,Next.js 不支持)
// 布局无法直接向 page 传递 props
// 方案 3:重复获取 + 缓存
// 利用 fetch 缓存,多次调用相同请求不会重复执行
async function getData() {
return fetch('/api/data', { cache: 'force-cache' });
}
// layout.tsx
const data = await getData(); // 第一次请求
// page.tsx
const data = await getData(); // 从缓存获取,不重复请求
5.2 动态导入与代码分割
// 布局中的重型组件应该动态导入
import dynamic from 'next/dynamic'
const HeavySidebar = dynamic(() => import('@/components/HeavySidebar'), {
loading: () => <SidebarSkeleton />,
});
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false } // 仅客户端加载
);
export default function Layout({ children }) {
return (
<div>
<HeavySidebar />
{children}
</div>
);
}
5.3 布局错误处理
// app/dashboard/error.tsx
'use client'
export default function DashboardError({ error, reset }) {
return (
<div className="error-container">
<h2>仪表盘加载失败</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>重试</button>
</div>
);
}
// 注意:error.tsx 不能捕获同级 layout.tsx 的错误
// 需要在父级目录创建 error.tsx 来捕获布局错误
// app/error.tsx - 捕获 app/layout.tsx 的错误
// app/dashboard/error.tsx - 捕获 app/dashboard/layout.tsx 的错误
结语:布局是架构的体现
Next.js 的布局系统看似简单——不过是嵌套的组件。但真正理解和用好它,需要理解其背后的设计理念:
- 层次化结构:反映应用的逻辑层次
- 状态隔离:每层布局管理自己的状态
- 渐进式加载:布局稳定,内容变化
- 代码复用:共享布局,减少重复
好的布局设计,是应用架构清晰的体现。花时间规划你的布局结构,会在后续开发中节省大量时间。


