Layout、Template 与嵌套布局系统
第4章:Layout、Template 与嵌套布局系统
布局的核心价值是导航时的持久性——组件在路由切换时不重新挂载,状态保持,性能卓越。但有时你又需要刻意重新挂载,Template 就是为此而生。
本章核心问题:Layout 和 Template 的持久性有什么区别?嵌套布局如何工作?如何在布局中管理共享状态?
读完本章你将理解:
- Layout 在路由导航时真正零重渲染的机制
- 嵌套布局的分层组合模式与认证守卫的实现
- Template 的使用场景:页面动画、状态重置、页面级追踪
Level 1 · 你需要知道的(1-3年经验)
布局的本质:持久的 UI 边框
在 Web 开发中,有一类 UI 是"不变的"——导航栏、侧边栏、页脚、认证上下文。每次页面导航都重新渲染这些内容是浪费的:它们不需要重新挂载,不需要重新请求数据,不需要重新执行动画。
这正是 layout.tsx 的设计意图:在路由切换时保持不重新渲染。
Next.js 是如何做到这一点的?通过 React 的协调(reconciliation)机制。当路由从 /dashboard 切换到 /dashboard/settings 时,Next.js 识别出这两个路由共享相同的布局组件,因此保持布局组件的 React 实例不变,只替换 children 对应的子树。从 DOM 的角度看,布局的 DOM 节点从未被移除或重新创建。
这个机制有一个重要含义:layout 中的状态(useState、useRef 等)在路由导航时会保持。如果布局中有一个展开/折叠的侧边栏,用户在页面间导航时它的状态会被保留。
根布局:应用的外壳
app/layout.tsx 是整个应用的根布局,也是唯一强制性要求的约定文件。它有一个特殊职责:必须渲染 <html> 和 <body> 标签,因为 Next.js 不会自动添加它们。
// app/layout.tsx — 根布局的标准形态
import type { Metadata } from 'next';
import { Inter, Noto_Sans_SC } from 'next/font/google';
import { Providers } from './providers';
import './globals.css';
// next/font 在构建时下载字体并生成 CSS 变量
// 字体文件由 Next.js 自托管,不会产生对 Google 的外部请求
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const notoSansSC = Noto_Sans_SC({
subsets: ['chinese-simplified'],
weight: ['400', '500', '700'],
variable: '--font-noto',
display: 'swap',
});
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
template: '%s | My App',
default: 'My App',
},
description: 'A Next.js 15 full-stack application',
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={`${inter.variable} ${notoSansSC.variable}`}>
<body>
{/* Providers 是客户端组件,包裹需要 context 的部分 */}
<Providers>{children}</Providers>
</body>
</html>
);
}
根布局有几个重要特征:
它是服务器组件(除非你明确加 'use client')。这意味着你可以在根布局中直接执行服务端操作,比如读取会话、查询全局配置。
字体优化是内置的。next/font/google 在构建时下载字体文件,通过自托管服务,避免用户浏览器向 Google CDN 发出请求,提升隐私保护和加载速度。
元数据模板。title.template 中的 %s 会被子页面的 metadata.title 替换。子页面定义 title: 'Dashboard',最终渲染为 <title>Dashboard | My App</title>。
Level 2 · 它是怎么运行的(3-5年经验)
嵌套布局:组合胜于继承
Next.js 的布局系统是分层嵌套的。每个目录都可以有自己的 layout.tsx,子目录的布局嵌套在父目录的布局之内:
app/
├── layout.tsx # 层级 1:根布局(html, body, 全局导航)
├── (app)/
│ └── layout.tsx # 层级 2:应用布局(侧边栏,认证守卫)
│ └── dashboard/
│ └── layout.tsx # 层级 3:仪表盘布局(仪表盘特定头部)
│ └── page.tsx # 层级 4:页面内容
访问 /dashboard 时,渲染树是:
RootLayout
└── AppLayout
└── DashboardLayout
└── DashboardPage
每一层布局只关注自己那一层的 UI,不关心内层的具体实现。这是组合模式的经典应用:每层都是独立的、可替换的,整体复杂度分摊在各层之间。
// app/(app)/layout.tsx — 认证守卫 + 应用外壳
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { Sidebar } from '@/components/Sidebar';
import { TopBar } from '@/components/TopBar';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
// 在布局层做认证检查,所有子路由自动受保护
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<div className="flex h-screen overflow-hidden">
<Sidebar user={session.user} />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
// app/(app)/dashboard/layout.tsx — 仪表盘特定布局
import { DashboardHeader } from './DashboardHeader';
import { DateRangePicker } from './DateRangePicker';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<DashboardHeader>
<DateRangePicker /> {/* 仪表盘特有的日期选择器 */}
</DashboardHeader>
<div className="mt-6">{children}</div>
</div>
);
}
嵌套布局的关键价值在于:认证逻辑、会话读取、全局状态 Context 都可以在最合适的层级处理,而不是在每个 page 中重复。/dashboard/settings、/dashboard/reports、/dashboard/users 都自动受到 (app)/layout.tsx 的认证保护,无需每个页面单独实现。
Layout 与 Pages Router _app.tsx 的根本区别
理解 App Router layout 的优势,最直接的方式是对比 Pages Router 的 _app.tsx:
_app.tsx 是全局单例。所有页面共享同一个 _app.tsx,无法针对不同路由区域设置不同的布局逻辑(除非在 _app.tsx 内部用条件判断,这很快变得混乱)。
_app.tsx 在每次导航时重新执行。虽然 React 会尽力复用 DOM 节点,但 _app.tsx 作为整体会在路由变化时重新渲染,包括其中的所有子组件。
App Router layout 是真正持久的。layout.tsx 在同级路由导航时,其 React 实例完全不变——不是"尽力复用",是真正的零重渲染。
App Router layout 可以嵌套。不同路由区域有各自的布局,按需组合,任何层级都可以有自己的数据获取和状态管理。
从性能角度,这个差异是显著的。一个 _app.tsx 中有导航栏、侧边栏、通知中心,Pages Router 在每次路由变化时都会重新渲染这三者。App Router 中,这些组件的 React 实例从不被重新创建——DOM 不变,状态不变,动画不中断。
Level 3 · 规范怎么定义的(资深)
Template:每次导航都重新创建的布局
template.tsx 是 layout.tsx 的变体,区别在于:每次导航到该路由时,template 都会创建新的实例。
app/
└── dashboard/
├── layout.tsx # 持久布局:导航时不重新挂载
├── template.tsx # 模板:导航时重新挂载
└── page.tsx
这个行为看起来像是退步——为什么要主动选择"重新渲染"?但在某些场景下,这正是需要的:
页面进入动画。如果你想在每次进入页面时播放一个淡入动画,layout 做不到(因为它不重新挂载),但 template 可以:
// app/dashboard/template.tsx
'use client';
import { motion } from 'framer-motion';
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}
每次重置的副作用。有些 UI 需要在每次访问时重置状态——例如,多步骤表单应该在用户离开并返回后从第一步开始。如果状态在 layout 中,它会被保持;放在 template 中,每次进入都从初始状态开始。
基于 useEffect 的页面级追踪。分析事件、页面浏览追踪,如果放在 layout 中,路由内部导航不会触发(因为 layout 不重新挂载)。放在 template 中,每次进入路由都会触发。
// app/(app)/template.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { trackPageView } from '@/lib/analytics';
export default function AppTemplate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
// 每次进入任何受保护页面时追踪页面浏览
trackPageView(pathname);
}, [pathname]);
return <>{children}</>;
}
Layout 和 Template 可以共存于同一目录——渲染顺序是 layout 在外,template 在内,page 在最内层:layout → template → page。
布局中的共享状态:Context 模式
布局是服务器组件,不能直接持有 React 状态。但它可以包裹客户端 Context Provider:
// app/providers.tsx — 客户端 Context 集中管理
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './theme-provider';
import { AuthProvider } from './auth-provider';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
// QueryClient 必须在客户端创建,避免跨请求共享状态
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 分钟内不重新请求
},
},
}));
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx 中使用
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
这个模式的关键点:Providers 是客户端组件,但它的 children 仍然可以是服务器组件。React 在这里采用了"将 children 作为插槽"的方式——服务器组件渲染好后,作为预渲染的 React 树传入 Providers,Providers 并不需要重新渲染它们。
这是 App Router 中一个重要的性能模式:用客户端组件包裹服务器组件,而不是将一切变成客户端组件。
导航栏 + 认证 Context 的完整模式
将本章所学综合应用,构建一个带认证的导航栏系统:
// app/(app)/layout.tsx — 顶层应用布局
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { NavBar } from '@/components/NavBar';
import { SessionProvider } from '@/components/SessionProvider';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) redirect('/login');
return (
// SessionProvider 是客户端组件,通过 context 共享 session
<SessionProvider initialSession={session}>
<NavBar />
<div className="pt-16"> {/* 为固定导航栏留出空间 */}
{children}
</div>
</SessionProvider>
);
}
// components/SessionProvider.tsx
'use client';
import { createContext, useContext } from 'react';
import type { Session } from '@/lib/auth';
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({
initialSession,
children,
}: {
initialSession: Session;
children: React.ReactNode;
}) {
return (
<SessionContext.Provider value={initialSession}>
{children}
</SessionContext.Provider>
);
}
export function useSession() {
const session = useContext(SessionContext);
if (!session) throw new Error('useSession must be used within SessionProvider');
return session;
}
// components/NavBar.tsx — 客户端组件,使用 context
'use client';
import Link from 'next/link';
import { useSession } from './SessionProvider';
export function NavBar() {
const { user } = useSession();
return (
<nav className="fixed top-0 w-full h-16 border-b bg-white flex items-center px-6">
<Link href="/dashboard" className="font-bold text-lg">
MyApp
</Link>
<div className="ml-auto flex items-center gap-4">
<span className="text-sm text-gray-600">{user.name}</span>
<UserMenu user={user} />
</div>
</nav>
);
}
这个架构实现了:
- 服务端一次读取 session,不重复请求
- 通过 Context 共享 session 到所有客户端子组件
- 布局持久,导航栏在路由切换时不重新挂载
- 认证守卫集中在布局层,子页面无需重复
Level 4 · 边界与陷阱(所有人)
陷阱1:Layout 中的状态在路由切换时会保持,这既是优势也可能是隐患——如果某个状态应该在页面切换后重置(如表单内容),需要使用 Template 而非 Layout。
陷阱2:嵌套布局中,认证检查放在 (app)/layout.tsx 可以保护所有子路由,但如果认证状态改变(如 token 过期),Layout 不会重新渲染——需要配合 Middleware 做路由级守卫。
陷阱3:Providers(客户端组件)包裹服务器组件的 children 时,children 仍然是服务器渲染的——不要因此将所有东西都变成客户端组件。
小结
Layout 的核心价值是导航时的持久性——布局组件在同级路由间切换时不重新挂载,状态保持,性能卓越。嵌套布局将 UI 关注点分层,认证、全局状态、局部布局各司其职。Template 提供了一个"刻意重新挂载"的出口,用于页面动画、状态重置和追踪事件等场景。服务器布局 + 客户端 Context Provider 的组合,让全局状态既能在服务端初始化,又能在客户端共享,是 App Router 应用的标准架构模式。