第 4 章

Layout、Template 与嵌套布局系统

第4章:Layout、Template 与嵌套布局系统

布局的核心价值是导航时的持久性——组件在路由切换时不重新挂载,状态保持,性能卓越。但有时你又需要刻意重新挂载,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.tsxlayout.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 树传入 ProvidersProviders 并不需要重新渲染它们。

这是 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>
  );
}

这个架构实现了:

Level 4 · 边界与陷阱(所有人)

陷阱1:Layout 中的状态在路由切换时会保持,这既是优势也可能是隐患——如果某个状态应该在页面切换后重置(如表单内容),需要使用 Template 而非 Layout。

陷阱2:嵌套布局中,认证检查放在 (app)/layout.tsx 可以保护所有子路由,但如果认证状态改变(如 token 过期),Layout 不会重新渲染——需要配合 Middleware 做路由级守卫。

陷阱3:Providers(客户端组件)包裹服务器组件的 children 时,children 仍然是服务器渲染的——不要因此将所有东西都变成客户端组件。

小结

Layout 的核心价值是导航时的持久性——布局组件在同级路由间切换时不重新挂载,状态保持,性能卓越。嵌套布局将 UI 关注点分层,认证、全局状态、局部布局各司其职。Template 提供了一个"刻意重新挂载"的出口,用于页面动画、状态重置和追踪事件等场景。服务器布局 + 客户端 Context Provider 的组合,让全局状态既能在服务端初始化,又能在客户端共享,是 App Router 应用的标准架构模式。

本章评分
4.5  / 5  (72 评分)

💬 留言讨论