范式转移:组件不再都跑在浏览器

React 19 把 Server Components(RSC)从实验特性推向稳定,这是自 Hooks 以来最大的一次范式变化。核心思想很简单:默认在服务端渲染组件,只有需要交互的部分才下发到客户端。

带来的直接收益:

  • 服务端组件可以直接访问数据库、文件系统、内部 API,不需要写 REST/GraphQL 中间层;
  • 依赖不再被打进浏览器 bundle,首屏 JS 体积显著下降;
  • 数据获取与渲染在同一处,天然避免瀑布流请求。

服务端组件:直接在组件里查数据库

// app/posts/page.tsx —— 默认是 Server Component
import { db } from "@/lib/db";
import LikeButton from "./like-button";

export default async function PostsPage() {
  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    take: 20,
  });

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.excerpt}</p>
          {/* 只有这个按钮会被打包到客户端 */}
          <LikeButton postId={p.id} initialLikes={p.likes} />
        </li>
      ))}
    </ul>
  );
}

注意:服务端组件不能有事件处理、不能用 useState/useEffect、不能访问 window。需要交互时提取一个带 "use client" 的子组件。

Actions:把表单提交变回“写函数”

// app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function createPost(prevState: any, formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  if (title.length < 3) {
    return { error: "标题至少 3 个字符" };
  }

  await db.post.create({ data: { title, content: String(formData.get("content")) } });
  revalidatePath("/posts");
  return { ok: true };
}
// app/posts/new-form.tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { createPost } from "./actions";

export default function NewForm() {
  const [state, formAction, pending] = useActionState(createPost, { ok: false });

  return (
    <form action={formAction}>
      <input name="title" placeholder="标题" />
      <textarea name="content" />
      <button disabled={pending}>{pending ? "提交中…" : "发布"}</button>
      {state.error && <p className="error">{state.error}</p>}
    </form>
  );
}

useActionState 统一了“提交中、成功、失败”三态,配合 pending 不再需要手写 loading 布尔值。

乐观更新:useOptimistic

"use client";

import { useOptimistic, startTransition } from "react";

export default function LikeButton({ postId, initialLikes }: Props) {
  const [likes, addOptimistic] = useOptimistic(initialLikes, (s, n: number) => s + n);

  return (
    <button
      onClick={() =>
        startTransition(async () => {
          addOptimistic(1);
          await fetch(`/api/like/${postId}`, { method: "POST" });
        })
      }
    >
      ♥ {likes}
    </button>
  );
}

use():在渲染中读取 Promise 与 Context

use() 是 React 19 新增的 API,与 Hook 不同,它可以在条件分支和循环中调用:

import { use, Suspense } from "react";

function Comments({ postId }: { postId: string }) {
  const comments = use(fetchComments(postId)); // 挂起,直到 resolve
  return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>;
}

export default function Page({ postId, showComments }: Props) {
  return (
    <article>
      {/* use 可以在条件里用,这是 Hook 做不到的 */}
      {showComments && (
        <Suspense fallback={<p>加载评论…</p>}>
          <Comments postId={postId} />
        </Suspense>
      )}
    </article>
  );
}

迁移清单

  1. forwardRef 可以删了:ref 现在作为普通 prop 传入函数组件。
  2. React.memo 不再是默认手段:React 编译器会自动做记忆化。
  3. useMemo/useCallback 可以大幅删减,交给编译器处理,代码更干净。
  4. 注意 "use client" 的传染性:一旦文件标记为客户端,其所有导入都会进入 bundle,尽量把边界下沉到叶子组件。

小结

React 19 的方向是“把复杂度从开发者手里收回框架”:数据获取回到服务端、表单回到函数、记忆化交给编译器。理解 Server/Client 边界是使用好它的唯一关键。