React Server Components改变了组件的运行位置与数据获取方式。本文从服务端与客户端的边界划分、序列化约束、数据流模式、缓存策略、组合技巧与迁移路径等角度,梳理RSC落地时的核心判断与常见误区。
React Server Components 刚出现时,很多团队的第一反应是“这不就是把数据获取放到组件里吗”。真正开始用之后,问题才逐渐浮现:哪些组件该是服务端组件,哪些必须是客户端组件?为什么服务端组件不能传函数给客户端组件?为什么在服务端组件里用了 useState 就报错?为什么页面首次加载很快,交互却卡顿?RSC 不是简单的语法变化,而是组件运行位置、数据流向和渲染时机的重新划分。理解这些边界,比记住 'use client' 写在哪里更重要。
两种组件,两种运行环境
RSC 把组件分成两类。服务端组件在服务器上运行,可以直接访问数据库、文件系统、内部 API,不发送到浏览器,不参与客户端 hydration。客户端组件在浏览器上运行,可以使用状态、事件、生命周期和浏览器 API,但需要通过 JavaScript bundle 发送到客户端。
维度 | 服务端组件 | 客户端组件 |
|---|---|---|
运行位置 | 服务器 | 浏览器 |
能否访问数据库 | 可以 | 不可以 |
能否使用 useState | 不可以 | 可以 |
能否绑定 onClick | 不可以 | 可以 |
是否发送到浏览器 | 否 | 是 |
能否导入客户端组件 | 可以 | 可以 |
能否被客户端组件导入 | 不可以 | 可以 |
是否参与 hydration | 否 | 是 |
这张表里最关键的一行是最后两行。服务端组件可以渲染客户端组件,但客户端组件不能导入服务端组件。这个单向关系决定了整个应用的组件树结构:服务端组件是骨架,客户端组件是叶子或交互孤岛。如果反过来设计,把客户端组件放在上层,服务端组件的能力就被切断了。
'use client' 不是标签,是边界声明
很多团队把 'use client' 当作“这个文件需要交互”的标记,但它真正的含义是:从这一行开始,这个模块及其导入的模块都进入客户端 bundle。它是一个边界,不是一个开关。
这意味着 'use client' 应该尽量下沉到组件树的叶子。如果把它放在布局或页面顶层,整个子树都会变成客户端组件,服务端组件的优势全部消失。正确的做法是把交互部分抽成独立的小组件,在文件顶部加 'use client',然后由服务端组件导入。
tsx
// app/page.tsx (服务端组件)
import { LikeButton } from './like-button';
import { getPost } from '@/lib/posts';
export default async function Page({ params }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} initialCount={post.likes} />
</article>
);
}tsx
// app/like-button.tsx (客户端组件)
'use client';
import { useState } from 'react';
export function LikeButton({ postId, initialCount }: Props) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}服务端组件负责数据获取和静态内容,客户端组件只负责点赞交互。bundle 里只包含 LikeButton,不包含文章内容和数据逻辑。
序列化边界:能传什么,不能传什么
服务端组件向客户端组件传递 props 时,数据必须可以被序列化。这意味着函数、类实例、Symbol、DOM 节点、某些库对象都不能直接传递。很多“为什么报错”的问题都源于这条约束。
可以传递 | 不可以传递 | 替代方案 |
|---|---|---|
字符串、数字、布尔 | 函数 | 用 Server Action 或客户端定义 |
数组、普通对象 | 类实例 | 转成普通对象 |
Date、Map、Set | Symbol | 用字符串标识 |
Promise(有限支持) | DOM 节点 | 传选择器或 ID |
JSX(作为 children) | 含方法的对象 | 拆成可序列化字段 |
函数不能传递这一点影响最大。过去在客户端组件里,父组件可以传一个 onSelect 回调给子组件。在 RSC 中,如果父组件是服务端组件,它不能传函数。解决方案有两种:一是把交互逻辑下沉到客户端组件内部;二是使用 Server Actions,让客户端组件通过表单或事件调用服务端函数。
tsx
// 使用 Server Action
async function updateName(formData: FormData) {
'use server';
const name = formData.get('name');
await db.user.update({ name });
}
export default function Page() {
return (
<form action={updateName}>
<input name="name" />
<button type="submit">保存</button>
</form>
);
}Server Action 本质是一个可以被客户端调用的服务端函数引用,它绕过了“函数不能序列化”的限制,但代价是它必须是一个网络调用,不能用于同步渲染逻辑。
数据获取:从 useEffect 到直接 await
RSC 最直接的好处是数据获取。客户端组件通常需要 useEffect + useState + loading 状态,或者依赖 SWR、React Query 等库。服务端组件可以直接 await 数据,不需要 loading 状态,不需要客户端缓存,也不会有瀑布式请求。
tsx
// 服务端组件直接获取数据
export default async function Page() {
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
]);
return (
<main>
<UserProfile user={user} />
<PostList posts={posts} />
</main>
);
}但直接 await 也带来新的问题。如果页面里多个服务端组件各自获取数据,可能产生重复请求;如果某个请求很慢,整个页面会被阻塞;如果数据需要根据用户交互更新,服务端组件无法自行重新渲染。这些问题需要缓存策略和客户端组件配合解决。
一个实用的数据流模式是:服务端组件负责初始数据,客户端组件负责后续交互和增量更新。服务端组件把初始数据作为 props 传给客户端组件,客户端组件在需要时调用 API 或 Server Action 获取新数据。
数据场景 | 推荐方式 | 原因 |
|---|---|---|
首屏静态数据 | 服务端组件直接获取 | 无 loading,无客户端请求 |
用户个性化数据 | 服务端组件 + 缓存 | 避免客户端闪烁 |
高频交互数据 | 客户端组件 + API | 需要实时更新 |
表单提交 | Server Action | 服务端校验与写入 |
无限滚动 | 客户端组件 + API | 分页由交互驱动 |
实时数据 | 客户端组件 + WebSocket | 服务端组件无法推送 |
缓存与重新验证:RSC 的隐形复杂度
RSC 的缓存分为多个层次:请求记忆化、数据缓存、完整路由缓存、客户端路由缓存。每一层的失效策略不同,组合起来容易让人困惑。
fetch 在 Next.js 中默认被缓存,cache() 可以记忆化函数调用,revalidatePath 和 revalidateTag 用于按需失效。如果理解不清,会出现“数据更新了但页面没变”或“每次请求都重新获取,性能很差”两种极端。
一个可操作的心智模型是:把缓存分为“静态”和“动态”两类。静态数据用长时间缓存加标签失效;动态数据用 no-store 或短时间缓存。页面级别的 dynamic 和 revalidate 配置应该与数据的实际变化频率匹配,而不是统一设置。
tsx
// 静态数据,按标签失效
const posts = await fetch('/api/posts', {
next: { tags: ['posts'] },
});
// 动态数据,不缓存
const user = await fetch('/api/me', {
cache: 'no-store',
});团队应该约定:哪些数据是静态的,哪些是动态的,失效由谁触发。没有约定的缓存策略,最终会变成“谁也不敢改”的技术债。
组件组合:服务端与客户端如何嵌套
服务端组件和客户端组件的组合方式,决定了应用的灵活性和性能。基本规则是:服务端组件可以渲染客户端组件,客户端组件可以通过 children 接收服务端组件渲染的内容,但不能直接导入服务端组件。
这意味着,如果客户端组件需要展示服务端数据,可以通过 props 传入已经序列化的数据,或者通过 children 传入服务端渲染的 JSX。
tsx
// 客户端组件通过 children 接收服务端内容
'use client';
export function Tabs({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(0);
return (
<div>
<TabButtons active={active} onChange={setActive} />
{children}
</div>
);
}tsx
// 服务端组件组合
export default async function Page() {
const data = await getData();
return (
<Tabs>
<TabContent data={data} />
</Tabs>
);
}这种模式下,Tabs 是客户端组件,负责交互;TabContent 是服务端组件,负责数据渲染。两者通过 children 组合,既保留了交互能力,又避免了把数据获取逻辑发送到客户端。
常见误区与判断方法
RSC 落地中最常见的误区,可以归纳为几类。
第一类是把 'use client' 放得太高。布局、页面、大块内容都变成客户端组件,bundle 膨胀,服务端优势消失。判断方法是看构建产物,客户端 bundle 是否包含了本该留在服务端的数据逻辑。
第二类是试图在服务端组件里使用客户端能力。useState、useEffect、onClick、浏览器 API 都会报错。判断方法是问:这个组件需要响应用户交互吗?需要状态吗?需要访问浏览器吗?如果都不需要,它应该是服务端组件。
第三类是忽略序列化约束。传递函数、类实例或复杂对象给客户端组件,导致运行时错误。判断方法是检查所有跨边界 props 是否只包含可序列化值。
第四类是数据获取重复。多个服务端组件各自请求同一份数据,没有使用缓存或提升到父级。判断方法是看网络请求日志,是否存在同一请求多次发出。
第五类是缓存策略缺失。所有数据都用默认缓存,导致内容不更新;或者全部 no-store,导致性能下降。判断方法是逐条列出数据源,标注变化频率和失效方式。
迁移路径:从客户端组件到混合树
对于已有项目,不建议一次性全量迁移到 RSC。更现实的路径是:
先把纯展示、无交互的组件改为服务端组件。
把数据获取从
useEffect迁移到服务端组件的await。把交互部分抽成独立的客户端组件,下沉到叶子。
为动态数据设计缓存和失效策略。
用 Server Action 替代部分客户端提交逻辑。
逐步减少顶层
'use client',缩小客户端 bundle。
迁移过程中,最重要的是建立团队对边界的共识。哪些组件在服务端,哪些在客户端,数据如何流动,缓存由谁负责。这些决策一旦明确,RSC 的优势才能稳定发挥。否则,它只会变成另一种“能跑但没人懂”的框架特性。
演示站内容均来自互联网,如有侵权,请与我联系
文章不错?点个赞呗~