Appearance
从零开始理解React Server Components
自从2020年底React团队首次公布这个概念,到现在已经快4年了,期间争议不断。有人说它是React的未来,有人说它把简单的事情搞复杂了。
作为一个在生产环境中使用过RSC的开发者,我想分享一些实际的使用心得。不管你是否认同Vercel的商业策略,RSC确实已经成为React官方力推的技术方向。今天咱们就来聊聊:RSC到底是个啥?为什么React要搞这么个东西?我们又该怎么用好它?
虽然现在Remix、Waku这些框架也在支持RSC,但说实话,Next.js在这方面确实走得最前面,所以文章里的例子主要基于Next.js。
RSC到底是什么东西
简单来说,React Server Components就是一种只在服务器上跑的React组件。在传统的React组件,不管是SSR还是CSR,最终都要在浏览器里被激活(hydrate)。但RSC不一样,它不会出现在浏览器里。服务器渲染完之后,会生成一种特殊的数据格式(叫RSC Payload),然后流式传输给浏览器,浏览器的React再把这些数据"翻译"成真正的DOM。
这种设计带来了几个很有意思的特性
全新的组件类型
有了RSC之后,React组件的类型就发生了变化,从原来的一种变成了三种:
组件类型 | 文件扩展名 | 运行环境 | 主要特点 |
---|---|---|---|
服务器组件 (Server Component) | *.server.ts | 服务器 | 零客户端包体:其代码和依赖库完全不进入客户端的 JavaScript bundle。 直接访问后端资源:可以像 Node.js 程序一样直接访问数据库、文件系统、内部 API 等。 - 无状态、无交互:不能使用 useState 、useEffect 等 Hooks,也不能绑定事件监听器。 |
客户端组件 (Client Component) | *.client.ts | 服务器 (SSR/SSG) + 客户端 | 传统的 React 组件:我们所熟悉的一切,包含状态、生命周期、交互逻辑。 代码被打包:其代码和依赖项会发送到客户端。 在客户端“水合” (Hydrate):在浏览器中变得可交互。 |
共享组件 (Shared Component) | *.ts | 服务器 + 客户端 | - 同构组件:可以在两种环境中运行,但必须遵守双方的约束。- 不能包含特定环境的 API:例如,不能在共享组件中使用 Node.js 的 fs 模块,也不能使用浏览器的 window 对象。 |
零客户端包体(Zero Client Bundle)
零客户端包体这个名词听起来有点怪,我们拆开来理解:客户端包体指的是需要下载到用户浏览器中执行的JavaScript代码包。而零客户端包体指服务器组件的代码压根不会出现在浏览器里,在构建出来 浏览器 javascript产物中不会包含这部分内容。
从react官网上抄来的一个例子:
按照以前的做法,如果要在要在页面上显示一篇Markdown文章,我们需要这样写代码:
javascript
// Post.client.js - 传统方式
import { useState, useEffect } from 'react';
import marked from 'marked'; // 一个 50KB+ 的库
function Post({ postId }) {
const [markdown, setMarkdown] = useState('');
useEffect(() => {
fetch(`/api/posts/${postId}`)
.then(res => res.text())
.then(text => setMarkdown(text));
}, [postId]);
const html = marked(markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
看到问题了吗?marked
这个50KB+的库会被打包到客户端,用户每次访问都得下载。尽管我们也可以通过 import动态引入
javascript
// Post.client.js - 使用动态导入优化
import { useState, useEffect, Suspense, lazy } from 'react';
const MarkdownRenderer = lazy(() =>
import('./MarkdownRenderer')
// MarkdownRenderer.js 内部会 import marked 并导出渲染逻辑
);
function Post({ postId }) {
const [markdown, setMarkdown] = useState('');
useEffect(() => {
// 假设从 API 获取 Markdown 文本
fetch(`/api/posts/${postId}`)
.then(res => res.text())
.then(text => setMarkdown(text));
}, [postId]);
return (
<div>
{/* 2. 使用 Suspense 包裹,提供加载中的后备 UI */}
<Suspense fallback={<div>正在加载渲染器...</div>}>
<MarkdownRenderer content={markdown} />
</'Suspense'>
</div>
);
}
它确实优化了初始加载。marked 这个库不会被打包进主 JS 文件(main.js)里。只有当 Post 组件被渲染时,浏览器才会去下载一个包含 marked 库的、独立的 JavaScript 文件(例如 MarkdownRenderer-chunk.js),然后在浏览器中执行它,最后渲染出 HTML。单实际上其还是需要引入、下载、运行 marked
,只是其运行在浏览器中,而不是在服务器中。
明明只是看个文章,为啥要引入、下载、运行Markdown解析器?想象一下,你在服务器组件里引入了一个500KB的Markdown解析库,但用户下载的JS包里完全没有这个库的代码。因为引入、下载、运行工作已经在服务器完成了,浏览器只需要接收最终的HTML结果。是不是很棒!
javascript
// Post.server.js - RSC 方式
import { promises as fs } from 'fs'; // 直接访问文件系统
import path from 'path';
import marked from 'marked'; // 这个库只在服务器上运行
async function Post({ postId }) {
// 直接从数据库或文件系统读取数据,无需 API
const content = await fs.readFile(path.join(process.cwd(), `posts/${postId}.md`), 'utf8');
// 在服务器上完成转换
const html = marked(content);
// 渲染结果发送到客户端
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
这样做的好处立竿见影:marked
库不会在浏览器中下载和运行,服务器把活儿干完,页面加载快了,特别是那些用了很多大型库但不需要交互的组件,效果立竿见影。
RSC !== SSR
很多人包括我刚开始一听到服务端组件,下意识的将其和SSR联系起来,但是后面经过详细的了解,才发现二者有着本质的区别,虽然都在服务器上运行,但解决的问题和工作方式完全不同。
- | 传统SSR | React Server Components (RSC) |
---|---|---|
渲染时机 | 每次请求时在服务器渲染HTML | 构建时或请求时渲染组件树 |
输出格式 | HTML字符串 | RSC Payload(特殊的序列化格式) |
客户端包体 | 包含所有组件代码 | 仅包含客户端组件代码 |
数据获取 | 通过API或在渲染前获取 | 组件内直接访问后端资源 |
水合过程 | 整页水合 | 选择性水合 |
主要目标 | 提升首屏渲染速度 | 减少客户端包体积和优化数据流 |
RSC解决了什么问题
说实话,从推出到现在,RSC都带来了不少的争议,在社区吵得不可开交,抛开争议不谈,它确实解决了几个问题。
bundle体积包越发膨胀
你有没有遇到过这种情况:项目刚开始的时候bundle才几十上百KB,后面随着业务需求不停的迭代,我们引入UI库、状态管理、工具库等等,随之带来的就是bundle size的膨胀。
当然我们也有一些优化手段,比如代码分割和Tree Shaking,但说实话,这些还是有些局限性:
- 代码分割:用
React.lazy
和动态import()
按需加载。听起来不错,但其实只是把下载时间推迟了,浏览器还是需要下载、运行、解析。 - Tree Shaking:删掉没用的代码。这个确实有用,但问题是很多库你确实在用啊!哪怕只是为了格式化一个日期,整个日期库还是得打包进去。
RSC的思路就很直接:既然这个组件不需要交互,那它就不会分发到浏览器上?
就像前面marked
的例子,所有只是用来"算个数、显示个结果"的代码都留在服务器就行了。这样用户下载的JS包就只包含真正需要交互的部分,包体积能小很多。对LCP、TTI这些性能指标的提升是实实在在的。
数据请求瀑布
除了包体积,还有一个更让人头疼的问题:数据请求瀑布。简单来说,在业务开发中,往往会出现数据依赖的问题,组件A要等组件B的数据,组件B要等组件C的数据,串行的请求往往会拖慢页面的加载,让我们来看一个非常经典的例子:
在上面的例子中,文章数据必须等用户数据拿到了才能请求,这是传统CSR的问题。当然,也许我们可以使用 Promise.all
一次性获取所有数据,但这样父组件就得知道所有子组件要什么数据,组件封装性就被破坏掉了。
RSC就不一样了,因为在服务器上跑,组件可以直接写成async
函数,数据获取和渲染合二为一。
javascript
// Page.server.js
// 假设这是两个不同的数据获取函数
import { getUser } from '@/lib/data';
import { getPosts } from '@/lib/data';
import UserProfile from './UserProfile.server';
import UserPosts from './UserPosts.server';
export default async function Page({ userId }) {
// 在服务器上,数据请求可以并行发起
const userPromise = getUser(userId);
const postsPromise = getPosts(userId);
// 等待所有数据返回
const user = await userPromise;
const posts = await postsPromise;
return (
<div>
<UserProfile user={user} />
<UserPosts posts={posts} />
</div>
);
}
看到区别了吗?数据获取都在服务器完成,而且可以并行请求,不用等来等去。拿到数据后直接渲染,把结果流式传给浏览器。
前后端分离提高了复杂度
第三个问题就是前后端分离搞得太复杂了。按照传统做法,前端想要个数据都得通过API,这就带来了一堆麻烦事儿:
- 写不完的样板代码:每个数据需求都得写API端点、路由、请求响应处理。
- 安全问题:API密钥、数据库凭证这些敏感信息得小心翼翼,一不小心就泄露了。
- 限制太多:前端想要的数据格式API不支持,有时候还需要前端自己做BFF层面的开发
RSC直接把这层壁垒给打破了。既然在服务器上跑,那就可以像后端代码一样直接连数据库。
javascript
// UserDashboard.server.js
import db from './lib/db'; // 直接导入数据库客户端实例
import { cache } from 'react'; // React 提供的请求级缓存
// 使用 cache 可以确保在同一次渲染中,对相同参数的调用只执行一次
const getUserData = cache(async (userId) => {
const user = await db.user.findUnique({ where: { id: userId } });
const permissions = await db.permissions.findMany({ where: { userId } });
return { user, permissions };
});
export default async function UserDashboard({ userId }) {
const { user, permissions } = await getUserData(userId);
if (!permissions.canViewDashboard) {
return <p>您没有权限访问此页面。</p>;
}
return (
<div>
<h1>欢迎, {user.name}!</h1>
{/* ... 更多仪表盘内容 ... */}
</div>
);
}
这样一来,全栈开发就简单多了,一套React代码就可以全部搞定。
什么时候该用RSC
尽管RSC有这么多的好处,但RSC并不是银弹,对于普通的前端开发来说,其带来了很大的开发、部署、运维成本,算算总体收益,贸然上车其实并不太划算。
考虑到这些,我个人认为对于以下的场景,RSC的确是个好武器:
大型数据密集应用:电商、社交媒体、复杂后台这种巨型应用,数据多、交互复杂,RSC的优势能发挥出来。
全栈开发者和小团队:一个人或几个人搞定前后端,使用RSC一套打天下。
性能强迫症团队:追求极致的性能。
如何选择使用哪种组件
在Next.js App Router里,默认所有组件都是服务器组件。这个思路转变很重要:只有真的需要客户端功能时,才在文件顶部加"use client";
。
这种"服务器优先"的策略其实挺好的,逼着你把逻辑尽量留在服务器上,性能自然就好了。所以现在"用哪种组件"成了个重要的架构决策。
我总结了个简单原则:能放服务器就放服务器,实在不行了再搬到客户端。
还有个巧妙的用法:客户端组件可以接收服务器组件作为children
,比如:
javascript
// CommentSection.client.js
'use client';
import { useState } from 'react';
import PostCommentForm from './PostCommentForm';
// 这个客户端组件接收一个服务器组件作为 children
export default function CommentSection({ children }) {
const [showComments, setShowComments] = useState(true);
return (
<div>
<button onClick={() => setShowComments(!showComments)}>
{showComments ? '隐藏评论' : '显示评论'}
</button>
{showComments && (
<>
{children} {/* `children` 是在服务器上渲染好的评论列表 */}
<PostCommentForm /> {/* 这是一个交互式的表单 */}
</>
)}
</div>
);
}
// page.server.js
import CommentSection from './CommentSection.client';
import CommentList from './CommentList.server'; // 一个获取并渲染评论列表的 RSC
export default function Page({ postId }) {
return (
<article>
{/* ... 文章内容 ... */}
<CommentSection>
{/* 将 RSC 作为 prop 传递给客户端组件 */}
<CommentList postId={postId} />
</CommentSection>
</article>
);
}
这里CommentList.server.js
在服务器上拿数据、渲染评论,然后把结果作为children
传给客户端的CommentSection
。客户端组件只管显示隐藏的交互,根本不知道评论怎么来的,就像是服务器在客户端组件里"打了个洞",也被称为“Hole Punching”。
大体上,可以使用下面的原则去进行划分:
用服务器组件的情况:
- 要连数据库的:直接读写数据库、访问文件系统、调用内部API的,肯定是服务器组件。
- 用大型库但不交互的:比如Markdown解析、代码高亮、数据可视化这些,库很大但用户不需要交互。
- 纯展示内容:文章内容、产品详情、新闻列表、页头页脚这些,天生就适合服务器组件。
- 应用骨架:页面布局、顶层组件这些,负责拿全局数据然后传给子组件。
用客户端组件的情况:
- 需要状态管理:用了
useState
、useEffect
相关Hook。 - 需要进行交互:按钮点击、表单提交、输入框变化这些UI交互。
- 用浏览器API:访问
window
、document
、localStorage
这些只有浏览器才有的东西。
RSC到底在哪跑
搞清楚了组件怎么选,现在来看看RSC具体在哪运行。这就涉及两个问题:物理上在哪跑,以及在代码架构中的位置。
物理运行环境
服务器组件只在服务器上跑,这个"服务器"可以是:
- 传统服务器:你自己的云服务器,比如阿里云ECS、腾讯云这些。
- Serverless函数:AWS Lambda、Vercel Functions这种,来一个请求就启动一个函数实例。
- 边缘计算:Cloudflare Workers、Vercel Edge Runtime,在全球各地的节点上跑,离用户更近,延迟更低。RSC的流式特性和边缘计算配合得特别好。
关键是,RSC的代码绝对不会跑到用户浏览器里,这就保证了安全性和性能。
代码架构中的位置
RSC在你的代码里画了条服务器-客户端边界,这条线很重要,得遵守规则:
核心规则:
数据只能从服务器流向客户端
- 服务器组件可以导入和渲染客户端组件
- 客户端组件不能直接
import
服务器组件(因为服务器组件代码根本不在客户端) - 例外:可以把服务器组件作为
children
传给客户端组件,这是框架帮你做的"魔法"
传给客户端的Props必须能序列化
- 从服务器组件传props给客户端组件时,这些props必须能转成JSON
- 能传的:字符串、数字、布尔值、对象、数组、Date
- 不能传的:函数、Class实例等,因为函数代码在服务器上,客户端执行不了
这种硬性规定其实约束了我们进行更清晰的架构设计,长远来看是有助于代码的可维护性。
- 数据获取层:肯定是服务器组件
- 交互逻辑层:肯定是客户端组件
- 展示层:静态的用服务器组件,动态的用客户端组件
虽然多了约束,但长远看对代码维护性是好事。
RSC工作原理
渲染和流式传输的过程
当一个请求到达支持 RSC 的服务器时,会发生以下一系列事件:
请求进来:用户浏览器请求一个URL
路由匹配:服务器的路由系统(比如Next.js App Router)找到对应的页面组件,通常是个服务器组件
RSC渲染:
- React在服务器上开始渲染页面组件树
- 遇到
async
服务器组件时,会暂停等数据,但可以继续渲染其他分支 - 生成RSC Payload:这是个特殊的JSON流,不是HTML,而是UI的描述,包含:
- 渲染好的字符串和HTML标签
- 客户端组件的"占位符"和要传给它们的props
- 客户端组件JS文件的引用
流式传输:RSC Payload边生成边发送,不等全部完成,浏览器可以尽早开始处理
浏览器端处理:
- 浏览器接收RSC Payload流
- React在客户端解析这个流
- 立即把静态、非交互的部分渲染成DOM,用户马上能看到内容(流式HTML效果,FCP很快)
- 遇到客户端组件占位符时,检查对应的JS bundle是否已加载
加载客户端JS:如果客户端组件的JS还没加载,React会在
<head>
里插入<script>
标签去请求,通常是并行的水合:客户端组件JS加载完后,React用真实组件替换占位符,附加事件监听器,让它变得可交互。这是选择性、非阻塞的,不像传统SSR要水合整个页面
完成:所有可见的客户端组件都水合完毕,页面完全可交互(TTI)
整个流程的本质是: 服务器负责执行 RSC、获取数据、编排 UI 结构,并将一份详细的“渲染说明书”(RSC Payload)流式传输给浏览器;浏览器则根据这份说明书,逐步绘制 UI、按需加载交互逻辑并激活它们。
用下面的流程图来展示整个过程:
实战:用Next.js写RSC
说了这么多理论,来看个具体例子。下面是个博客文章页面,展示RSC怎么用:
app/
├── posts/
│ └── [slug]/
│ └── page.tsx # 动态路由的服务器组件 (RSC)
└── components/
└── LikeButton.tsx # 可交互的客户端组件
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import LikeButton from '@/app/components/LikeButton'; // 导入客户端组件
// 为文章数据定义一个类型接口
interface Post {
id: number;
title: string;
body: string;
}
// 动态生成页面元数据 (SEO) - 在服务器上运行
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post?.title || '文章未找到',
description: post?.body.substring(0, 160) || '这是一个博客文章页面',
};
}
// 封装的数据获取函数
// Next.js 会自动缓存 fetch 请求,除非特殊指定
async function getPost(slug: string): Promise<Post | null> {
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`, {
// 增量静态再生 (ISR): 每小时重新验证一次数据
next: { revalidate: 3600 },
});
if (!res.ok) {
return null;
}
return res.json();
} catch (error) {
console.error('获取文章失败:', error);
return null;
}
}
// 页面主组件,它是一个异步的 RSC
export default async function PostPage({ params }: { params: { slug: string } }) {
// 1. 在服务器上直接获取数据
const post = await getPost(params.slug);
// 2. 如果数据不存在,显示 404 页面
if (!post) {
notFound();
}
// 模拟一个初始的点赞数
const initialLikes = Math.floor(Math.random() * 100);
// 3. 渲染静态内容,并将数据通过 props 传递给客户端组件
return (
<article className="max-w-4xl p-4 mx-auto prose lg:prose-xl">
<h1>{post.title}</h1>
<p className="text-slate-600">文章 ID: {post.id}</p>
<p className="text-lg leading-relaxed">{post.body}</p>
<hr className="my-8" />
<div className="flex items-center">
{/* LikeButton 是客户端组件,负责交互 */}
<LikeButton postId={post.id} initialLikes={initialLikes} />
</div>
</article>
);
}
写在最后
RSC算是React生态的一次大变革,不只是个新功能,更像是对Web应用架构的重新思考,随着React 19发布和更多框架支持RSC,个人觉得它会成为构建高性能Web应用的标准做法。当然需要注意的是:RSC是个强大的工具,但RSC不是银弹,它有自己的适用场景和局限性。我们需要理解它适合什么场景,合理设计组件架构,在实践中不断优化。
