Next.js: 布局系统与模板

最后更新:2026-08-26

布局系统就像建筑物的楼层——每个楼层(layout)有共享的走廊和设施,而每个房间(page)可以有不同的装饰,空间使用者的状态在楼层间走动时不会丢失。

1. 你将学到


2. 一个全栈开发者的真实故事

(1) 痛点:每个页面都要重复写导航栏和侧边栏

Alice 在开发 TaskFlow 管理后台时遇到了布局代码重复的问题:

"我们团队有 5 个开发者各自写不同的页面。每个人都要在自己的 page.tsx 里手动 import 导航栏和侧边栏。上周 Charlie 忘记在新建的 settings/page.tsx 里加侧边栏,用户点了设置页突然看不到菜单了,以为是导航坏了。"

代码重复情况:

问题 影响 涉及页面数
导航栏重复 import 每个页面都要手动包含 15 个页面
侧边栏状态不持久 导航后侧边栏选中状态丢失 全部页面
登录/注册页显示导航 不应该显示,需要额外条件判断 3 个页面
用户信息传递复杂 每个页面都要 fetch 用户数据 12 个页面

(2) Next.js 布局系统的解法

用嵌套 layout + Route Groups 实现布局隔离,一次定义全局生效。

TEXT 📖 仅展示
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) 布局的持久化行为

100%
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 重新运行 ❌ 不运行 ✅ 运行
数据重新获取 ❌ 不获取 ✅ 重新获取

▶ 示例:布局持久化演示

TSX
// ============================================
// 演示布局持久化 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>
  );
}
TSX
// 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>
  );
}
TSX
// 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>
  );
}

输出:

TEXT 📖 仅展示
1. 访问 /dashboard,侧边栏展开,显示 Dashboard 内容
2. 点击 "→ Collapse",侧边栏收缩为 64px
3. 点击 "Projects"链接,导航到 /dashboard/projects
4. 侧边栏保持收缩状态(没有重置为展开)✅
5. 页面内容从 Dashboard 变为 Projects ✅

(2) 嵌套布局的层级

100%
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 布局 分组内页面 侧边栏、导航栏、用户信息
嵌套布局 子目录页面 子导航、面包屑、局部过滤条

▶ 示例:三层嵌套布局

TSX
// ============================================
// 三层嵌套布局:全局 → 后台 → 产品管理
// ============================================

// 第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>
  );
}

输出:

TEXT 📖 仅展示
访问 /dashboard/products:
→ 根布局渲染 <html><body>
→ 后台布局渲染 <Sidebar> + <main>
→ 产品布局渲染 产品子导航 + 页面内容
三层嵌套全部生效

4. Route Groups (group)

(1) 什么是 Route Groups

(group) 目录在 URL 中不产生路径段,只用来逻辑分组。

100%
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 实现不同布局

TSX
// ============================================
// 使用 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>
  );
}
TSX
// 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>
  );
}

输出:

TEXT 📖 仅展示
访问 /login 看到:
- 居中表单设计(无导航栏、无侧边栏)
- 蓝色渐变背景
- TaskFlow 品牌 Logo + Sign In 表单

访问 /dashboard 看到:
- 左侧深色侧边栏(Dashboard / Projects / Settings)
- 顶部白色搜索栏
- 右侧内容区域

5. Layout vs Template

(1) 核心区别

100%
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 行为对比

TSX
// ============================================
// 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>
  );
}
TSX
// ============================================
// 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>
  );
}
TSX
// 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>;
}

输出:

TEXT 📖 仅展示
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 实现页面过渡动画

TSX
// ============================================
// 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>
  );
}

输出:

TEXT 📖 仅展示
每次页面导航:
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 指令

▶ 示例:完整的根布局

TSX
// ============================================
// 根布局 — 完整配置
// 文件: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>
  );
}

输出:

TEXT 📖 仅展示
所有页面自动获得:
- Inter 字体(性能优化,无 CLS)
- 全局 CSS 类(antialiased, bg-gray-50, text-gray-900)
- SEO metadata(title 模板 "Page | TaskFlow")
- Open Graph 标签(社交媒体分享卡片)

7. 布局数据共享模式

(1) 三种共享模式

100%
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 独立数据 解耦、并发 无法直接共享

▶ 示例:三种数据共享模式

TSX
// ============================================
// 三种布局数据共享模式完整示例
// ============================================

// 模式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>
  );
}
TSX
// 模式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>
  );
}

输出:

TEXT 📖 仅展示
访问 /dashboard:
侧边栏显示: Welcome, Alice
主要内容显示: Welcome back, Alice! You are logged in as admin.
用户数据通过 Context 在 layout 和 page 之间共享
无需每个页面都 fetch 用户信息

8. 完整示例:TaskFlow 完整布局系统

TSX
// ============================================
// 综合示例: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>
  );
}

预期输出:

TEXT 📖 仅展示
访问 /login:
- 深蓝色渐变全屏背景
- 居中白色表单卡片
- TaskFlow Logo + 登录表单

访问 /dashboard:
- 左侧深色侧边栏(可折叠,导航时保持状态)
- 顶部导航栏(显示 Welcome, Alice + 通知图标)
- 右侧内容区(Dashboard Overview + 3 个统计卡片)
- 用户数据通过 Context 共享

❓ 常见问题

Q layout.tsx 可以使用 'use client' 吗?
A 可以。layout.tsx 默认是 Server Component,但如果需要 useState、useEffect 或事件处理,可以添加 'use client' 指令。注意:添加后该 layout 及其子组件都变成 Client Component。
Q Route Groups 对 URL 有什么影响?
A 完全没有。(auth)/login/page.tsx 的 URL 仍然是 /login(auth) 只影响布局结构,不产生 URL 路径段。这是 Route Groups 的核心用途。
Q layout 和 template 可以同时存在吗?
A 可以。如果同一个目录下同时有 layout.tsx 和 template.tsx,template 会包裹在 layout 内部。用户导航时 layout 保持,template 重建。
Q 根 layout 的 <html> 标签可以修改吗?
A 可以,而且必须由你手动编写。根 layout 会自动替换 Next.js 的默认 HTML 外壳,所以你可以自由添加 langdirclassName 等属性。这是 RTL 支持的关键。
Q 嵌套 layout 最多可以嵌套几层?
A 没有硬性限制,但建议不超过 3-4 层。每一层 layout 都会在 DOM 中产生额外的包裹 div,过多的嵌套层级会影响性能和可维护性。
Q 不同 Route Groups 之间如何共享数据?
A 通过根 layout 的 Context Provider。在根 layout 中包裹 UserProviderThemeProvider 等,所有 Route Groups 内的页面都能访问。这是 Next.js 推荐的全局数据共享方式。

📖 小节


📝 作业

  1. 基础题(⭐):在项目中创建 (marketing)/layout.tsx(app)/layout.tsx 两个 Route Group 布局,分别给营销页面(首页、关于)和应用页面(仪表盘、设置)不同的样式。

  2. 进阶题(⭐⭐):在同一个目录下同时放置 layout.tsx 和 template.tsx,分别在两个文件中添加 useEffect(() => { console.log('mounted') }, []),导航时观察控制台输出,验证两者的挂载行为差异。

  3. 挑战题(⭐⭐⭐):在根 layout 中创建一个 UserProvider (Context),在 (auth)/login/page.tsx(dashboard)/page.tsx 中分别使用 useUser() 读取用户数据,验证 Context 跨 Route Groups 的数据共享。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏