前置知识: React

Next.js App Router

1 min高级

Next.js App Router详解:文件夹约定、布局、加载态、错误态。

1. 文件夹约定

1.1 路由结构

app/
  layout.tsx          # 根布局
  page.tsx            # 首页 /
  loading.tsx         # 全局加载态
  error.tsx           # 全局错误态
  not-found.tsx       # 404 页面
  about/
    page.tsx          # /about
  blog/
    layout.tsx        # /blog 布局
    page.tsx          # /blog
    [slug]/
      page.tsx        # /blog/:slug

1.2 特殊文件

文件用途
layout.tsx共享布局
page.tsx路由页面
loading.tsx加载状态
error.tsx错误处理
not-found.tsx404
template.tsx重新挂载的布局
default.tsx并行路由默认

2. 布局嵌套

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <nav>导航</nav>
        {children}
      </body>
    </html>
  );
}

// app/blog/layout.tsx
export default function BlogLayout({ children }) {
  return (
    <div className="blog-layout">
      <Sidebar />
      {children}
    </div>
  );
}

3. 加载态

// app/blog/loading.tsx
export default function Loading() {
  return <Skeleton />;
}

Next.js 自动用 Suspense 包裹页面,显示 loading.tsx。

4. 错误态

// app/error.tsx
'use client';

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>出错了</h2>
      <button onClick={reset}>重试</button>
    </div>
  );
}

5. 数据获取

// Server Component 中直接 async
async function Page() {
  const data = await fetch('https://api.example.com/data');
  return <div>{data.title}</div>;
}

文件约定 (File Conventions)

layout.tsx 布局 app/<segment>/layout.tsx

export default function Layout({ children }: { children: React.ReactNode }) {
  return <section>{children}</section>;
}

page.tsx 页面 app/<segment>/page.tsx

export default function Page() {
  return <h1>Home</h1>;
}

loading.tsx 加载态 app/<segment>/loading.tsx

export default function Loading() {
  return <Spinner />;
}

error.tsx 错误边界 app/<segment>/error.tsx

'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

not-found.tsx 404 页面 app/<segment>/not-found.tsx

export default function NotFound() {
  return <h1>页面不存在</h1>;
}

template.tsx 模板 app/<segment>/template.tsx

export default function Template({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>;
}

default.tsx 默认插槽 app/<segment>/default.tsx

export default function Default() {
  return <p>默认内容</p>;
}

route.ts API 路由 app/api/<name>/route.ts

export async function GET(request: Request) {
  return Response.json({ ok: true });
}

export async function POST(request: Request) {
  const body = await request.json();
  return Response.json(body, { status: 201 });
}

middleware.ts 中间件 middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

动态路由文件

动态路由 [param] app/users/[id]/page.tsx

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <h1>User {id}</h1>;
}

catch-all […slug] app/docs/[...slug]/page.tsx

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  return <p>{slug.join('/')}</p>;
}

catch-all 可选 [[…slug]] app/docs/[[...slug]]/page.tsx

export default async function Page({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  return <p>{slug?.join('/') ?? 'home'}</p>;
}

async params / searchParams

page props 类型

type PageProps = {
  params: Promise<{ [key: string]: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export default async function Page({ params, searchParams }: PageProps) {
  const { id } = await params;
  const { q } = await searchParams;
  return <div>{id} - {q}</div>;
}

cookies / headers

cookies 服务端 import { cookies } from 'next/headers';

import { cookies } from 'next/headers';

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token')?.value;
  return <p>{token}</p>;
}

cookies 设置

const cookieStore = await cookies();
cookieStore.set('theme', 'dark', {
  httpOnly: true,
  secure: true,
  maxAge: 60 * 60 * 24 * 7,
  path: '/',
});

headers 服务端 import { headers } from 'next/headers';

import { headers } from 'next/headers';

export default async function Page() {
  const headerList = await headers();
  const userAgent = headerList.get('user-agent');
  return <p>{userAgent}</p>;
}

Server Actions

‘use server’

// app/actions.ts
'use server';

export async function createItem(formData: FormData) {
  const title = formData.get('title') as string;
  await db.items.create({ data: { title } });
}

// 调用
'use client';
import { createItem } from '@/app/actions';

function Form() {
  return (
    <form action={createItem}>
      <input name="title" />
      <button type="submit">创建</button>
    </form>
  );
}

inline server action

export default function Page() {
  async function submit(formData: FormData) {
    'use server';
    await db.items.create({ data: { title: formData.get('title') as string } });
  }
  return <form action={submit}><input name="title" /><button>OK</button></form>;
}

Layout / Page 元数据

metadata 静态 export const metadata: Metadata = {...}

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '用户中心',
  description: '用户信息管理',
  openGraph: { images: ['/og.png'] },
};

generateMetadata 动态 export async function generateMetadata({ params }): Promise<Metadata>

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const user = await getUser(id);
  return { title: user.name };
}

useRouter import { useRouter } from 'next/navigation';

'use client';
import { useRouter } from 'next/navigation';

export default function Page() {
  const router = useRouter();
  return (
    <button onClick={() => router.push('/login')}>登录</button>
    <button onClick={() => router.back()}>返回</button>
    <button onClick={() => router.refresh()}>刷新</button>
  );
}

usePathname / useSearchParams

'use client';
import { usePathname, useSearchParams } from 'next/navigation';

function Breadcrumb() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const q = searchParams.get('q');
  return <span>{pathname}{q ? `?q=${q}` : ''}</span>;
}

Link <Link href=<path> [prefetch]>...</Link>

import Link from 'next/link';

<Link href="/dashboard">控制台</Link>
<Link href={{ pathname: '/users', query: { id: '1' } }}>用户</Link>
<Link href="/about" prefetch={false}>关于</Link>

Image 优化图片 <Image src=<src> alt=<alt> [width] [height] [fill] />

import Image from 'next/image';

<Image src="/logo.png" alt="Logo" width={120} height={40} />
<Image src={user.avatar} alt={user.name} fill sizes="(max-width: 768px) 100vw" />

generateStaticParams

静态参数生成 export async function generateStaticParams()

export async function generateStaticParams() {
  const users = await db.users.findMany();
  return users.map(u => ({ id: u.id }));
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <h1>{id}</h1>;
}

Suspense 与流式渲染

Suspense 边界

import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <AsyncComponent />
    </Suspense>
  );
}

loading.tsx 等价

// app/dashboard/loading.tsx
export default function Loading() {
  return <div>加载中...</div>;
}