Next.js: 布局系统与模板
最后更新:2026-08-26
布局系统就像建筑物的楼层——每个楼层(layout)有共享的走廊和设施,而每个房间(page)可以有不同的装饰,空间使用者的状态在楼层间走动时不会丢失。
1. 你将学到
- 嵌套布局(nested layouts)的持久化行为与配置方式
- Route Groups
(group)的逻辑分组与 URL 影响 - Layout vs Template 的核心区别与选择场景
- 根布局(root layout)的必知配置:
<html>、<body>、字体、metadata - 布局间数据共享的三种模式
2. 一个全栈开发者的真实故事
(1) 痛点:每个页面都要重复写导航栏和侧边栏
Alice 在开发 TaskFlow 管理后台时遇到了布局代码重复的问题:
"我们团队有 5 个开发者各自写不同的页面。每个人都要在自己的 page.tsx 里手动 import 导航栏和侧边栏。上周 Charlie 忘记在新建的
settings/page.tsx里加侧边栏,用户点了设置页突然看不到菜单了,以为是导航坏了。"
代码重复情况:
| 问题 | 影响 | 涉及页面数 |
|---|---|---|
| 导航栏重复 import | 每个页面都要手动包含 | 15 个页面 |
| 侧边栏状态不持久 | 导航后侧边栏选中状态丢失 | 全部页面 |
| 登录/注册页显示导航 | 不应该显示,需要额外条件判断 | 3 个页面 |
| 用户信息传递复杂 | 每个页面都要 fetch 用户数据 | 12 个页面 |
(2) Next.js 布局系统的解法
用嵌套 layout + Route Groups 实现布局隔离,一次定义全局生效。
src/app/
├── layout.tsx # 根布局(html/body/字体)
├── page.tsx # 首页
├── (auth)/
│ ├── layout.tsx # 登录/注册布局(无侧边栏)
│ ├── login/page.tsx
│ └── register/page.tsx
└── (dashboard)/
├── layout.tsx # 管理后台布局(导航+侧边栏)
├── page.tsx # 仪表盘
├── projects/page.tsx # 项目列表
└── settings/page.tsx # 设置页
(3) 收益
| 维度 | 之前(手动 import) | 之后(布局系统) |
|---|---|---|
| 导航栏引用 | 15 行 import × 15 页 | 1 个 layout.tsx |
| 侧边栏选中状态 | 丢失 | 持久保持 |
| 登录/注册布局 | 条件判断 | Route Groups 自然隔离 |
| 用户数据获取 | 12 次 fetch | 1 次 layout 共享 |
3. 嵌套布局原理
(1) 布局的持久化行为
graph TB
subgraph "页面导航"
A[root layout] --> B[dashboard layout]
B --> C[page 仪表盘]
B --> D[page 项目列表]
B --> E[page 设置]
end
subgraph "导航时行为"
F[layout 保持挂载<br/>状态不丢失]
G[page 卸载/重新挂载<br/>内容替换]
end
style F fill:#d4edda
style G fill:#f8d7da
| 行为 | layout | page |
|---|---|---|
| 导航时重新挂载 | ❌ 不重新挂载 | ✅ 重新挂载 |
| React 状态保持 | ✅ 保持 | ❌ 重置 |
| useEffect 重新运行 | ❌ 不运行 | ✅ 运行 |
| 数据重新获取 | ❌ 不获取 | ✅ 重新获取 |
▶ 示例:布局持久化演示
// ============================================
// 演示布局持久化 vs 页面重新挂载
// ============================================
// src/app/(dashboard)/layout.tsx — 侧边栏布局
'use client';
import { useState } from "react";
import Link from "next/link";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarState, setSidebarState] = useState("collapsed");
return (
<div className="flex h-screen">
{/* 侧边栏 — 导航时保持展开/折叠状态 */}
<aside className={`bg-gray-800 text-white ${sidebarState === "collapsed" ? "w-16" : "w-64"} transition-all`}>
<button
onClick={() => setSidebarState(s =>
s === "collapsed" ? "expanded" : "collapsed"
)}
className="p-4 hover:bg-gray-700 w-full text-left"
>
{sidebarState === "collapsed" ? "→" : "← Collapse"}
</button>
<nav className="mt-4">
<Link href="/dashboard" className="block p-3 hover:bg-gray-700">Dashboard</Link>
<Link href="/dashboard/projects" className="block p-3 hover:bg-gray-700">Projects</Link>
<Link href="/dashboard/settings" className="block p-3 hover:bg-gray-700">Settings</Link>
</nav>
<div className="mt-4 p-3 text-sm text-gray-400">
Sidebar state persists across navigation
</div>
</aside>
<main className="flex-1 p-8 overflow-auto">
{children}
</main>
</div>
);
}
// src/app/(dashboard)/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-600">Welcome to your dashboard.</p>
</div>
);
}
// src/app/(dashboard)/dashboard/projects/page.tsx
export default function ProjectsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="text-gray-600">Your project list goes here.</p>
</div>
);
}
输出:
1. 访问 /dashboard,侧边栏展开,显示 Dashboard 内容
2. 点击 "→ Collapse",侧边栏收缩为 64px
3. 点击 "Projects"链接,导航到 /dashboard/projects
4. 侧边栏保持收缩状态(没有重置为展开)✅
5. 页面内容从 Dashboard 变为 Projects ✅
(2) 嵌套布局的层级
graph TB
A[root layout] --> B[app/layout.tsx]
B --> C[(dashboard) layout]
C --> D[app/(dashboard)/layout.tsx]
D --> E[products layout]
E --> F[app/(dashboard)/products/layout.tsx]
F --> G[page]
G --> H[app/(dashboard)/products/page.tsx]
style B fill:#cce5ff
style D fill:#d4edda
style F fill:#f8d7da
| 布局层级 | 包裹范围 | 共享内容 |
|---|---|---|
| 根布局 | 所有页面 | <html>、<body>、全局字体、全局样式 |
| Group 布局 | 分组内页面 | 侧边栏、导航栏、用户信息 |
| 嵌套布局 | 子目录页面 | 子导航、面包屑、局部过滤条 |
▶ 示例:三层嵌套布局
// ============================================
// 三层嵌套布局:全局 → 后台 → 产品管理
// ============================================
// 第1层:src/app/layout.tsx — 根布局
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="bg-gray-50">
{children}
</body>
</html>
);
}
// 第2层:src/app/(dashboard)/layout.tsx — 后台布局
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
);
}
// 第3层:src/app/(dashboard)/products/layout.tsx — 产品布局
export default function ProductsLayout({ children }) {
return (
<div>
<nav className="flex gap-4 border-b pb-2 mb-4">
<a href="/products" className="text-blue-600">All Products</a>
<a href="/products/add" className="text-blue-600">Add Product</a>
<a href="/products/categories" className="text-blue-600">Categories</a>
</nav>
{children}
</div>
);
}
输出:
访问 /dashboard/products:
→ 根布局渲染 <html><body>
→ 后台布局渲染 <Sidebar> + <main>
→ 产品布局渲染 产品子导航 + 页面内容
三层嵌套全部生效
4. Route Groups (group)
(1) 什么是 Route Groups
(group) 目录在 URL 中不产生路径段,只用来逻辑分组。
graph LR
subgraph "文件结构"
A[app] --> B[(auth)]
A --> C[(dashboard)]
B --> D[login/page.tsx]
B --> E[register/page.tsx]
C --> F[page.tsx]
C --> G[settings/page.tsx]
end
subgraph "对应 URL"
H[/login]
I[/register]
J[/dashboard]
K[/dashboard/settings]
end
style B fill:#f8d7da
style C fill:#d4edda
| 目录 | URL 路径 | 说明 |
|---|---|---|
app/(auth)/login/page.tsx |
/login |
(auth) 不产生路径段 |
app/(auth)/register/page.tsx |
/register |
(auth) 不产生路径段 |
app/(dashboard)/page.tsx |
/dashboard |
(dashboard) 不产生路径段 |
app/(dashboard)/settings/page.tsx |
/dashboard/settings |
子路径正常 |
▶ 示例:Route Groups 实现不同布局
// ============================================
// 使用 Route Groups 隔离登录页和管理后台的布局
// ============================================
// src/app/(auth)/layout.tsx — 登录/注册布局(无导航、无侧边栏)
export default function AuthLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">TaskFlow</h1>
<p className="text-gray-500">Collaborate and deliver</p>
</div>
<div className="bg-white p-8 rounded-xl shadow-sm">
{children}
</div>
</div>
</div>
);
}
// src/app/(auth)/login/page.tsx
export default function LoginPage() {
return (
<form className="space-y-4">
<h2 className="text-xl font-bold text-center">Sign In</h2>
<input
type="email"
placeholder="Email"
className="w-full p-3 border rounded-lg"
/>
<input
type="password"
placeholder="Password"
className="w-full p-3 border rounded-lg"
/>
<button
type="submit"
className="w-full p-3 bg-blue-600 text-white rounded-lg"
>
Sign In
</button>
</form>
);
}
// src/app/(dashboard)/layout.tsx — 管理后台布局(侧边栏 + 顶部导航)
export default function DashboardLayout({ children }) {
return (
<div className="flex h-screen">
<aside className="w-64 bg-gray-900 text-white">
<div className="p-4 text-xl font-bold">TaskFlow</div>
<nav className="mt-4">
<a href="/dashboard" className="block p-3 hover:bg-gray-800">
Dashboard
</a>
<a href="/dashboard/projects" className="block p-3 hover:bg-gray-800">
Projects
</a>
<a href="/dashboard/settings" className="block p-3 hover:bg-gray-800">
Settings
</a>
</nav>
</aside>
<div className="flex-1 flex flex-col">
<header className="bg-white shadow-sm p-4">
<input
type="search"
placeholder="Search..."
className="w-64 p-2 border rounded"
/>
</header>
<main className="flex-1 p-8 overflow-auto">{children}</main>
</div>
</div>
);
}
输出:
访问 /login 看到:
- 居中表单设计(无导航栏、无侧边栏)
- 蓝色渐变背景
- TaskFlow 品牌 Logo + Sign In 表单
访问 /dashboard 看到:
- 左侧深色侧边栏(Dashboard / Projects / Settings)
- 顶部白色搜索栏
- 右侧内容区域
5. Layout vs Template
(1) 核心区别
graph LR
A[导航到新页面] --> B{layout 还是 template?}
B -->|layout| C[保持挂载<br/>状态持久]
B -->|template| D[卸载重建<br/>重新挂载]
C --> E[子组件更新]
D --> F[子组件 + 包裹容器都重建]
style C fill:#d4edda
style D fill:#f8d7da
| 特性 | layout.tsx |
template.tsx |
|---|---|---|
| 导航时重新挂载 | ❌ 保持挂载 | ✅ 卸载 + 重新挂载 |
| React 状态保持 | ✅ 保持 | ❌ 重置 |
| useEffect 重新运行 | ❌ 不运行 | ✅ 运行 |
| 页面过渡动画 | 不适合 | ✅ 适合 |
| 刷新数据 | ❌ 不刷新 | ✅ 每次导航刷新 |
| 性能 | 更优 | 略差(重建开销) |
▶ 示例:Layout vs Template 行为对比
// ============================================
// layout 和 template 行为对比演示
// ============================================
// src/app/(dashboard)/layout.tsx — 使用 layout(导航时保持)
'use client';
import { useEffect, useState } from "react";
import Link from "next/link";
export default function DashboardLayout({ children }) {
const [count, setCount] = useState(0);
const [mountTime] = useState(new Date().toLocaleTimeString());
useEffect(() => {
console.log("Layout mounted at:", new Date().toLocaleTimeString());
}, []);
return (
<div className="border-2 border-blue-500 p-4 rounded m-4">
<div className="text-sm text-blue-600 mb-2">
[LAYOUT] Mounted at: {mountTime} | Count: {count}
<button onClick={() => setCount(c => c + 1)} className="ml-2 px-2 bg-blue-100 rounded">
+1
</button>
</div>
<nav className="flex gap-4 mb-4">
<Link href="/dashboard/page-a" className="text-blue-600">Page A</Link>
<Link href="/dashboard/page-b" className="text-blue-600">Page B</Link>
</nav>
{children}
</div>
);
}
// ============================================
// template.tsx — 导航时重建
// 文件:src/app/(dashboard)/template.tsx
// ============================================
'use client';
import { useEffect, useState } from "react";
import Link from "next/link";
export default function DashboardTemplate({ children }) {
const [count, setCount] = useState(0);
const [mountTime] = useState(new Date().toLocaleTimeString());
useEffect(() => {
console.log("Template mounted at:", new Date().toLocaleTimeString());
}, []);
return (
<div className="border-2 border-red-500 p-4 rounded m-4">
<div className="text-sm text-red-600 mb-2">
[TEMPLATE] Mounted at: {mountTime} | Count: {count}
<button onClick={() => setCount(c => c + 1)} className="ml-2 px-2 bg-red-100 rounded">
+1
</button>
</div>
<nav className="flex gap-4 mb-4">
<Link href="/dashboard/page-a" className="text-red-600">Page A</Link>
<Link href="/dashboard/page-b" className="text-red-600">Page B</Link>
</nav>
{children}
</div>
);
}
// src/app/(dashboard)/page-a.tsx
export default function PageA() {
return <div className="text-lg">Page A Content</div>;
}
// src/app/(dashboard)/page-b.tsx
export default function PageB() {
return <div className="text-lg">Page B Content</div>;
}
输出:
1. 访问 /dashboard/page-a:
[LAYOUT] Mounted at: 10:30:00 | Count: 0
[TEMPLATE] Mounted at: 10:30:00 | Count: 0
Page A Content
2. 点击 Count +1 按钮(两个容器的 Count 各自+1)
3. 点击 "Page B" 链接:
[LAYOUT] Mounted at: 10:30:00 | Count: 1 ← layout 保持,状态保留
[TEMPLATE] Mounted at: 10:30:05 | Count: 0 ← template 重建,状态重置
Page B Content
结论:layout 保持挂载和状态,template 每次导航重建
(2) 何时使用 template
| 场景 | 推荐 | 原因 |
|---|---|---|
| 页面过渡动画 | template | framer-motion 出口/入口动画 |
| 每次导航要刷新数据 | template | useEffect 重新运行 |
| 导航分析追踪 | template | 每次导航触发追踪代码 |
| 导航栏/侧边栏 | layout | 保持选中状态和展开/折叠 |
| 购物车/播放器 | layout | 持久的 UI 状态 |
▶ 示例:template 实现页面过渡动画
// ============================================
// template + framer-motion 实现页面过渡动画
// ============================================
'use client';
import { motion } from "framer-motion";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}
输出:
每次页面导航:
1. 旧页面向上淡出(opacity: 1→0, y: 0→-20)
2. 新页面从下方淡入(opacity: 0→1, y: 20→0)
3. 动画时长 300ms
4. layout 保持不重建,动画只作用于内容区域
6. 根布局必知配置
(1) 根布局职责
根布局是唯一必须的布局文件,定义了所有页面的外壳:
| 配置项 | 代码 | 说明 |
|---|---|---|
<html> 标签 |
<html lang="en"> |
语言属性,影响 SEO |
<body> 标签 |
<body className="..."> |
全局 CSS 类 |
| 字体加载 | next/font/google |
性能优化的字体 |
| Metadata | export const metadata |
全局 SEO 元数据 |
| 全局样式 | import './globals.css' |
Tailwind 指令 |
▶ 示例:完整的根布局
// ============================================
// 根布局 — 完整配置
// 文件:src/app/layout.tsx
// ============================================
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
// Google 字体优化(自动 preload + CSS size-adjust)
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
// 全局 SEO metadata
export const metadata: Metadata = {
title: {
template: "%s | TaskFlow",
default: "TaskFlow - Project Management",
},
description: "A collaborative project management platform",
openGraph: {
title: "TaskFlow",
description: "Collaborate and deliver projects faster",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.variable}>
<body className="antialiased bg-gray-50 text-gray-900 min-h-screen">
{children}
</body>
</html>
);
}
输出:
所有页面自动获得:
- Inter 字体(性能优化,无 CLS)
- 全局 CSS 类(antialiased, bg-gray-50, text-gray-900)
- SEO metadata(title 模板 "Page | TaskFlow")
- Open Graph 标签(社交媒体分享卡片)
7. 布局数据共享模式
(1) 三种共享模式
graph TB
subgraph "数据共享模式"
A[1. Props 传递<br/>layout → page]
B[2. Context Provider<br/>全局状态]
C[3. 并行数据获取<br/>layout + page 各自获取]
end
style A fill:#d4edda
style B fill:#cce5ff
style C fill:#f8d7da
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Props 传递 | layout 获取数据传给 page | 类型安全 | 仅向下传递一级 |
| Context Provider | 用户信息、主题 | 全局可用 | Client Component 必需 |
| 并行数据获取 | layout 和 page 独立数据 | 解耦、并发 | 无法直接共享 |
▶ 示例:三种数据共享模式
// ============================================
// 三种布局数据共享模式完整示例
// ============================================
// 模式1: layout 获取数据,通过 children 无法直接传 props
// 解决:使用 "平行数据获取" — layout 自己拿自己的数据
// src/app/(dashboard)/layout.tsx
// 模式2: Context Provider — 用户信息全局共享
// src/app/(dashboard)/layout.tsx
'use client';
import { createContext, useContext } from "react";
const UserContext = createContext(null);
export function useUser() {
return useContext(UserContext);
}
export default function DashboardLayout({ children }) {
const user = { name: "Alice", role: "admin" }; // 模拟从 API 获取
return (
<UserContext.Provider value={user}>
<div className="flex">
<aside className="w-64 bg-gray-900 text-white p-4">
<p>Welcome, {user.name}</p>
<nav className="mt-4">
<a href="/dashboard" className="block py-2">Dashboard</a>
<a href="/dashboard/projects" className="block py-2">Projects</a>
</nav>
</aside>
<main className="flex-1 p-8">{children}</main>
</div>
</UserContext.Provider>
);
}
// 模式3: page 组件使用 Context 数据
// src/app/(dashboard)/page.tsx
'use client';
import { useUser } from "./layout";
export default function DashboardPage() {
const user = useUser();
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-600">
Welcome back, {user.name}! You are logged in as {user.role}.
</p>
</div>
);
}
输出:
访问 /dashboard:
侧边栏显示: Welcome, Alice
主要内容显示: Welcome back, Alice! You are logged in as admin.
用户数据通过 Context 在 layout 和 page 之间共享
无需每个页面都 fetch 用户信息
8. 完整示例:TaskFlow 完整布局系统
// ============================================
// 综合示例:TaskFlow 完整布局系统
// 根布局 + Auth 布局 + Dashboard 布局 + 产品布局
// ============================================
// src/app/layout.tsx — 根布局
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export const metadata: Metadata = {
title: { template: "%s | TaskFlow", default: "TaskFlow" },
description: "Project management platform",
};
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body className="bg-gray-50 antialiased">{children}</body>
</html>
);
}
// src/app/(auth)/layout.tsx — Auth 布局
export default function AuthLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-700">
<div className="w-full max-w-md">
<div className="text-center text-white mb-8">
<h1 className="text-4xl font-bold">TaskFlow</h1>
<p className="text-blue-200 mt-2">Collaborate and deliver</p>
</div>
<div className="bg-white rounded-xl shadow-2xl p-8">
{children}
</div>
</div>
</div>
);
}
// src/app/(dashboard)/layout.tsx — Dashboard 布局
'use client';
import { createContext, useContext, useState } from "react";
import Link from "next/link";
const DashboardContext = createContext(null);
export function useDashboard() { return useContext(DashboardContext); }
export default function DashboardLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const user = { name: "Alice", role: "Admin", avatar: "/avatar.png" };
return (
<DashboardContext.Provider value={{ user, sidebarOpen, setSidebarOpen }}>
<div className="flex h-screen">
<aside className={`bg-gray-900 text-white ${sidebarOpen ? "w-64" : "w-16"} transition-all duration-300`}>
<div className="p-4 flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-sm">
{user.name[0]}
</div>
{sidebarOpen && <span className="font-bold">TaskFlow</span>}
</div>
<nav className="mt-4">
<Link href="/dashboard" className="flex items-center gap-3 p-3 hover:bg-gray-800">
<span>📊</span>
{sidebarOpen && <span>Dashboard</span>}
</Link>
<Link href="/dashboard/projects" className="flex items-center gap-3 p-3 hover:bg-gray-800">
<span>📁</span>
{sidebarOpen && <span>Projects</span>}
</Link>
<Link href="/dashboard/team" className="flex items-center gap-3 p-3 hover:bg-gray-800">
<span>👥</span>
{sidebarOpen && <span>Team</span>}
</Link>
<Link href="/dashboard/settings" className="flex items-center gap-3 p-3 hover:bg-gray-800">
<span>⚙️</span>
{sidebarOpen && <span>Settings</span>}
</Link>
</nav>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="absolute bottom-4 left-4 text-gray-400 hover:text-white"
>
{sidebarOpen ? "◀" : "▶"}
</button>
</aside>
<div className="flex-1 flex flex-col">
<header className="bg-white shadow-sm px-8 py-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-700">
Welcome, {user.name}
</h2>
<div className="flex items-center gap-4">
<button className="text-gray-500">🔔</button>
<div className="w-8 h-8 bg-gray-300 rounded-full" />
</div>
</header>
<main className="flex-1 p-8 overflow-auto">{children}</main>
</div>
</div>
</DashboardContext.Provider>
);
}
// src/app/(dashboard)/dashboard/page.tsx — 仪表盘首页
'use client';
import { useDashboard } from "../layout";
export default function DashboardHomePage() {
const { user } = useDashboard();
return (
<div>
<h1 className="text-3xl font-bold">Dashboard Overview</h1>
<p className="text-gray-500 mt-2">
Welcome back, {user.name}! Here is your project summary.
</p>
<div className="grid grid-cols-3 gap-6 mt-8">
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="text-sm text-gray-500">Active Projects</div>
<div className="text-3xl font-bold mt-2">12</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="text-sm text-gray-500">Pending Tasks</div>
<div className="text-3xl font-bold mt-2">48</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm">
<div className="text-sm text-gray-500">Team Members</div>
<div className="text-3xl font-bold mt-2">8</div>
</div>
</div>
</div>
);
}
预期输出:
访问 /login:
- 深蓝色渐变全屏背景
- 居中白色表单卡片
- TaskFlow Logo + 登录表单
访问 /dashboard:
- 左侧深色侧边栏(可折叠,导航时保持状态)
- 顶部导航栏(显示 Welcome, Alice + 通知图标)
- 右侧内容区(Dashboard Overview + 3 个统计卡片)
- 用户数据通过 Context 共享
❓ 常见问题
(auth)/login/page.tsx 的 URL 仍然是 /login,(auth) 只影响布局结构,不产生 URL 路径段。这是 Route Groups 的核心用途。<html> 标签可以修改吗?lang、dir、className 等属性。这是 RTL 支持的关键。UserProvider、ThemeProvider 等,所有 Route Groups 内的页面都能访问。这是 Next.js 推荐的全局数据共享方式。📖 小节
- 嵌套 layout 在导航时保持挂载,React 状态不丢失
- Route Groups
(group)不产生 URL 路径段,只用于逻辑分组和布局隔离 - Layout 保持挂载适合持久 UI,template 重建适合动画和数据刷新
- 根 layout 是所有页面的外壳,必须包含
<html>、<body>和 metadata - Context Provider 是 layout 间数据共享的推荐方式
- Route Groups 最常用场景:登录页(无导航)vs 管理后台(有导航)的布局隔离
- layout 默认 Server Component,需要交互时添加 'use client'
📝 作业
-
基础题(⭐):在项目中创建
(marketing)/layout.tsx和(app)/layout.tsx两个 Route Group 布局,分别给营销页面(首页、关于)和应用页面(仪表盘、设置)不同的样式。 -
进阶题(⭐⭐):在同一个目录下同时放置 layout.tsx 和 template.tsx,分别在两个文件中添加
useEffect(() => { console.log('mounted') }, []),导航时观察控制台输出,验证两者的挂载行为差异。 -
挑战题(⭐⭐⭐):在根 layout 中创建一个 UserProvider (Context),在
(auth)/login/page.tsx和(dashboard)/page.tsx中分别使用useUser()读取用户数据,验证 Context 跨 Route Groups 的数据共享。