← 返回目录

第四章:数据获取

fetch、Server Actions 与缓存策略

1. 服务端数据获取

在 App Router 中,默认导出的页面与布局组件可以是 异步 Server Components。这意味着你可以像写普通异步函数一样,在组件顶层直接使用 async/await 拉取数据,而不必把数据获取塞进 useEffect

Next.js 对浏览器原生的 fetch 做了扩展:除了标准参数外,还支持 cachenext.revalidatenext.tags 等选项,用于与全路由缓存、按需失效策略对齐。

下面示例在 async 页面组件中请求 JSON 并渲染列表(路径可按项目调整):

type Post = { id: number; title: string };

async function getPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5", {
    cache: "force-cache",
  });
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
}

export default async function Page() {
  const posts = await getPosts();

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold">文章列表</h1>
      <ul className="mt-4 space-y-2">
        {posts.map((post) => (
          <li key={post.id} className="border-b pb-2">
            <span className="font-mono text-gray-500">#{post.id}</span> {post.title}
          </li>
        ))}
      </ul>
    </main>
  );
}

💡 要点

Server Components 中可以直接 await fetch(...),数据在服务端解析后再把 HTML/RSC 载荷发给客户端,无需 useEffect 与客户端二次请求(除非你需要浏览器端交互或订阅)。

有 React SPA 经验时

在纯 CSR 应用中,常见模式是 useEffect + fetch;在 Next.js App Router 中,优先把可静态或服务端完成的读取放在 Server Component,减少客户端 JS 与水合成本。

2. 缓存策略

fetch 的第二个参数可控制数据缓存行为:

此外可使用 next: { revalidate: number }基于时间的 revalidation(ISR 风格):在指定秒数后,下次访问会触发后台重新验证。

三种典型写法:

// 1) 默认:静态缓存(构建期或首次请求后复用,具体行为见官方 Data Cache 说明)
await fetch("https://api.example.com/items", { cache: "force-cache" });

// 2) 禁用缓存:始终动态获取
await fetch("https://api.example.com/me", { cache: "no-store" });

// 3) 时间窗口 revalidate:每 60 秒最多后台刷新一次
await fetch("https://api.example.com/feed", {
  next: { revalidate: 60 },
});

与 Route Handler 或外部工具联调时,可用 curl 快速验证接口(与 Next 缓存无关,仅便于调试):

curl -sS "https://jsonplaceholder.typicode.com/posts/1" | head -c 200

POST 请求体常用 JSON 格式,例如创建资源:

{
  "title": "Hello Next.js 15",
  "body": "Server Actions 与 fetch 缓存",
  "userId": 1
}
方式 典型场景 行为概要
cache: 'force-cache' 博客文章、文档站公开内容 尽量命中 Data Cache,减少上游压力。
cache: 'no-store' 仪表盘、个性化接口 不做持久化缓存,每次执行都拉新数据。
next: { revalidate: n } 新闻列表、价格等需定期刷新 在 n 秒周期内可复用,过期后后台更新。

3. Server Actions

Server Actions 是在服务端执行的异步函数,用于处理表单提交、数据库写入、调用内部 API 等,无需单独暴露 HTTP 路由(编译器会生成安全端点)。

独立文件示例(src/app/actions.ts):

'use server';

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") ?? "");
  const body = String(formData.get("body") ?? "");
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, body, userId: 1 }),
  });
  if (!res.ok) {
    throw new Error("createPost failed");
  }
  return res.json();
}

在 Server Component 中内联定义并绑定到表单(简化演示):

export default async function Page() {
  async function submit(formData: FormData) {
    "use server";
    const name = String(formData.get("name") ?? "");
    // 此处可写入数据库或调用内部服务
    console.log("server received:", name);
  }

  return (
    <form action={submit}>
      <input name="name" className="border px-2" />
      <button type="submit">提交</button>
    </form>
  );
}

4. 表单与 Server Actions

原生 <form>action 可直接绑定 Server Action:提交时由服务端执行,天然支持渐进增强(无 JS 也能提交)。

完整示例:Server Action 返回消息 + 客户端展示 pending 与 state(拆成 Client 子组件以满足 hooks 规则):

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

export type FormState = { ok: boolean; message: string };

export async function subscribe(
  _prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return { ok: false, message: "邮箱格式不正确" };
  }
  // 模拟写入
  return { ok: true, message: "已订阅:" + email };
}
// app/ui/subscribe-form.tsx
'use client';

import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
import { subscribe, type FormState } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="rounded bg-sky-600 px-4 py-2 text-white disabled:opacity-50"
    >
      {pending ? '提交中…' : '订阅'}
    </button>
  );
}

const initialState: FormState = { ok: false, message: '' };

export function SubscribeForm() {
  const [state, formAction] = useActionState(subscribe, initialState);

  return (
    <form action={formAction} className="space-y-2">
      <input
        name="email"
        type="email"
        placeholder="you@example.com"
        className="w-full rounded border px-3 py-2"
        required
      />
      <SubmitButton />
      {state.message ? (
        <p className={state.ok ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      ) : null}
    </form>
  );
}
// app/page.tsx(Server Component 中引用客户端表单)
import { SubscribeForm } from '@/app/ui/subscribe-form';

export default function Page() {
  return (
    <main className="p-8">
      <h1 className="text-xl font-bold">邮件订阅</h1>
      <SubscribeForm />
    </main>
  );
}

5. 数据获取模式

同一页面需要多个接口时,优先分析依赖关系:无依赖的请求应并行,有依赖的再串行。

并行 vs 顺序:

import { cache } from 'react';

const getUser = cache(async (id: string) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
    cache: 'no-store',
  });
  return res.json();
});

export default async function DashboardPage() {
  // 并行:用户与文章列表互不依赖
  const [user, posts] = await Promise.all([
    getUser('1'),
    fetch('https://jsonplaceholder.typicode.com/posts?userId=1', {
      cache: 'no-store',
    }).then((r) => r.json()),
  ]);

  // 顺序:先取 post 再取评论(示例)
  const firstPost = posts[0];
  const comments = firstPost
    ? await fetch(
        `https://jsonplaceholder.typicode.com/posts/${firstPost.id}/comments`,
        { cache: 'no-store' }
      ).then((r) => r.json())
    : [];

  return (
    <section className="p-8">
      <h1 className="text-2xl font-bold">{user.name}</h1>
      <p className="text-gray-600">{user.email}</p>
      <h2 className="mt-6 font-semibold">评论数:{comments.length}</h2>
    </section>
  );
}

💡 要点

尽量并行获取互不依赖的数据,避免在 Server Component 中写成长串无必要的 await 瀑布流;对重复调用的纯函数请求可用 cache() 收敛。

6. 按需 Revalidation

当 Server Action 或 Route Handler 中写入了新数据,你可能希望立即让某段缓存失效,而不是等到 revalidate 时间窗。Next.js 提供:

在 Server Action 更新后调用 revalidatePath

'use server';

import { revalidatePath } from 'next/cache';

export async function addItem(formData: FormData) {
  const title = String(formData.get('title') ?? '');
  // await db.item.create({ data: { title } })
  revalidatePath('/items');
}

fetch 打上 tag,并在变更后 revalidateTag

// 读取侧(Server Component 或共享数据模块)
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});
const posts = await res.json();
'use server';

import { revalidateTag } from 'next/cache';

export async function refreshPosts() {
  revalidateTag('posts');
}

配合策略:列表页用 tag 聚合缓存,详情页也可用 revalidatePath('/posts/[id]') 精确刷新;按项目一致约定即可。

7. 本章要点

服务端拉数

Server Component 中直接使用 async/await 与扩展后的 fetch,理解默认缓存与 no-storerevalidate

Server Actions

'use server' 声明服务端函数;表单 action= 绑定;复杂逻辑放到独立 actions.ts

表单 UX

useFormStatus 处理 pending;useActionState 处理上一次返回值与校验错误。

模式与失效

Promise.all 并行、cache() 去重;写入后用 revalidatePath / revalidateTag 按需刷新。