bookworm-smart-assistant/skills/frontend-expert/references/nextjs-guide.md

532 lines
15 KiB
Markdown
Raw Normal View History

# 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;
}
```