Next.js 路由与布局的使用笔记

Next.js 的 App Router 是 React 框架范式的一次重大飞跃。它不仅仅是文件系统路由的升级, 更是对 Server Components (RSC) 架构的全面拥抱。 在使用 App Router 构建复杂应用时,如何组织文件、管理数据流和界定渲染边界,直接决定了项目的可维护性与性能上限。

1. 文件即架构:超越单纯的 URL 映射

在 Pages Router 时代,文件目录几乎等同于 URL 路径。但在 App Router 中,目录结构承载了更多布局嵌套数据层级的含义。

1.1 Colocation (就近原则) 的实战应用

不要再把所有组件都丢到 src/components 里。属于某个路由独有的组件、工具函数、测试文件,应该直接放在该路由的文件夹下。App Router 默认不公开非 page.js 文件,这天然支持了模块聚合。

app/
├── dashboard/
│   ├── page.tsx              # 公开路由
│   ├── DashboardChart.tsx    # 私有组件
│   ├── utils.ts              # 私有工具函数
│   ├── types.ts              # 私有类型定义
│   └── __tests__/            # 私有测试
└── (marketing)/
    └── landing/
        ├── page.tsx
        ├── Hero.tsx          # 仅 landing 页使用
        └── testimonials.ts   # 静态数据

最佳实践: 只有当组件被 3 个以上不同路由共享时,才考虑提升到全局 components/ 目录。这种"局部优先"的策略显著降低了代码的认知负担和维护成本。

常见陷阱: 新手容易将 page.tsx 的命名误认为必须是 page。实际上你可以在同目录下创建 HomePage.tsx 作为页面主组件,然后在 page.tsx 中导入并导出,这样能更清晰地区分"路由入口"与"页面实现"。

1.2 Route Groups (路由分组) 的高级用法

使用 (folderName) 语法不仅是为了组织代码,更是为了打破布局嵌套。例如,(marketing)(dashboard) 可以拥有完全不同的 layout.tsx,却在 URL 上处于根路径。

app/
├── (marketing)/
│   ├── layout.tsx        # 营销布局(无侧边栏)
│   ├── page.tsx          # URL: /
│   └── about/
│       └── page.tsx      # URL: /about
└── (dashboard)/
    ├── layout.tsx        # 后台布局(带侧边栏)
    ├── analytics/
    │   └── page.tsx      # URL: /analytics
    └── settings/
        └── page.tsx      # URL: /settings

应用场景:

边界情况: 路由分组的括号不能嵌套使用,即 (group1)/(group2) 会被视为普通路径。如需更深层次的组织,考虑使用下划线前缀的私有文件夹 _internal/

1.3 私有文件夹与命名约定

除了路由分组,Next.js App Router 还支持以下特殊命名模式:

app/
├── _lib/                 # 私有工具库
│   └── database.ts
├── @modal/               # 并行路由:模态框槽位
│   └── login/
│       └── page.tsx
└── photos/
    ├── [id]/
    │   └── page.tsx      # /photos/123
    └── (.)photo/         # 拦截路由:在同页面打开模态框
        └── [id]/
            └── page.tsx

实战技巧: 使用拦截路由实现"软导航"模态框时,配合 useRouter().back() 可以让用户在刷新页面后仍然看到完整页面而非模态框,体验更自然。

2. 布局系统:嵌套与状态保持

layout.tsx 是 App Router 的灵魂。它的核心特性是状态保持 (State Preservation)——当路由在同一布局下的子页面间切换时,布局组件不会卸载,状态 (如搜索框内容、滚动位置) 得以保留。

2.1 布局嵌套的工作原理

Next.js 会自动将路由层级中的所有 layout.tsx 嵌套组合,形成一个完整的组件树。这种嵌套是自动的、声明式的,无需手动管理。

// app/layout.tsx (根布局)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <GlobalNav />
        {children}
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx (仪表盘布局)
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

当用户访问 /dashboard/analytics 时,实际渲染的组件树为:
RootLayout → DashboardLayout → AnalyticsPage

性能优势: 当从 /dashboard/analytics 导航到 /dashboard/settings 时,RootLayoutDashboardLayout 不会重新渲染,只有页面组件会切换。这保留了侧边栏的展开状态、搜索框的输入内容等。

2.2 Provider 下沉策略

最佳实践: 避免在 Root Layout 中塞入过多的 Context Provider。将 Provider 下沉到真正需要它们的子布局中。

// ❌ 不推荐:所有 Provider 都在根部
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          <AuthProvider>
            <CartProvider>
              <NotificationProvider>
                {children}
              </NotificationProvider>
            </CartProvider>
          </AuthProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

// ✅ 推荐:Provider 下沉到需要的子布局
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          <AuthProvider>
            {children}
          </AuthProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

// app/shop/layout.tsx
export default function ShopLayout({ children }) {
  return (
    <CartProvider>
      {children}
    </CartProvider>
  );
}

这样做的好处是减少不必要的重渲染范围。当 CartContext 更新时,只有 /shop 路由下的组件会受影响。

2.3 并行路由与插槽 (Parallel Routes & Slots)

对于复杂的嵌套布局,利用 Parallel Routes (@folder) 可以实现类似模态框 (Modal) 或侧边栏的并列视图,而无需脱离路由体系。

app/
├── layout.tsx
├── @modal/
│   ├── default.tsx       # 默认插槽内容
│   └── login/
│       └── page.tsx
└── @sidebar/
    ├── default.tsx
    └── settings/
        └── page.tsx

// app/layout.tsx
export default function Layout({ children, modal, sidebar }) {
  return (
    <>
      <aside>{sidebar}</aside>
      <main>{children}</main>
      {modal}
    </>
  );
}

使用场景: 这种模式特别适合"同时显示多个独立区域"的场景,如仪表盘 (主内容 + 通知面板 + 聊天侧边栏)、照片墙 (网格视图 + 预览模态框) 等。

常见陷阱: 必须提供 default.tsx 作为兜底,否则在某些路由下并行槽位会报错。default.tsx 通常返回 null 或一个占位符。

3. 服务端组件 vs 客户端组件:划定边界

这是新架构中最难掌握的部分。默认所有组件都是 Server Component

3.1 服务端组件的优势与限制

优势:

// app/dashboard/page.tsx (服务端组件)
import { db } from '@/lib/database'; // 不会发送到客户端

export default async function DashboardPage() {
  // 直接在组件内查询数据库
  const stats = await db.query('SELECT * FROM analytics');

  return (
    <div>
      <h1>Dashboard</h1>
      <StatsChart data={stats} />
    </div>
  );
}

限制:

3.2 客户端组件的使用时机

仅在以下场景使用 'use client':

// components/SearchBox.tsx (客户端组件)
'use client';

import { useState } from 'react';

export function SearchBox() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="搜索..."
    />
  );
}

3.3 "树叶模式"架构策略

核心原则: 将组件树尽可能保持在服务端,只在树的末端 (叶子节点) 把交互逻辑封装为 Client Component。

// ✅ 推荐:服务端组件包裹客户端组件
// app/products/page.tsx (Server Component)
import { ProductList } from './ProductList'; // Server
import { AddToCartButton } from './AddToCartButton'; // Client

export default async function ProductsPage() {
  const products = await fetchProducts();

  return (
    <div>
      <h1>产品列表</h1>
      {products.map(product => (
        <div key={product.id}>
          <h2>{product.name}</h2>
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}

// components/AddToCartButton.tsx (Client Component)
'use client';

export function AddToCartButton({ productId }) {
  return (
    <button onClick={() => addToCart(productId)}>
      加入购物车
    </button>
  );
}

例如,一个包含搜索栏的 Header,Header 本身应该是 Server Component,只有搜索输入框那个小组件标为 'use client'

性能影响: 这种模式能最大化减少客户端 JavaScript。在上面的例子中,ProductList 和产品数据处理逻辑都在服务端执行,只有极小的 AddToCartButton 组件会被发送到客户端。

3.4 组件组合模式:避免客户端边界污染

常见错误: 在客户端组件中导入服务端组件会导致服务端组件被强制转换为客户端组件。

// ❌ 错误:ServerComponent 会被污染为客户端组件
'use client';

import { ServerComponent } from './ServerComponent';

export function ClientWrapper() {
  return <ServerComponent />;
}

// ✅ 正确:通过 children 传递
'use client';

export function ClientWrapper({ children }) {
  return <div className="wrapper">{children}</div>;
}

// 在服务端组件中使用
<ClientWrapper>
  <ServerComponent />
</ClientWrapper>

通过 children 或其他 props 传递,可以保持服务端组件的性质,避免不必要的客户端代码膨胀。

4. 数据获取与 Suspense 流式传输

告别 getServerSideProps。现在,数据获取就是普通的 async/await 函数。

4.1 服务端组件中的数据获取

// app/posts/[id]/page.tsx
async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { revalidate: 60 } // ISR: 60秒后重新验证
  });
  return res.json();
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

自动去重: Next.js 会自动对相同的 fetch 请求进行去重。如果多个组件在同一次渲染中请求相同的 URL,实际只会发起一次网络请求。

4.2 流式渲染与 Suspense

结合 Streaming (流式渲染),我们可以利用 <Suspense> 包裹慢速的数据组件。Next.js 会立即发送页面骨架 (Shell),一旦慢速组件的数据准备好,再通过 HTTP 流将 HTML 片段推送到浏览器。

import { Suspense } from 'react';
import { SlowComponent } from './SlowComponent';
import { LoadingSkeleton } from './LoadingSkeleton';

export default function Page() {
  return (
    <div>
      <h1>欢迎</h1>
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

// SlowComponent.tsx
async function SlowComponent() {
  const data = await fetchSlowData(); // 需要3秒
  return <div>{data}</div>;
}

这极大地优化了 TTFB (Time to First Byte) 和用户感知性能。用户会立即看到页面框架和加载骨架,而不是长时间的白屏。

4.3 并行数据获取 vs 瀑布式请求

常见陷阱: 串行的 await 会导致瀑布式请求,显著增加总加载时间。

// ❌ 瀑布式:总时间 = 2秒 + 3秒 = 5秒
async function BadPage() {
  const user = await fetchUser();     // 2秒
  const posts = await fetchPosts();   // 3秒
  return ...;
}

// ✅ 并行:总时间 = max(2秒, 3秒) = 3秒
async function GoodPage() {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts()
  ]);
  return ...;
}

最佳实践: 始终使用 Promise.all() 并行获取独立的数据源。只有当一个请求依赖另一个请求的结果时,才使用串行 await。

4.4 loading.tsx 与 Suspense 的区别

loading.tsx 是路由级别的加载状态,而 <Suspense> 是组件级别的。

// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

// 等价于在 layout 中使用 Suspense
<Suspense fallback={<DashboardSkeleton />}>
  {children}
</Suspense>

5. 约定优于配置:特殊文件

熟练掌握 error.tsx, loading.tsx, not-found.tsx。它们自动构成了 React Error Boundaries 和 Suspense 的层级结构。

5.1 错误处理:error.tsx 与 global-error.tsx

error.tsx 必须是 Client Component,因为它需要捕获浏览器端的渲染错误。

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 上报错误到监控系统
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>出错了!</h2>
      <button onClick={reset}>重试</button>
    </div>
  );
}

错误边界层级:

最佳实践:error.tsx 中集成错误监控 (如 Sentry),自动收集错误堆栈和用户操作路径。

5.2 404 处理:not-found.tsx

// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';

async function getPost(id: string) {
  const post = await db.post.findUnique({ where: { id } });
  if (!post) notFound(); // 触发 not-found.tsx
  return post;
}

// app/posts/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>文章不存在</h2>
      <Link href="/posts">返回列表</Link>
    </div>
  );
}

层级规则: Next.js 会向上查找最近的 not-found.tsx。如果当前路由没有,会使用父路由的,最终回退到根部的 app/not-found.tsx

5.3 元数据管理:metadata 与 generateMetadata

Next.js 13+ 提供了类型安全的元数据 API,无需手动操作 <head>

// 静态元数据
export const metadata = {
  title: '我的博客',
  description: '技术分享与学习笔记'
};

// 动态元数据
export async function generateMetadata({ params }) {
  const post = await getPost(params.id);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.coverImage]
    }
  };
}

SEO 优势: 服务端生成的元数据对搜索引擎爬虫完全可见,无需等待客户端 JavaScript 执行。

6. 缓存与即时性 (Caching & Revalidation)

Next.js 的 fetch API 扩展了缓存控制。理解 force-cache (默认静态), no-store (动态), 和 revalidate (ISR) 是控制数据新鲜度的关键。

6.1 四层缓存体系

Next.js App Router 有 4 层缓存:

  1. 请求记忆化 (Request Memoization): 同一次渲染中相同的 fetch 请求只执行一次
  2. 数据缓存 (Data Cache): fetch 结果在服务端持久化,跨请求复用
  3. 完整路由缓存 (Full Route Cache): 整个页面的 HTML 和 RSC Payload 被缓存
  4. 路由器缓存 (Router Cache): 客户端侧的页面缓存,用于快速导航

6.2 Fetch 缓存配置详解

// 默认:永久缓存(静态渲染)
fetch('https://api.example.com/data');

// 等价于
fetch('https://api.example.com/data', {
  cache: 'force-cache'
});

// 动态渲染:每次请求都重新获取
fetch('https://api.example.com/data', {
  cache: 'no-store'
});

// ISR:60秒后重新验证
fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
});

// 按需重新验证:使用标签
fetch('https://api.example.com/data', {
  next: { tags: ['posts'] }
});
// 在 Server Action 中触发
revalidateTag('posts');

6.3 路由段配置:强制动态/静态

// app/dashboard/page.tsx
// 强制整个路由为动态
export const dynamic = 'force-dynamic';
// 等价于所有 fetch 都设置 cache: 'no-store'

// 强制静态(默认)
export const dynamic = 'force-static';

// 设置重新验证间隔
export const revalidate = 3600; // 1小时

常见陷阱: 在 App Router 中,缓存非常激进。如果发现数据没更新,首先检查:

6.4 按需重新验证的实战场景

// app/actions.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: ... });

  // 重新验证特定路径
  revalidatePath('/posts');

  // 或重新验证特定标签
  revalidateTag('posts');
}

使用场景: 适合"内容发布"类应用。编辑后台创建文章后,无需等待 ISR 间隔,立即刷新前台页面缓存。

小结

Next.js App Router 强迫开发者以"服务器优先"的思维去构建应用。虽然学习曲线陡峭,但它带来的回报是巨大的:更小的客户端体积、更快的首屏加载、更合理的代码组织。掌握它,不仅仅是学会一个框架,更是对现代 Web 架构 (React Server Components) 的深刻理解。

关键要点回顾: