
一句话: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` 但页面里又用了「动态函数」(会退化为动态渲染)
// ⑮ 客户端组件「嵌套在很深的位置」却没有明确边界(心智混乱)
十、面试延伸
- 「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,用于组织代码与共享布局。 -
「App Router 的布局和 Pages Router 的有什么不同?」
关键差异:① 布局可嵌套——app/下每层目录都可以有layout.tsx,导航时不会重新挂载(状态保留、不闪);② 布局与页面是「组合关系」——布局通过children渲染页面(而不是「包裹一个 page 组件」),这让「布局是 Server Component、页面是 Client Component」这类混合成为可能;③ 有template.tsx——与 layout 类似但「每次导航都重新挂载」(需要重置状态时用);④ 布局在app/下是必需的根布局(要渲染<html>/<body>)。 -
「Server Component 里怎么取数据?」
直接async/await——把组件声明为async function,然后const data = await db.post.findMany()或await fetch(url)。因为它在服务端执行,所以可以「直接访问数据库」(少一层 HTTP)、代码不进客户端包(包体积更小)。多个请求用Promise.all并行(避免瀑布)。慢的部分可以用<Suspense>包住(流式渲染:先给 shell,数据就绪后补上),或依赖同目录的loading.tsx作为整段的加载 UI。 -
「Server Actions 是什么?有什么用?」
Server Actions 是「在服务端执行、可以被客户端直接调用的函数」——用'use server'标记文件或函数,然后可以直接用在<form action={fn}>或事件处理里(Next.js 会生成一个 HTTP 端点并在背后处理调用)。它的价值:① 少写一层 API(不需要手写route.ts+ 客户端 fetch);② 自动处理表单默认行为(preventDefault、重置);③ 与缓存集成(可以revalidatePath/revalidateTag立刻刷新);④ 前后端共享 TypeScript 类型。 -
「Server Action 有什么安全风险?」
它是「公开的 HTTP 端点」——任何人都可以直接调用它(不经过你的 UI)。所以必须:① 鉴权——在函数内部检查「当前用户是否有权执行」(从 cookie/session 读,不能相信客户端传来的用户 ID);② 输入校验——所有参数都要校验(你不能假设调用者就是你的表单);③ 不返回敏感数据。常见的错误是「以为『只在表单里用』就安全」——攻击者可以直接构造请求调用它。 -
「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 缓存」。 -
「
loading.tsx和<Suspense>有什么区别?」
① 粒度——loading.tsx是「路由段级」的(覆盖整个目录对应的页面与子路由),Next.js 会自动用<Suspense>包住页面;<Suspense>是「组件级」的(你可以精确控制「哪一块慢、哪一块先显示」)。② 使用方式——loading.tsx是「文件约定」(零代码,写上就有),<Suspense>要手动包裹。③ 实践——loading.tsx用于「整页加载」的粗糙体验,<Suspense>用于「页面内多块各自流式」(更细腻);两者可以同时用。 -
「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'会把它导入的所有模块都变成客户端代码,所以要把边界下沉。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!