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:把"提交→等待→刷新"变成一等公民
以前要自己管 isPending、useState、表单重置。现在 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 的驱动),因为这部分代码根本不下发。 - 服务端组件没有
useState、useEffect、浏览器事件——它只在请求到达时跑一次。 - 客户端组件(标了
"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); // 同一次渲染里多次调用只查一次库
});
七、迁移真实项目的几个坑
- Context 只能从客户端组件往下传。在服务端组件里定义 Context Provider 会报错——把 Provider 放到客户端边界内。
- 事件处理器不能从服务端组件传下去。需要
onClick?那一层必须是客户端组件。 window/document/localStorage在服务端是 undefined。所有浏览器 API 的访问必须放在"use client"组件、useEffect或事件里。- 第三方库不一定兼容。遇到"exports were not found"或水合报错,先看它有没有
"use client"友好的版本,或把它包进你自己的客户端组件里再引入。 - 别在服务端组件里写
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 能直接用在老项目里吗?
客户端特性(use、useOptimistic 等)升 React 版本即可。但要享受 RSC,需要配套的框架(Next.js App Router、或支持 RSC 的元框架)。裸 webpack 项目用不了 RSC。
Q5:水合(hydration)不匹配怎么排查?
典型症状是服务端渲染的文本和客户端首次渲染不一致(如时间戳、随机数、toLocaleString)。把这类值在 useEffect 里再赋值,或对「依赖客户端的信息」做骨架屏占位。
工具推荐
折腾 RSC 和组件时,这几个 ToolsKu 工具很好用:
- 在线代码运行 — 快速验证一段 TSX 片段的运行时行为
- Mermaid 预览 — 把客户端/服务端组件树画成流程图对齐团队认知
- JSON 格式化 — 看清楚从服务端组件往下传的 props 到底序列成了什么
RSC 的本质,是把「代码运行在哪里」这个被 SPA 时代藏起来的问题,重新摆回你面前。想清楚边界,它就是你包体积的救星;想不清楚,它就是新的混乱源头。
本站提供浏览器本地工具,免注册即可试用 →