532 lines
15 KiB
Markdown
532 lines
15 KiB
Markdown
# Next.js 15 App Router 完整指南
|
||
|
||
> 涵盖 App Router 核心约定、数据获取、Server Actions、路由系统、缓存策略、ISR 和中间件。
|
||
|
||
---
|
||
|
||
## 一、App Router 核心约定
|
||
|
||
### 1.1 文件约定
|
||
|
||
App Router 使用文件系统路由,每个文件夹代表一个路由段,特殊文件名有约定含义:
|
||
|
||
```
|
||
app/
|
||
├── layout.tsx # 根布局(必须)
|
||
├── page.tsx # 首页 /
|
||
├── loading.tsx # 加载 UI(自动包裹 Suspense)
|
||
├── error.tsx # 错误 UI(自动包裹 ErrorBoundary)
|
||
├── not-found.tsx # 404 UI
|
||
├── global-error.tsx # 全局错误处理
|
||
├── dashboard/
|
||
│ ├── layout.tsx # 仪表盘布局(嵌套布局)
|
||
│ ├── page.tsx # /dashboard
|
||
│ ├── loading.tsx # 仪表盘加载态
|
||
│ └── settings/
|
||
│ └── page.tsx # /dashboard/settings
|
||
```
|
||
|
||
### 1.2 Layout(布局)
|
||
|
||
Layout 在路由切换时保持状态,不重新渲染,适合放导航、侧边栏等共享 UI。
|
||
|
||
```tsx
|
||
// app/layout.tsx — 根布局
|
||
import { Inter } from 'next/font/google';
|
||
import '@/styles/globals.css';
|
||
|
||
const inter = Inter({ subsets: ['latin'] });
|
||
|
||
export const metadata = {
|
||
title: { default: '我的应用', template: '%s | 我的应用' },
|
||
description: '应用描述',
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="zh-CN" className={inter.className}>
|
||
<body>
|
||
<header><Navbar /></header>
|
||
<main>{children}</main>
|
||
<footer><Footer /></footer>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 1.3 Loading + Error
|
||
|
||
```tsx
|
||
// app/dashboard/loading.tsx — 自动 Suspense 边界
|
||
export default function DashboardLoading() {
|
||
return (
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="h-32 animate-pulse rounded-lg bg-gray-200" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// app/dashboard/error.tsx — 自动 ErrorBoundary
|
||
'use client';
|
||
|
||
export default function DashboardError({
|
||
error,
|
||
reset,
|
||
}: {
|
||
error: Error & { digest?: string };
|
||
reset: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-4 p-8">
|
||
<h2 className="text-xl font-bold">仪表盘加载失败</h2>
|
||
<p className="text-gray-500">{error.message}</p>
|
||
<button onClick={reset} className="btn-primary">重试</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二、数据获取
|
||
|
||
### 2.1 Server Components 数据获取
|
||
|
||
Server Components 是默认的,可以直接使用 `async/await`。
|
||
|
||
```tsx
|
||
// app/posts/page.tsx — 直接在组件中获取数据
|
||
export default async function PostsPage() {
|
||
// 方式1:使用 fetch(支持缓存控制)
|
||
const res = await fetch('https://api.example.com/posts', {
|
||
next: { revalidate: 3600 }, // 1小时后重新验证
|
||
});
|
||
const posts: Post[] = await res.json();
|
||
|
||
// 方式2:直接查询数据库(推荐,减少网络往返)
|
||
// const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } });
|
||
|
||
return (
|
||
<ul>
|
||
{posts.map((post) => (
|
||
<li key={post.id}><PostCard post={post} /></li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 2.2 fetch 缓存策略
|
||
|
||
```tsx
|
||
// 强制缓存(默认行为,等同于 SSG)
|
||
fetch(url, { cache: 'force-cache' });
|
||
|
||
// 不缓存(每次请求都重新获取,等同于 SSR)
|
||
fetch(url, { cache: 'no-store' });
|
||
|
||
// 按时间重新验证(ISR)
|
||
fetch(url, { next: { revalidate: 60 } }); // 60秒后重新验证
|
||
|
||
// 按标签重新验证
|
||
fetch(url, { next: { tags: ['posts'] } });
|
||
// 调用 revalidateTag('posts') 触发重新获取
|
||
```
|
||
|
||
### 2.3 并行数据获取
|
||
|
||
```tsx
|
||
// 避免请求瀑布流,使用 Promise.all 并行获取
|
||
export default async function DashboardPage() {
|
||
// 并行发起请求
|
||
const [user, stats, notifications] = await Promise.all([
|
||
getUser(),
|
||
getStats(),
|
||
getNotifications(),
|
||
]);
|
||
|
||
return (
|
||
<div>
|
||
<UserHeader user={user} />
|
||
<StatsGrid stats={stats} />
|
||
<NotificationList items={notifications} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、Server Actions
|
||
|
||
### 3.1 表单处理
|
||
|
||
```tsx
|
||
// actions/post.ts
|
||
'use server';
|
||
|
||
import { z } from 'zod';
|
||
import { revalidatePath } from 'next/cache';
|
||
import { redirect } from 'next/navigation';
|
||
|
||
const PostSchema = z.object({
|
||
title: z.string().min(1, '标题不能为空').max(100, '标题不超过100字'),
|
||
content: z.string().min(10, '内容至少10个字符'),
|
||
categoryId: z.string().uuid('分类ID格式错误'),
|
||
});
|
||
|
||
type PostState = {
|
||
errors?: { title?: string[]; content?: string[]; categoryId?: string[] };
|
||
message?: string;
|
||
};
|
||
|
||
export async function createPost(
|
||
prevState: PostState,
|
||
formData: FormData
|
||
): Promise<PostState> {
|
||
const parsed = PostSchema.safeParse({
|
||
title: formData.get('title'),
|
||
content: formData.get('content'),
|
||
categoryId: formData.get('categoryId'),
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { errors: parsed.error.flatten().fieldErrors };
|
||
}
|
||
|
||
try {
|
||
const post = await db.post.create({ data: parsed.data });
|
||
revalidatePath('/posts');
|
||
redirect(`/posts/${post.id}`);
|
||
} catch (e) {
|
||
return { message: '创建失败,请稍后重试' };
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 useActionState 配合表单
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useActionState } from 'react';
|
||
import { createPost } from '@/actions/post';
|
||
|
||
export function CreatePostForm() {
|
||
const [state, formAction, isPending] = useActionState(createPost, {});
|
||
|
||
return (
|
||
<form action={formAction} className="space-y-4">
|
||
<div>
|
||
<label htmlFor="title">标题</label>
|
||
<input id="title" name="title" className="input" />
|
||
{state.errors?.title?.map((e) => (
|
||
<p key={e} className="text-sm text-red-500">{e}</p>
|
||
))}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="content">内容</label>
|
||
<textarea id="content" name="content" className="input" rows={6} />
|
||
{state.errors?.content?.map((e) => (
|
||
<p key={e} className="text-sm text-red-500">{e}</p>
|
||
))}
|
||
</div>
|
||
|
||
{state.message && <p className="text-red-500">{state.message}</p>}
|
||
|
||
<button type="submit" disabled={isPending} className="btn-primary">
|
||
{isPending ? '发布中...' : '发布文章'}
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3.3 revalidatePath 与 revalidateTag
|
||
|
||
```tsx
|
||
'use server';
|
||
|
||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||
|
||
export async function updatePost(id: string, data: PostData) {
|
||
await db.post.update({ where: { id }, data });
|
||
|
||
// 方式1:按路径重新验证
|
||
revalidatePath('/posts'); // 重新验证列表页
|
||
revalidatePath(`/posts/${id}`); // 重新验证详情页
|
||
|
||
// 方式2:按标签重新验证(更精确)
|
||
revalidateTag('posts'); // 所有带 posts 标签的 fetch 都重新验证
|
||
revalidateTag(`post-${id}`); // 特定文章
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、路由系统
|
||
|
||
### 4.1 动态路由
|
||
|
||
```tsx
|
||
// app/posts/[id]/page.tsx — 动态路由段
|
||
interface PageProps {
|
||
params: Promise<{ id: string }>;
|
||
}
|
||
|
||
export default async function PostPage({ params }: PageProps) {
|
||
const { id } = await params;
|
||
const post = await db.post.findUnique({ where: { id } });
|
||
|
||
if (!post) notFound(); // 触发 not-found.tsx
|
||
|
||
return <PostDetail post={post} />;
|
||
}
|
||
|
||
// 静态生成参数(用于 SSG)
|
||
export async function generateStaticParams() {
|
||
const posts = await db.post.findMany({ select: { id: true } });
|
||
return posts.map((post) => ({ id: post.id }));
|
||
}
|
||
```
|
||
|
||
### 4.2 路由组
|
||
|
||
```tsx
|
||
// 使用 (groupName) 组织路由,不影响 URL 路径
|
||
app/
|
||
├── (marketing)/
|
||
│ ├── layout.tsx # 营销页面专用布局
|
||
│ ├── about/page.tsx # /about
|
||
│ └── pricing/page.tsx # /pricing
|
||
├── (dashboard)/
|
||
│ ├── layout.tsx # 仪表盘专用布局(带侧边栏)
|
||
│ ├── overview/page.tsx # /overview
|
||
│ └── settings/page.tsx # /settings
|
||
```
|
||
|
||
### 4.3 平行路由
|
||
|
||
```tsx
|
||
// 使用 @slotName 实现平行路由,同一页面渲染多个独立路由
|
||
app/dashboard/
|
||
├── layout.tsx
|
||
├── page.tsx
|
||
├── @analytics/
|
||
│ └── page.tsx # 分析面板
|
||
├── @activity/
|
||
│ └── page.tsx # 活动面板
|
||
|
||
// app/dashboard/layout.tsx
|
||
export default function DashboardLayout({
|
||
children,
|
||
analytics,
|
||
activity,
|
||
}: {
|
||
children: React.ReactNode;
|
||
analytics: React.ReactNode;
|
||
activity: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="col-span-2">{children}</div>
|
||
<aside>
|
||
{analytics}
|
||
{activity}
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.4 拦截路由
|
||
|
||
```tsx
|
||
// 使用 (.) (..) (...) 拦截路由,实现模态框效果
|
||
app/
|
||
├── feed/
|
||
│ ├── page.tsx # /feed(帖子列表)
|
||
│ ├── (..)photo/[id]/page.tsx # 拦截:在模态框中打开
|
||
├── photo/
|
||
│ └── [id]/
|
||
│ └── page.tsx # /photo/123(完整页面)
|
||
|
||
// 点击 feed 中的图片时,(..)photo/[id] 拦截路由,用模态框展示
|
||
// 直接访问 /photo/123 时,走正常的全页面路由
|
||
```
|
||
|
||
---
|
||
|
||
## 五、缓存策略
|
||
|
||
### 5.1 四层缓存体系
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ 1. Request Memoization (请求去重) │ → 同一渲染中相同 fetch 自动去重
|
||
├─────────────────────────────────────┤
|
||
│ 2. Data Cache (数据缓存) │ → fetch 结果缓存,跨请求持久化
|
||
├─────────────────────────────────────┤
|
||
│ 3. Full Route Cache (全路由缓存) │ → 静态路由在构建时缓存 HTML + RSC
|
||
├─────────────────────────────────────┤
|
||
│ 4. Router Cache (客户端路由缓存) │ → 浏览器端缓存已访问的路由
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### 5.2 按需重新验证
|
||
|
||
```tsx
|
||
// 方式1:基于时间 — 页面级
|
||
export const revalidate = 3600; // 整个路由每3600秒重新验证
|
||
|
||
// 方式2:基于时间 — fetch 级
|
||
fetch(url, { next: { revalidate: 60 } });
|
||
|
||
// 方式3:按需 — 通过 Server Action 触发
|
||
'use server';
|
||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||
|
||
export async function publishPost() {
|
||
await db.post.update({ ... });
|
||
revalidateTag('posts');
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、ISR(增量静态再生)
|
||
|
||
```tsx
|
||
// app/blog/[slug]/page.tsx
|
||
// 在构建时生成静态页面,之后按需重新生成
|
||
|
||
export const revalidate = 3600; // 页面每小时重新生成一次
|
||
|
||
export async function generateStaticParams() {
|
||
// 构建时生成前100篇文章的静态页面
|
||
const posts = await db.post.findMany({ take: 100, select: { slug: true } });
|
||
return posts.map((post) => ({ slug: post.slug }));
|
||
}
|
||
|
||
// dynamicParams 默认为 true
|
||
// 未预生成的页面会在首次访问时动态生成并缓存
|
||
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
|
||
const { slug } = await params;
|
||
const post = await db.post.findUnique({ where: { slug } });
|
||
|
||
if (!post) notFound();
|
||
|
||
return (
|
||
<article>
|
||
<h1>{post.title}</h1>
|
||
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
|
||
</article>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 七、中间件
|
||
|
||
### 7.1 认证中间件
|
||
|
||
```tsx
|
||
// middleware.ts(项目根目录)
|
||
import { NextResponse } from 'next/server';
|
||
import type { NextRequest } from 'next/server';
|
||
|
||
// 需要认证的路由
|
||
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
|
||
|
||
export function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl;
|
||
|
||
// 检查认证 token
|
||
const token = request.cookies.get('auth-token')?.value;
|
||
|
||
// 保护路由:未登录重定向到登录页
|
||
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
|
||
if (!token) {
|
||
const loginUrl = new URL('/login', request.url);
|
||
loginUrl.searchParams.set('callbackUrl', pathname);
|
||
return NextResponse.redirect(loginUrl);
|
||
}
|
||
}
|
||
|
||
// 已登录用户访问登录页,重定向到仪表盘
|
||
if (pathname === '/login' && token) {
|
||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||
}
|
||
|
||
return NextResponse.next();
|
||
}
|
||
|
||
export const config = {
|
||
// 匹配所有路由,排除静态资源和 API
|
||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||
};
|
||
```
|
||
|
||
### 7.2 国际化中间件
|
||
|
||
```tsx
|
||
// middleware.ts
|
||
import { NextResponse } from 'next/server';
|
||
import type { NextRequest } from 'next/server';
|
||
import { match } from '@formatjs/intl-localematcher';
|
||
import Negotiator from 'negotiator';
|
||
|
||
const locales = ['zh-CN', 'en', 'ja'];
|
||
const defaultLocale = 'zh-CN';
|
||
|
||
function getLocale(request: NextRequest): string {
|
||
const headers = { 'accept-language': request.headers.get('accept-language') ?? '' };
|
||
const languages = new Negotiator({ headers }).languages();
|
||
return match(languages, locales, defaultLocale);
|
||
}
|
||
|
||
export function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl;
|
||
|
||
// 检查路径是否已包含 locale 前缀
|
||
const hasLocale = locales.some(
|
||
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
||
);
|
||
|
||
if (hasLocale) return NextResponse.next();
|
||
|
||
// 自动检测语言并重定向
|
||
const locale = getLocale(request);
|
||
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||
};
|
||
```
|
||
|
||
### 7.3 请求头 / 重定向
|
||
|
||
```tsx
|
||
// middleware.ts — 添加自定义请求头
|
||
export function middleware(request: NextRequest) {
|
||
const response = NextResponse.next();
|
||
|
||
// 安全头
|
||
response.headers.set('X-Frame-Options', 'DENY');
|
||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||
|
||
// 传递请求信息给 Server Components
|
||
response.headers.set('x-pathname', request.nextUrl.pathname);
|
||
response.headers.set('x-search-params', request.nextUrl.search);
|
||
|
||
return response;
|
||
}
|
||
```
|