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

15 KiB
Raw Permalink Blame 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。

// 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

// 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

// 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 缓存策略

// 强制缓存(默认行为,等同于 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 并行数据获取

// 避免请求瀑布流,使用 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 表单处理

// 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 配合表单

'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

'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 动态路由

// 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 路由组

// 使用 (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 平行路由

// 使用 @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 拦截路由

// 使用 (.) (..) (...) 拦截路由,实现模态框效果
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 按需重新验证

// 方式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增量静态再生

// 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 认证中间件

// 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 国际化中间件

// 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 请求头 / 重定向

// 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;
}