React 19 与 Server Components 深度实践

前端工程

我们团队踩过的第一个 RSC 坑

去年把一个 CRA 老项目往 Next.js App Router 迁,我信心满满地把整个页面标成异步组件,直接在组件里 await fetch。本地跑得好好的,一上预发就白屏。查了半小时,才发现我把一个用了 onClick 的按钮连同它的父组件一起留在了服务端——服务端组件里根本不存在事件这个概念。

那次事故让我明白:RSC 不是「换个写法」,而是把「哪段代码跑在哪台机器上」重新想了一遍。下面把这两年踩的坑和验证过的做法摊开讲。


一、React 19 到底改了什么

先说纯粹的客户端侧变化,这些和 RSC 无关,但迁移时一定会碰到。

use():终于能读 Promise 和 Context 了

use() 可以在渲染中直接 await 一个 Promise,也能读 Context,而且支持在条件语句里调用(这是和 useContext 最大的区别)。

import { use } from "react";

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise); // 直接拿到结果,不用 useEffect
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}

Actions:把"提交→等待→刷新"变成一等公民

以前要自己管 isPendinguseState、表单重置。现在 useActionState + 服务端 Action 一把梭:

"use server";
async function subscribe(prevState: { ok: boolean }, formData: FormData) {
  const email = formData.get("email");
  // 直接跑在服务端,能碰数据库/密钥
  await db.users.subscribe(String(email));
  return { ok: true };
}
"use client";
import { useActionState } from "react";

function Form() {
  const [state, formAction, pending] = useActionState(subscribe, { ok: false });
  return (
    <form action={formAction}>
      <input name="email" />
      <button disabled={pending}>{pending ? "提交中…" : "订阅"}</button>
      {state.ok && <p>已订阅</p>}
    </form>
  );
}

useOptimistic:乐观更新不再手写

const [optimistic, addOptimistic] = useOptimistic(
  messages,
  (cur, next: string) => [...cur, { text: next, sending: true }]
);

两个小但爽的改动

  • ref 可以作为普通 prop 传递,不再需要 forwardRef(老 API 仍可用)。
  • 原生 <form><meta><title><link> 可以直接写在组件里,React 会自动提升到 <head>

二、RSC 到底是什么:运行模型

Server Component 是在构建时或服务端渲染的组件,它的代码不会进入浏览器 bundle。它返回的不是 DOM,而是一种特殊的序列化格式(RSC Payload),由客户端 React 拼接。

几个反直觉的点:

  • 服务端组件里可以 import 巨大的服务端-only 库(比如直接连 DB 的驱动),因为这部分代码根本不下发。
  • 服务端组件没有 useStateuseEffect、浏览器事件——它只在请求到达时跑一次。
  • 客户端组件(标了 "use client")会被打包进 JS,在服务端也会先渲染出初始 HTML。

画个心智模型:

请求 ──▶ 服务端组件树(跑在 Node)
            │ 遇到 "use client" 边界
            ▼
       序列化 RSC Payload ──▶ 浏览器
                               │ 客户端组件 hydrate
                               ▼
                         可交互的页面

三、'use client' 边界怎么划

这是迁移时最费脑子的地方。原则只有一条:客户端边界要尽可能靠近叶子节点

// 服务端组件(默认)
import { db } from "@/server/db";
import LikeButton from "./LikeButton"; // 这是个客户端组件

export default async function Post({ id }: { id: string }) {
  const post = await db.posts.find(id); // 直接查库,安全
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton initial={post.likes} /> {/* 只有这个按钮需要交互 */}
    </article>
  );
}
"use client";
export default function LikeButton({ initial }: { initial: number }) {
  const [likes, setLikes] = useState(initial);
  return <button onClick={() => setLikes(v => v + 1)}>👍 {likes}</button>;
}

常见错误:把整个页面标成 "use client"。这样你就退回到了纯 CSR,RSC 的全部收益(更小的 bundle、直连数据源)都没了。只在真正需要交互、浏览器 API、useState 的最小组件上加 "use client"


四、在服务端组件里直接拿数据

不用再写 getServerSideProps 那套。组件本身是 async 的:

export default async function Dashboard() {
  const [orders, users] = await Promise.all([
    fetch("https://api/internal/orders").then(r => r.json()),
    fetch("https://api/internal/users").then(r => r.json()),
  ]);
  return <Charts orders={orders} users={users} />;
}

注意:返回给客户端组件用的 props,必须是可序列化的——函数、Map、class 实例都不行。需要传函数?把它留在客户端组件里定义。


五、流式渲染与 Suspense

大数据查询慢,但我们不想让整个页面干等。用 Suspense 把慢的部分"挖"出来单独加载:

import { Suspense } from "react";

export default function Page() {
  return (
    <main>
      <h1>报表中心</h1>
      <Suspense fallback={<Skeleton />}>
        <SlowReport /> {/* 这个组件内部 await 慢查询 */}
      </Suspense>
    </main>
  );
}

服务端会先吐出 <h1> 和骨架屏,等 SlowReport 的数据就绪,再把这个"洞"补上(流式)。用户感知到的首屏快了很多。


六、缓存:fetch 默认就被缓存了

RSC 环境下,fetch 默认带缓存(等同于 cache: 'force-cache')。这在多数读场景是好事,但要清楚怎么关:

// 每次请求都拿最新
fetch(url, { cache: "no-store" });

// 带时间窗口的重新验证
fetch(url, { next: { revalidate: 60 } }); // 60 秒内用缓存

需要跨请求复用、且不在 fetch 上的计算,用 React.cache()

import { cache } from "react";

export const getUser = cache(async (id: string) => {
  return db.users.find(id); // 同一次渲染里多次调用只查一次库
});

七、迁移真实项目的几个坑

  1. Context 只能从客户端组件往下传。在服务端组件里定义 Context Provider 会报错——把 Provider 放到客户端边界内。
  2. 事件处理器不能从服务端组件传下去。需要 onClick?那一层必须是客户端组件。
  3. window/document/localStorage 在服务端是 undefined。所有浏览器 API 的访问必须放在 "use client" 组件、useEffect 或事件里。
  4. 第三方库不一定兼容。遇到"exports were not found"或水合报错,先看它有没有 "use client" 友好的版本,或把它包进你自己的客户端组件里再引入。
  5. 别在服务端组件里写 useState。一写就编译不过,提醒你边界划错了。

八、什么时候其实不该用 RSC

RSC 不是银弹。以下场景纯客户端可能更合适:

  • 强交互、弱数据的工具型页面(富文本编辑器、画板、IDE 类)。
  • 纯静态、几乎无后端依赖的营销页(用 SSG/静态导出更轻)。
  • 团队对服务端渲染心智模型还不熟、又赶工期时,强上 RSC 容易写出既慢又难维护的"四不像"。

常见问题

Q1:RSC 和 SSR 是一回事吗?

不是。SSR 是把客户端组件在服务端渲染成 HTML(代码仍会进 bundle)。RSC 是组件根本不进浏览器,运行在服务端。两者常一起用:RSC 负责数据/结构,SSR 负责首屏 HTML。

Q2:客户端组件的 props 为什么不能传函数?

因为 props 要经过 RSC Payload 序列化,函数无法序列化。需要行为?在客户端组件内部自己定义,或通过 Actions 触发服务端逻辑。

Q3:'use client' 写在整个文件顶部,那文件里所有组件都变客户端了?

是的。一个文件只能有一个边界,"use client" 让整个模块成为客户端模块。所以尽量让客户端组件独立成小文件,服务端组件去 import 它们。

Q4:React 19 能直接用在老项目里吗?

客户端特性(useuseOptimistic 等)升 React 版本即可。但要享受 RSC,需要配套的框架(Next.js App Router、或支持 RSC 的元框架)。裸 webpack 项目用不了 RSC。

Q5:水合(hydration)不匹配怎么排查?

典型症状是服务端渲染的文本和客户端首次渲染不一致(如时间戳、随机数、toLocaleString)。把这类值在 useEffect 里再赋值,或对「依赖客户端的信息」做骨架屏占位。


工具推荐

折腾 RSC 和组件时,这几个 ToolsKu 工具很好用:

  • 在线代码运行 — 快速验证一段 TSX 片段的运行时行为
  • Mermaid 预览 — 把客户端/服务端组件树画成流程图对齐团队认知
  • JSON 格式化 — 看清楚从服务端组件往下传的 props 到底序列成了什么

RSC 的本质,是把「代码运行在哪里」这个被 SPA 时代藏起来的问题,重新摆回你面前。想清楚边界,它就是你包体积的救星;想不清楚,它就是新的混乱源头。

本站提供浏览器本地工具,免注册即可试用 →

#React 19#Server Components#SSR#前端工程#Next.js