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

532 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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