陌上人如玉
公子世无双

9.2 Next.js App Router

9.2 Next.js App Router

一句话:App Router 用「目录结构即路由」——app/ 下的目录是路由段,page.tsx 是页面,layout.tsx 是共享布局,loading.tsx/error.tsx 提供边界;组件默认是 Server Component,要交互才加 "use client"。

一、目录结构与路由

app/
├── layout.tsx                 根布局(必需,含 <html>/<body>)
├── page.tsx                   首页(对应 /)
├── loading.tsx                根级加载 UI
├── error.tsx                  根级错误边界
├── not-found.tsx              404
├── about/
│   └── page.tsx               /about
├── blog/
│   ├── layout.tsx             /blog 下的共享布局
│   ├── page.tsx               /blog
│   ├── loading.tsx            /blog 的加载 UI
│   └── [slug]/
│       └── page.tsx           /blog/:slug(动态段)
└── (marketing)/               路由组(不产生路径段)
    ├── pricing/page.tsx       /pricing
    └── contact/page.tsx       /contact
⭐ 核心规则:
① 目录名 = 路径段
② 只有 page.tsx 才「对外可见」(才能被访问)
③ 目录里可以放「非路由文件」(组件、工具、测试),它们不会变成路由
④ 路由组 (name) —— 圆括号目录「不进入 URL」,用于「组织代码 / 共享布局」
⑤ 动态段 [slug] —— 方括号目录是动态参数
⑥ 捕获所有 catch-all [..slug] / 可选 [[..slug]]

二、特殊文件

文件 作用 说明
page.tsx 页面(对外可访问) 默认导出组件
layout.tsx 布局(包裹子路由,不重新挂载) 接收 children
template.tsx 类似 layout,但每次导航都重新挂载 需要「每次重置状态」时用
loading.tsx 加载 UI(自动包 Suspense) 路由切换时显示
error.tsx 错误边界(客户端组件) 接收 error 与 reset
global-error.tsx 捕获根布局的错误 必须自己渲染 <html>/<body>
not-found.tsx 404 UI 配合 notFound() 调用
route.ts API 路由(HTTP 端点) 导出 GET/POST 等函数
// app/blog/layout.tsx —— 共享布局
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="blog">
      <BlogNav />
      {children}                  {/* ⭐ 子路由在这里渲染 */}
    </div>
  );
}
// app/blog/loading.tsx —— 路由切换时的加载 UI(自动被 Suspense 包住)
export default function Loading() {
  return <ListSkeleton />;
}
// app/blog/error.tsx —— 错误边界(必须是客户端组件)
'use client';

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <div role="alert">
      <h2>博客加载失败</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>       {/* ⭐ 重新渲染这一段的子树 */}
    </div>
  );
}
// app/api/users/route.ts —— API 路由
import { NextResponse } from 'next/server';

export async function GET() {
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

三、动态段与路由组

app/blog/[slug]/page.tsx            /blog/hello
app/blog/[slug]/[commentId]/page.tsx   /blog/hello/123
app/shop/[...slug]/page.tsx         /shop/a/b/c(捕获所有段)
app/shop/[[...slug]]/page.tsx       /shop 与 /shop/a/b(可选捕获)
app/(marketing)/about/page.tsx      /about(组名不进入 URL)
app/(shop)/cart/page.tsx            /cart
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;                 // ⭐ Next.js 15 起 params 是 Promise
  const post = await getPost(slug);

  if (!post) notFound();                         // ⭐ 触发 not-found.tsx

  return <article>{post.content}</article>;
}

// 生成静态路径(SSG)
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((p) => ({ slug: p.slug }));   // ⭐ 构建时预生成这些页面
}
// 查询参数与元数据
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const { q } = await searchParams;
  return <SearchResult keyword={q ?? ''} />;
}

四、布局的嵌套与共享

// app/layout.tsx —— 根布局(必需,负责 <html>/<body>)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <TopNav />
        {children}
      </body>
    </html>
  );
}
// 嵌套布局的渲染结构(访问 /blog/hello)
<RootLayout>                 {/* 不重新挂载 */}
  <TopNav />
  <BlogLayout>               {/* /blog 下共享,不重新挂载 */}
    <BlogNav />
    <BlogPost />             {/* 叶子页面 */}
  </BlogLayout>
</RootLayout>
⭐ 布局的三个关键点:
① 布局「不会在导航时重新挂载」——状态保留、不闪
② 布局是「从外到内」嵌套的,与目录结构一致
③ 布局是 Server Component(默认)——不能有 state/事件;
   需要交互的导航组件要单独抽成 Client Component
// ✅ 常见模式:布局保持 Server,把交互部分抽成 Client Component
// app/layout.tsx(Server)
import { NavBar } from './nav-bar';            // Client Component

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <NavBar />                             {/* ⭐ 交互部分在 Client Component 里 */}
        <main>{children}</main>
      </body>
    </html>
  );
}

五、数据获取(Server Component 里直接 await)

// ✅ Server Component 可以是 async,直接 await 数据
export default async function PostsPage() {
  const posts = await db.post.findMany();       // ⭐ 直接查数据库(代码不进客户端包)
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
// ✅ 并行请求(避免瀑布)
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const [user, posts] = await Promise.all([
    getUser(id),
    getPosts(id),
  ]);

  return (
    <>
      <UserCard user={user} />
      <PostList posts={posts} />
    </>
  );
}
// ✅ 流式渲染:慢的部分用 Suspense 单独包
export default function Page() {
  return (
    <>
      <Header />                                   {/* 快 → 先显示 */}
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />                                  {/* 慢 → 流式补上(Posts 是 async 组件)*/}
      </Suspense>
    </>
  );
}

async function Posts() {
  const posts = await getPosts();                  // ⚠️ 慢
  return <PostList posts={posts} />;
}
方式 位置 说明
Server Component 里 await 服务端 默认方式,最直接
fetch 的缓存选项 服务端 { cache: 'force-cache' \| 'no-store' } / next: { revalidate, tags }
loading.tsx 路由级 整个路由段的加载 UI
<Suspense> 组件级 更细的流式边界
客户端 useEffect / TanStack Query 客户端 需要交互后才请求的数据

六、Server Actions

// app/posts/actions.ts
'use server';                                      // ⭐ 标记:这个文件里的函数在服务端执行

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const post = await db.post.create({ data: { title } });

  revalidatePath('/posts');                        // ⭐ 让 /posts 的缓存失效
  redirect(`/posts/${post.id}`);                    // ⭐ 跳转
}
// 客户端组件里直接用(React 19 的 Actions)
import { createPost } from './actions';

export function NewPostForm() {
  return (
    <form action={createPost}>                     {/* ⭐ 直接传 Server Action */}
      <input name="title" required />
      <SubmitButton />
    </form>
  );
}

// 提交状态(React 19 的 useFormStatus,必须在 form 的子组件里)
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '提交中…' : '创建'}</button>;
}
⭐ Server Actions 的价值:
① 【少写一层 API】——表单直接调用服务端函数(不需要手写 route.ts + fetch)
② 【自动处理】——`<form action={fn}>` 自动处理 preventDefault 与表单重置
③ 【与缓存集成】——可以 `revalidatePath` / `revalidateTag` 直接刷新缓存
④ 【类型安全】——前后端共享 TypeScript 类型(不需要手写接口类型)

⚠️ 安全要点:
· Server Action 是「HTTP 端点」,**必须自己做「鉴权与输入校验」**
  (任何人都可以直接调用它,别以为「只在表单里用就安全」)
· 不要「把敏感数据当参数传来传去」(应该在服务端从 session 取)

七、元数据 API

// ① 静态元数据
export const metadata: Metadata = {
  title: '我的博客',
  description: '前端技术分享',
  openGraph: { title: '我的博客', images: ['/og.png'] },
};

// ② 动态元数据
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post?.title ?? '未找到',
    description: post?.excerpt,
    openGraph: { images: [`/api/og?title=${encodeURIComponent(post?.title ?? '')}`] },
  };
}
// ③ 或者在组件里直接渲染(React 19 会自动提升到 <head>)
export default function Page() {
  return (
    <>
      <title>页面标题</title>
      <meta name="description" content="描述" />
      <h1>内容</h1>
    </>
  );
}

八、缓存与 revalidate

// 页面级:60 秒后再生
export const revalidate = 60;

// fetch 级
const data = await fetch(url, { next: { revalidate: 60, tags: ['posts'] } });
// 完全动态(不缓存)
const data = await fetch(url, { cache: 'no-store' });
// 强制缓存
const data = await fetch(url, { cache: 'force-cache' });

// 按需失效
revalidatePath('/posts');            // 让路径的缓存失效
revalidateTag('posts');               // 让某类数据的缓存失效
⭐ 三个层级要分清:
① 【页面/路由级】——`export const revalidate`(影响整个页面的静态化策略)
② 【数据级】——`fetch` 的 `next.revalidate` / `cache`(影响单个请求)
③ 【按需】——`revalidatePath` / `revalidateTag`(内容变更时主动失效)

⚠️ Next.js 15 起「fetch 默认不再缓存」(此前是「默认缓存」)——
   升级时要检查「哪些请求需要显式开启缓存」。

九、常见坑

// ① 在 Server Component 里用 useState / useEffect / onClick
'use client' 没写却用了 Hook                              // ❌ 报错
// ✅ 加 'use client' 或把交互部分下沉到 Client Component

// ② 把 'use client' 写在顶层布局 → 整个应用都变成客户端组件
// ③ 在客户端组件里访问「数据库 / 服务端专属 API」
'use client';
const data = await db.query();                            // ❌ 客户端没有 db

// ④ 传「不可序列化的 props」给客户端组件(函数、类实例、Symbol)
<ClientComp onSelect={() => {}} />                       // ❌ 函数不能跨边界传递
// ✅ 用 Server Action(可以传)或改用「客户端内定义回调」

// ⑤ 忘记 `await params` / `await searchParams`(Next.js 15 起是 Promise)
// ⑥ error.tsx 忘记 'use client'(错误边界必须是客户端组件)
// ⑦ 在路由组里「把同名路径用了两次」(冲突)
// ⑧ 以为「page.tsx 之外的文件也会变成路由」
// ⑨ 在 Server Component 里用「浏览器 API」(window/localStorage)
// ⑩ 用 ISR 渲染「个性化内容」(共享缓存 → 串用户数据)
// ⑪ Server Action 不做「鉴权与校验」(它是公开的 HTTP 端点)
// ⑫ 忘记 `revalidatePath/Tag`(提交后列表不更新)
// ⑬ 在「根布局」里读取 cookies/headers(会让整个应用变成动态渲染)
export default function RootLayout() {
  const theme = cookies().get('theme');                   // ⚠️ 使整个应用无法静态化
  // ✅ 放到「真正需要它的深层组件」里读
}
// ⑭ 用 `generateStaticParams` 但页面里又用了「动态函数」(会退化为动态渲染)
// ⑮ 客户端组件「嵌套在很深的位置」却没有明确边界(心智混乱)

十、面试延伸

  1. 「Next.js App Router 的核心约定是什么?」
    四点:① 目录即路由——app/ 下的目录是路由段,page.tsx 是对外可见的页面;② 特殊文件——layout.tsx(共享布局,导航时不重新挂载)、page.tsx(页面)、loading.tsx(路由级加载 UI,自动包 Suspense)、error.tsx(路由级错误边界,必须是客户端组件)、not-found.tsx(404)、route.ts(API 路由);③ 组件默认是 Server Component——可以直接 async/await 取数据、访问数据库,要交互才加 "use client";④ 路由组 (name)——圆括号目录不进入 URL,用于组织代码与共享布局。

  2. 「App Router 的布局和 Pages Router 的有什么不同?」
    关键差异:① 布局可嵌套——app/ 下每层目录都可以有 layout.tsx,导航时不会重新挂载(状态保留、不闪);② 布局与页面是「组合关系」——布局通过 children 渲染页面(而不是「包裹一个 page 组件」),这让「布局是 Server Component、页面是 Client Component」这类混合成为可能;③ 有 template.tsx——与 layout 类似但「每次导航都重新挂载」(需要重置状态时用);④ 布局在 app/ 下是必需的根布局(要渲染 <html>/<body>)。

  3. 「Server Component 里怎么取数据?」
    直接 async/await——把组件声明为 async function,然后 const data = await db.post.findMany() 或 await fetch(url)。因为它在服务端执行,所以可以「直接访问数据库」(少一层 HTTP)、代码不进客户端包(包体积更小)。多个请求用 Promise.all 并行(避免瀑布)。慢的部分可以用 <Suspense> 包住(流式渲染:先给 shell,数据就绪后补上),或依赖同目录的 loading.tsx 作为整段的加载 UI。

  4. 「Server Actions 是什么?有什么用?」
    Server Actions 是「在服务端执行、可以被客户端直接调用的函数」——用 'use server' 标记文件或函数,然后可以直接用在 <form action={fn}> 或事件处理里(Next.js 会生成一个 HTTP 端点并在背后处理调用)。它的价值:① 少写一层 API(不需要手写 route.ts + 客户端 fetch);② 自动处理表单默认行为(preventDefault、重置);③ 与缓存集成(可以 revalidatePath/revalidateTag 立刻刷新);④ 前后端共享 TypeScript 类型。

  5. 「Server Action 有什么安全风险?」
    它是「公开的 HTTP 端点」——任何人都可以直接调用它(不经过你的 UI)。所以必须:① 鉴权——在函数内部检查「当前用户是否有权执行」(从 cookie/session 读,不能相信客户端传来的用户 ID);② 输入校验——所有参数都要校验(你不能假设调用者就是你的表单);③ 不返回敏感数据。常见的错误是「以为『只在表单里用』就安全」——攻击者可以直接构造请求调用它。

  6. 「Next.js 的缓存有哪几个层级?」
    三个:① 页面/路由级——export const revalidate = 60(决定整个页面的静态化与再生周期),或用动态函数(cookies()/headers()/searchParams)触发动态渲染;② 数据级——fetch 的 cache: 'force-cache' | 'no-store' 与 next: { revalidate, tags }(Next.js 15 起 fetch 默认不缓存,需要显式开启);③ 按需失效——revalidatePath(path) / revalidateTag(tag)(在 Server Action 或 API 路由里调用,让缓存立刻失效)。此外还有「客户端路由缓存」(Router Cache)与「CDN 缓存」。

  7. 「loading.tsx 和 <Suspense> 有什么区别?」
    ① 粒度——loading.tsx 是「路由段级」的(覆盖整个目录对应的页面与子路由),Next.js 会自动用 <Suspense> 包住页面;<Suspense> 是「组件级」的(你可以精确控制「哪一块慢、哪一块先显示」)。② 使用方式——loading.tsx 是「文件约定」(零代码,写上就有),<Suspense> 要手动包裹。③ 实践——loading.tsx 用于「整页加载」的粗糙体验,<Suspense> 用于「页面内多块各自流式」(更细腻);两者可以同时用。

  8. 「App Router 中常见的『让整页变成动态渲染』的原因有哪些?」
    五类:① 用了 动态函数——cookies()、headers()、searchParams、connection();② 路由段配置了动态——export const dynamic = 'force-dynamic';③ 数据请求用了 cache: 'no-store'(或 Next.js 15 的默认不缓存);④ 在根布局里读了 cookies/headers(最隐蔽——会让整个应用都动态化);⑤ 用了「无法静态分析的动态值」(如 Math.random() 在服务端渲染时)。排查方式是看构建输出(哪些路由被标记为「ƒ (Dynamic)」而不是「○ (Static)」),然后把「动态依赖」尽量下沉到真正需要它的深层组件。

一句话速记

App Router 的核心是「目录即路由」+「组件默认是 Server Component」:page.tsx(页面)、layout.tsx(共享布局、导航不重新挂载)、loading.tsx(路由级加载 UI,自动包 Suspense)、error.tsx(必须是 'use client')、not-found.tsx、route.ts(API);路由组 (name) 不产生路径、[slug] 是动态段、[...slug] 捕获所有;Server Component 可以 async/await 直接查数据库(代码不进客户端包),配合 Suspense 做流式渲染、用 Promise.all 避免瀑布;Server Actions('use server')把 API 层省掉,但它是公开 HTTP 端点——必须自己鉴权与校验输入;缓存分「页面级 revalidate / 数据级 fetch 选项 / 按需 revalidatePath|Tag」(Next.js 15 起 fetch 默认不缓存);'use client' 会把它导入的所有模块都变成客户端代码,所以要把边界下沉。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.2 Next.js App Router

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫