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 是否包含了本该留在服务端的数据逻辑。

第二类是试图在服务端组件里使用客户端能力。useStateuseEffectonClick、浏览器 API 都会报错。判断方法是问:这个组件需要响应用户交互吗?需要状态吗?需要访问浏览器吗?如果都不需要,它应该是服务端组件。

第三类是忽略序列化约束。传递函数、类实例或复杂对象给客户端组件,导致运行时错误。判断方法是检查所有跨边界 props 是否只包含可序列化值。

第四类是数据获取重复。多个服务端组件各自请求同一份数据,没有使用缓存或提升到父级。判断方法是看网络请求日志,是否存在同一请求多次发出。

第五类是缓存策略缺失。所有数据都用默认缓存,导致内容不更新;或者全部 no-store,导致性能下降。判断方法是逐条列出数据源,标注变化频率和失效方式。

迁移路径:从客户端组件到混合树

对于已有项目,不建议一次性全量迁移到 RSC。更现实的路径是:

  • 先把纯展示、无交互的组件改为服务端组件。

  • 把数据获取从 useEffect 迁移到服务端组件的 await

  • 把交互部分抽成独立的客户端组件,下沉到叶子。

  • 为动态数据设计缓存和失效策略。

  • 用 Server Action 替代部分客户端提交逻辑。

  • 逐步减少顶层 'use client',缩小客户端 bundle。

迁移过程中,最重要的是建立团队对边界的共识。哪些组件在服务端,哪些在客户端,数据如何流动,缓存由谁负责。这些决策一旦明确,RSC 的优势才能稳定发挥。否则,它只会变成另一种“能跑但没人懂”的框架特性。

© 版权声明
演示站内容均来自互联网,如有侵权,请与我联系

文章不错?点个赞呗~