本文阐述了Web渲染从MPA到SPA、SSR的演进及痛点。React服务端组件(RSC)通过仅在服务器执行且代码零客户端体积,解决了JS负担过重问题,提升性能与数据获取效率。RSC与客户端组件协作,需框架支持,但也引入了新复杂性。
在 React 出现之前,传统的 Web 开发通常使用 ASP、PHP 等技术构建多页面应用 (MPA)。这种架构下,服务器负责访问数据库、渲染模板,然后将生成的完整 HTML 页面发送给客户端浏览器。然而,这种模式存在明显的弊端:
因此,现代Web开发越来越倾向于使用React等前端框架来构建更动态、响应迅速的SPA。这方式不仅减轻了服务器的压力,也提升了应用的可维护性和体验。
为了克服这些问题,单页面应用 (SPA) 应运而生,以 React、Vue、Angular 等为代表的前端框架极大地推动了 SPA 的发展。SPA 将大部分渲染逻辑移到客户端(浏览器)执行,通过 JavaScript 动态更新页面内容,提供了接近原生应用的流畅交互体验。
然而,纯粹的客户端渲染 (CSR) 模式也带来了新的挑战:
为了解决 SPA 的这些痛点,服务端渲染 (SSR) 技术被重新引入到现代前端框架中。SSR 的核心思想是在服务器端预先执行 React 组件代码,生成包含内容的初始 HTML,直接发送给浏览器。这样用户可以更快地看到页面内容(改善 FCP),同时也方便了搜索引擎爬虫抓取。
但 SSR 并非终点。它本身是为了弥补纯客户端渲染缺陷而采取的一种策略,其自身的局限性也直接推动了 Web 渲染技术的进一步演进,最终导向了 React Suspense 和服务端组件 (RSC) 的诞生。可以说,Web 开发的演进过程,就是在不断寻求平衡服务器能力与客户端体验、追求更优性能和开发效率的过程。
传统的 SSR 模式虽然改善了初始加载速度和 SEO 问题,但其工作机制也带来了新的挑战,主要体现在所谓的“一切或全无”瀑布流问题和沉重的客户端负担上。
因此,传统 SSR 虽然解决了 FCP 问题,让用户更快地“看到”内容,但往往以牺牲 TTI 为代价。由于需要下载和执行庞大的 JavaScript 并完成整个应用的水合,用户可能需要等待更长时间才能真正“使用”页面,这与精心优化的、采用代码分割的 CSR 应用相比,实际可交互时间可能反而更长。此外,开发者还需要维护服务器端渲染和客户端运行两个环境,处理可能的状态同步问题,增加了开发的复杂性。这些局限性促使 React 团队寻求更优化的解决方案。
为了缓解传统 SSR 的瀑布流问题,React 18 引入了基于 Suspense 的全新 SSR 架构,带来了两大关键改进:HTML 流式传输和选择性水合。
通过将页面中可能加载较慢的部分(例如需要异步获取数据的组件)用 <Suspense>
组件包裹起来,并提供一个 fallback
UI(如加载指示器),React 服务器渲染器不再需要等待所有数据都准备好才开始发送 HTML。它可以先发送页面的整体骨架和非 Suspense 包裹部分的内容,对于 Suspense 包裹的部分,则先发送 fallback
UI 的 HTML。当服务器端的数据准备就绪后,React 会将该组件渲染成 HTML,并通过同一个流式连接将这段 HTML 追加发送给客户端。客户端 JavaScript 接收到这段新的 HTML 后,会将其“填入”到对应的位置。
这种机制打破了 SSR 必须等待所有数据就绪才能响应的限制,让用户能够更快地看到页面的部分内容,改善了 FCP。
仅仅流式传输 HTML 还不够,因为页面的交互性仍然依赖于 JavaScript 的下载和执行。React 18 的选择性水合允许客户端在接收到流式 HTML 的同时,就开始进行水合操作,并且可以根据用户的交互行为来确定水合的优先级。
例如,在一个电商页面中,即使产品推荐部分的数据还在加载(服务器还在流式传输其 HTML),如果用户点击了已经渲染好的导航栏或者产品列表中的某个按钮,React 会优先下载并执行与被交互组件相关的 JavaScript 代码,并对其进行水合,使其能够响应用户的操作。其他非关键部分的水合则可以稍后进行。这避免了传统 SSR 中必须等待所有 JS 下载完毕、整个应用水合完成后才能交互的尴尬局面,显著改善了 TTI。
举例说明:
假设一个页面包含导航栏 (NavBar)、产品列表 (ProductList) 和推荐栏 (Recommendations),使用 Suspense
和 lazy
实现:
import React, { Suspense, lazy } from 'react';
// 使用 lazy 动态导入组件
const NavBar = lazy(() => import('./NavBar'));
const ProductList = lazy(() => import('./ProductList'));
const Recommendations = lazy(() => import('./Recommendations'));
function HomePage() {
return (
<div>
{/* NavBar 可能很快加载 */}
<Suspense fallback={<div>Loading NavBar...</div>}>
<NavBar />
</Suspense>
{/* ProductList 可能需要一些时间获取数据 */}
<Suspense fallback={<div>Loading Product List...</div>}>
<ProductList />
</Suspense>
{/* Recommendations 可能加载最慢 */}
<Suspense fallback={<div>Loading Recommendations...</div>}>
<Recommendations />
</Suspense>
</div>
);
}
export default HomePage;
工作流程:
HomePage
。遇到 NavBar
的 Suspense
,如果 NavBar
组件代码或数据未就绪,则发送 Loading NavBar...
的 HTML。ProductList
的 Suspense
,发送 Loading Product List...
的 HTML。Recommendations
的 Suspense
,发送 Loading Recommendations...
的 HTML。NavBar
的数据和代码先就绪,服务器将 NavBar
的 HTML 流式传输到客户端,客户端 JS 接收到后替换 fallback 并开始水合 NavBar
。ProductList
接着就绪,其 HTML 被流式传输并水合。NavBar
或 ProductList
中的元素,React 会优先完成这些组件的水合以响应交互。Recommendations
的 HTML 到达并被水合。尽管 Suspense 显著优化了 SSR 的用户体验,但它并没有改变一个根本事实:所有需要在客户端交互的组件,其 JavaScript 代码最终仍然需要被下载到浏览器并执行水合。Suspense 优化的是代码和数据的传输与执行时机,而不是传输与执行的总量。随着应用规模的增长,客户端需要下载和处理的 JavaScript 总量依然会增加,客户端的计算负担问题并未得到根本解决。正是这一核心局限性,成为了 React 服务端组件 (RSC) 诞生的最主要驱动力。
为了从根本上解决客户端 JavaScript 负担过重的问题,React 团队引入了一种全新的组件类型——React 服务端组件 (React Server Components, RSC)。这不仅仅是对现有 SSR 的优化,而是一次深刻的范式转变,旨在更智能地划分服务器和客户端的职责。
服务端组件是一种特殊的 React 组件,具有以下核心特征:
useState
, useEffect
)或访问浏览器 API(如 window
, document
)。它们是无状态、无副作用的(相对于浏览器环境而言)。 async
函数,允许直接在组件内部使用 await
来进行数据获取或其他异步操作,简化了服务端的数据处理逻辑。 与 RSC 相对的是客户端组件,它们是我们一直以来熟悉的标准 React 组件:
"use client"
指令:为了区分 RSC 和 CC,客户端组件需要在文件顶部明确声明 "use client"
指令。 useState
, useEffect
, useContext
等 Hooks,可以访问浏览器 API,处理用户事件,管理状态和副作用。 理解 RSC 和 SSR 的关系至关重要:
还存在一类组件,它们既不包含仅能在服务器端运行的逻辑(如直接访问数据库),也不包含仅能在客户端运行的逻辑(如 useState
)。这类组件被称为共享组件,它们可以在 RSC 或 CC 中使用。当被 RSC 导入时,它们作为 RSC 运行;当被 CC 导入时,它们作为 CC 运行,其代码会被包含在客户端包中。
为了更清晰地理解这几种模式的区别,下表进行了总结:
特性 (Feature) | 客户端渲染 (CSR) | 传统服务端渲染 (SSR) | React 服务端组件 (RSC) + SSR for CC |
---|---|---|---|
初始 HTML (Initial HTML) | 空白或骨架 (Empty/Shell) | 完整静态内容 (Full Static) | 完整静态内容 (Full Static) |
主要渲染地点 (Primary Rendering) | 客户端 (Client) | 服务器 (初始) + 客户端 (水合/更新) (Server (initial) + Client (hydration/updates)) | 服务器 (RSC) + 服务器/客户端 (CC) (Server (RSC) + Server/Client (CC)) |
JavaScript 包体积 (JS Bundle Size) | 大,随应用增长 (Large, grows with app) | 大,随应用增长 (Large, grows with app) | 更小,RSC 代码不包含 (Smaller, RSC code excluded) |
客户端计算负担 (Client Load) | 高 (High) | 高 (水合) (High (Hydration)) | 更低 (无 RSC 水合) (Lower (No RSC hydration)) |
数据获取 (Data Fetching) | 客户端瀑布流 (Client-side waterfalls) | 服务器端 (初始) 阻塞 / 客户端瀑布流 (Server-side (initial) blocking / Client-side waterfalls) | 服务器端直接访问 (RSC) / 客户端 (CC) (Server-side direct access (RSC) / Client-side (CC)) |
首次内容绘制 (FCP) | 慢 (Slow) | 快 (Fast) | 快 (Fast) |
可交互时间 (TTI) | 取决于 JS 大小/网络 (Depends on JS size/network) | 可能较慢 (JS 下载 + 水合) (Potentially slow (JS download + hydration)) | 可能更快 (更少 JS, 无 RSC 水合) (Potentially faster (less JS, no RSC hydration)) |
SEO | 差 (需预渲染) (Poor without pre-rendering) | 好 (Good) | 好 (Good) |
交互性 (Interactivity) | 完全 (Full) | 完全 (水合后) (Full (after hydration)) | 完全 (客户端组件) (Full (Client Components)) |
复杂性 (Complexity) | 初始设置较低 (Lower initial setup) | 更高 (服务器设置, 水合) (Higher (server setup, hydration)) | 更高 (新概念, 边界, 框架) (Higher (new concepts, boundaries, framework)) |
RSC 的本质:预渲染而非直接渲染
需要强调的是,RSC 的渲染过程与传统的 SSR 或模板引擎直接输出 HTML 不同。RSC 渲染产生的是一种结构化的中间数据(RSC Payload),它描述了 UI 的状态和结构,包括渲染好的 RSC 部分、客户端组件的占位符及其所需的 JS 引用、以及传递给客户端组件的 props。这个 Payload 随后被用于流式传输,并指导客户端 React 进行高效的 DOM 更新和客户端组件的水合。这种“预渲染到载荷”的机制是 RSC 能够实现零打包体积、与 Suspense 深度集成等优势的基础。
默认行为的重要性:思维转变
在像 Next.js App Router 这样的现代框架中,组件默认被视为服务端组件。开发者必须显式地使用 "use client"
指令来标记那些需要在客户端运行并具有交互性的组件。这不仅仅是一个技术细节,它代表了一种重要的思维转变:从过去默认一切在客户端运行(除非明确进行 SSR),转变为现在默认一切在服务器端运行(除非明确需要客户端交互)。这种“服务器优先”的理念促使开发者更审慎地思考哪些部分真正需要客户端 JavaScript,从而有助于构建更轻量、更高效的应用。
引入 RSC 的核心动机是为了解决传统 Web 应用架构(包括 CSR 和 SSR)中存在的关键痛点。RSC 通过其独特机制,在多个方面带来了显著的优势:
这是 RSC 最具革命性的特点。
marked
和 sanitize-html
这样的库来处理渲染,这可能增加几十 KB (gzipped) 的包体积。使用 RSC,你可以在服务端组件中引入并使用这些库来生成 HTML,而这些库的代码完全不会影响客户端包的大小。 这种零打包体积影响的特性,从根本上改变了开发者对第三方库选择的考量。过去因为担心增加客户端负担而避免使用的一些功能强大但体积庞大的库(如复杂的日期处理库、数据可视化库、重量级 UI 库的非交互部分),现在如果只在 RSC 中使用,就变得完全可行。这为在服务器端构建更丰富、更强大的非交互式 UI 体验打开了大门,而无需牺牲客户端性能。
RSC 改变了 React 应用中数据获取的方式:
async/await
:RSC 可以是异步函数,允许在组件的渲染逻辑中直接使用 async/await
进行数据查询,代码更简洁直观。 RSC 带来的数据获取方式的变革,可能会简化应用的数据层。对于页面的初始加载,直接在 RSC 中获取数据,可以避免创建专门的 API 接口,也可能减少对 React Query
、SWR
等客户端数据缓存库的依赖。然而,这并不意味着数据管理变得完全简单。对于数据的更新(如表单提交后的 CUD 操作)和需要实时响应用户交互的数据,仍然需要额外的机制,例如新兴的 Server Actions或传统的 API 调用。并且,对于复杂的客户端交互状态,可能仍然需要客户端状态管理或缓存库。因此,RSC 更多的是转移了数据获取的复杂性,简化了初始加载场景,但整体复杂度是否降低取决于具体的应用需求和实现方式。
结合零打包体积和高效数据获取,RSC 带来了多方面的性能提升:
RSC 和客户端组件 (CC) 并非孤立存在,它们可以在同一个 React 应用中组合使用,共同构建用户界面。理解它们之间的交互规则至关重要。
"use client"
指令"use client"
指令是区分服务器和客户端组件的明确标记。它必须放置在文件的最顶部(在任何 import
语句之前)。 "use client"
,它以及它导入的所有其他模块(除非这些模块本身也被标记为 "use client"
或来自仅客户端的库)都会被包含在客户端 JavaScript 包中。 "use server"
组件指令:再次强调,没有用于标记组件的 "use server"
指令。组件要么是默认的 RSC,要么是显式标记的 CC。("use server"
用于标记 Server Actions 函数)。 RSC 和 CC 之间的导入和渲染遵循特定的规则:
import
服务端组件。因为 RSC 的代码不存在于客户端环境中,无法被导入和执行。 children
或其他 prop 从父级 RSC 传递给 CC。此时,CC 接收到的不是 RSC 的代码或组件类型,而是该 RSC 在服务器上已经渲染好的结果(作为 RSC Payload 的一部分)。这种机制允许服务器和客户端组件在 UI 树中交错存在,实现灵活布局。 这种“客户端不能导入服务端”的规则,实际上引导了一种特定的架构模式:服务端组件倾向于位于组件树的较高层级,负责数据获取、整体布局和业务逻辑编排;而客户端组件则更多地作为叶子节点,负责具体的交互逻辑和状态管理。这天然地促进了关注点分离,但也要求开发者在设计组件结构时仔细规划服务器和客户端的边界。
当 RSC 渲染 CC 并向其传递 props 时,这些 props 必须是可序列化 (Serializable) 的,就像通过网络发送数据一样。
use
Hook 处理)。 Props 序列化的要求,构成了服务器环境和客户端环境之间一道清晰的“API 边界”。开发者必须像设计传统 API 的数据载荷一样,仔细考虑哪些数据可以通过这个边界从服务器传递到客户端。这不仅仅是一个技术限制,更是一个重要的架构设计约束。
一个重要的特性是,当包含客户端组件的服务端组件树在服务器上重新渲染或重新获取数据时(例如,通过路由导航或 Server Actions 触发刷新),嵌套在其中的客户端组件在浏览器中的状态(如 useState
的值、输入框的焦点、甚至是进行中的动画)会被保留下来,不会丢失。这确保了在更新部分服务端渲染内容时,用户交互状态的连续性。
虽然 RSC 是 React 核心团队提出的概念和能力,但在实践中,要有效地使用 RSC,通常离不开元框架 (Meta-frameworks) 的支持,其中 Next.js 是目前最主要的推动者和实现者。
从零开始实现 RSC 的支持是一项非常复杂的工程,涉及到:
"use client"
指令,并据此进行代码分割,生成客户端和服务端不同的 bundle。 框架(如 Next.js, Remix 等)将这些底层复杂性进行了封装和抽象,提供了开箱即用的 RSC 开发体验。
Next.js 的 App Router 是围绕 RSC 构建的,其实现方式体现了 RSC 的核心思想:
app/
目录下创建的组件,默认就是服务端组件。只有添加了 "use client"
指令的组件才是客户端组件。 loading.js
文件定义路由级别的 fallback,或在组件中使用 <Suspense>
),Next.js 可以将 RSC Payload 和初始 HTML 分块流式传输到客户端。 框架依赖性与生态考量
目前来看,想要在实际项目中应用 RSC,很大程度上需要依赖像 Next.js 这样的框架。虽然 RSC 本身是 React 的一部分,但脱离框架使用需要开发者自行处理大量复杂的底层集成工作,门槛非常高。这意味着选择采用 RSC,往往也意味着需要接受特定框架的技术栈和约束。
同时,RSC 的强力推广主要由 Next.js (Vercel) 引领,这也引发了一些关于生态系统碎片化的担忧。如果其他框架、路由库或构建工具在对 RSC 的支持上出现滞后,或者不同实现之间存在兼容性问题,可能会导致开发者在技术选型上面临困境,第三方库的适配也可能需要针对不同框架进行。此外,迁移现有的大型 React 应用到基于 RSC 的新架构(如 Next.js App Router)本身也是一项充满挑战的任务。
尽管 RSC 带来了诸多诱人的优势,但在采用它时,开发者也需要认识到其伴随的挑战和需要权衡的因素:
"use client"
)、Props 序列化规则、异步组件 (async/await
)、RSC Payload 等。开发者需要跳出传统纯客户端 React 的思维定式,理解这种跨服务器和客户端的混合渲染模式,这需要一定的学习成本。它在某种程度上融合了前端和后端的关注点,要求开发者具备更全面的知识。 useEffect
、useState
或直接操作 DOM、依赖 window
等浏览器 API 的库,可能无法直接在 RSC 中使用,或者需要库作者进行适配,提供兼容 RSC 的导出或用法。对于状态管理库、UI 组件库等,开发者需要仔细考虑它们与服务器/客户端边界的交互方式,确保状态传递和渲染的正确性。 技术成熟度曲线
RSC 作为一个相对较新的技术(RFC 提出于 2020 年底,Next.js App Router 在 2022-2023 年间趋于稳定),其相关的生态系统,包括第三方库的支持、最佳实践、开发工具链以及社区的普遍理解程度,都还处于不断发展和成熟的过程中。早期采用者需要有准备去探索和适应这些仍在演进中的方面。
React 服务端组件 (RSC) 的出现,标志着 React 应用架构发展的一个重要里程碑。它并非简单地回归服务器渲染,而是对服务器和客户端能力的一次重新审视与智能划分。
RSC 的核心价值在于,它直面了现代 Web 应用(无论是纯客户端渲染还是传统服务端渲染)普遍存在的痛点——向客户端传输过多的 JavaScript 代码。通过让一部分组件完全在服务器端运行,并且其代码和依赖不进入客户端包,RSC 从根本上解决了这个问题,带来了显著的性能优势:更小的包体积、更快的初始加载、更短的可交互时间,以及更低的客户端计算负担。
同时,RSC 提供的服务端直接数据获取能力、与 Suspense 的无缝集成带来的流式渲染体验、以及增强的安全性,共同构成了其强大的吸引力。它使得开发者能够构建出既具备 SPA 丰富交互性,又拥有接近传统 MPA 性能优势的应用。
当然,RSC 也并非没有代价。它引入了新的概念和规则,带来了学习曲线和额外的复杂性,生态系统的成熟度和兼容性仍在发展中,且目前很大程度上依赖于 Next.js 等元框架的支持。
总而言之,React 服务端组件代表了 React 生态系统在追求极致性能和更优开发体验道路上的重要一步。虽然存在挑战,但它所展现的潜力——构建更快、更轻、更强大的 Web 应用——使其成为未来 React 开发的关键方向之一。对于 React 开发者而言,理解 RSC 的核心思想、优势与局限,并关注其生态发展,将是在未来构建高性能 Web 应用的必备技能。
传统ssr