Bakka's Blog

性能分析rsc-与-ssr

2025/12/8

intro

很长一段时间里,前端世界默认的渲染方式是客户端渲染,但在今天,除了客户端渲染,服务端渲染,静态页面,还有很多混合,以及细分,在 react 生态中尤为明显,关于 vercel 和 RSC 的争论是前端的一个常态的话题,为什么会发展出那么多的渲染方式?他们的原理是什么?这就是本文探讨的主要话题

渲染方式总览

CSR渲染过程: 下载HTML,JS, 编译JS,执行JS

  • 在这之后,UI 才会变得可见,LCP 指标才会被记录,并且 fetch 请求等副作用才会被触发。
  • 在第一次进入一个 大型的CSR 的网页时,往往能感知到明显的加载时间,这段时间做的就是这些事

SSR 聚焦的点其实在于两个事情:首屏渲染速度和页面可交互的速度,前者对应的是HTML ,后者则是对应 JS 和服务端的数据获取

  • CSR 准备好一切再渲染,使得首屏慢,但可交互只剩下了获取数据的时间
  • SSR w/o data,选择的是直接在服务端把 HTML 的字符串发送到前端,使其可以立刻渲染,这么做首屏会很快,但是可用的时间是没有提前的,解析 js 和获取数据的时间没变化,这使得首屏快了,但是用户有了显著的等待页面可交互的时间存在
  • SSR w/ data,把数据获取这一步在服务端的解决,渲染的时机改至两者中间,是一个中间的解决方案
  • RSC 做的核心举措就是通过把一个页面给按照组件级别拆分,逐个渲染在页面上,这使得浏览器会先解析了到达客户端的组件,执行 js,同时,服务端也在处理别的组件流式的发送过来,这使得原本串行的过程,被拆分成单位实现了并行

RSC解决的问题:

  • 在CSR和SSR中,发送HTML到Client,加载js,Fetch Data
  • 这三件事是相互阻塞的,我们事实上在改变事情发生的顺序,我们无法并行的做这几件事,这是最大的问题
  • 其次是无法解决,快速的首屏伴随很长的无交互时间的问题,以及fetch Data的长耗时带来的阻塞问题

相互阻塞,这是因为服务器渲染目前是一个同步过程。我们先等待所有数据,然后将这些数据传递给 renderToString ,最后将结果发送给客户端。

但是获取数据和渲染为什么需要是同步的呢?可以并行的,分块的做这件事

如果我们能够:

  1. 一开始就触发所有数据请求
  2. 同时先渲染那些不依赖这些数据的组件
  3. 哪部分数据先准备好,就把消费该数据的组件单独发给客户端
  4. 等 Sidebar 或 Messages 的数据回来后,再分别渲染对应内容,并通过流式传输补到页面里

要做到这一点,React 必须:

  • 放弃同步的 renderToString
  • 支持分块渲染(chunked rendering)
  • 能把这些块按顺序发送到客户端并动态注入

这正是 RSC + async components + Suspense + streaming 的核心设计目标。它让 React 既能在服务器上异步获取数据、流式输出 HTML,又能保持组件化结构,让用户更早看到内容。

CSR的优点

CSR 的首屏加载速度显然是有明显的劣势的,那么他为什么被人采用呢?

除了开发体验和学习曲线相关的一切(这些就非常重要),与SSR 网站相比,CSR主要有两个优势。

  1. 页面之间的切换可以非常快,结合本地数据备份,复杂交互几乎可以快速响应。
  2. 其次,它非常便宜。
  3. 此外,缓慢的加载只会发生用户第一次访问您的应用程序时,因为会有缓存。

诚然,对于类似landing page的页面,缓慢的首屏,这是不可接受的。但对于 SaaS,期望用户经常访问网站,完整的加载只会发生一次(每次部署)。

SSR/SSG 的基本原理

不得不盯着空白页面很长时间,这让人感到烦躁。即使只是第一次。

为了解决这个问题: 能不能让 React 在服务器上执行组件,直接产出一段完整的 HTML 字符串?

我们知道,最终整个 React 应用看起来是这样的:

const HTMLString = renderToString(<App />);

renderToString 是 React 的内置 API(来自 react-dom/server),它不会创建真实 DOM,而是返回一段 HTML 字符串。

于是我们把这段字符串「塞进」模板 HTML:

export const serveStatic = async (c) => {
  const html = fs.readFileSync('index.html').toString();
  const HTMLString = renderToString(<App />);
  const htmlWithSSR = html.replace('<div id="root"></div>', HTMLString);
  return c.body(htmlWithSSR, 200);
};

这样浏览器收到的 HTML 就已经包含了完整的 UI,即使 JavaScript 还没下载、还没执行,用户也能马上看到页面。

这就是 服务端渲染(Server-Side Rendering, SSR)。

欢迎来到 React 的服务器端渲染(SSR)和静态网站生成(SSG)时代。因为 renderToString 实际上是一个 React 支持的真实 API。这确实是某些 React SSG/SSR 框架背后的核心实现。

SSR w/o data fetching

LCP 指标会改善,在 HTML 和 CSS 下载之后立即出现,因为整个 HTML 都在初始服务器响应中发送,并且所有内容都能立即显示。

然而,我们仍然需要以完全相同的方式下载、编译和执行相同的 JavaScript。所以取代空白等待的实际上只是一个渲染内容后的等待,因为页面依然是不可交互的,仍然需要等待 JS 处理完。

同时: useEffect 只在浏览器端挂载后执行。 renderToString 在服务器端运行时,会直接跳过这些副作用。

所以:

  • 数据请求不会在服务器跑;
  • 组件显示的依然是空的 sidebar 或 loading;
  • 直到客户端 JS 执行完、React “水合(hydrate)” 之后,useEffect 才开始请求数据。

HTML 已经显示出来,但 JS 还没加载完,因此:

  • 按钮、下拉框都无法点击;
  • 看起来“卡住了”。
渲染方式LCPSidebar 出现Toggle 可用无交互期
Client-Side Rendering4.1s4.7s4.1s
Server-Side Rendering1.61s4.7s4.0s2.39s

这种“无交互”的间隙,加上运行服务器的成本,是你从客户端渲染过渡到服务器端渲染时为 LCP 改进所必须付出的代价。没有办法消除它。我们只能通过减少用户在首次运行时需要下载的 JavaScript 量来将其最小化。

SSR w/ data fetching && hydration

我们已经处于服务端,为什么不能在这里提取这些数据呢?这肯定会更快。至少,延迟和带宽可能会好得多。添加数据fetch的逻辑,并将结果,作为Props传递给APP。

// Add data fetching to the SSR server
export const serveStatic = async (c) => {
  const html = fs.readFileSync('index.html').toString(); // Data fetching logic
  const sidebarPromise = fetch(`/api/sidebar`).then((res) =>
    res.json(),
  );
  const messagesPromise = fetch(`/api/messages`).then(
    (res) => res.json(),
  );

  const [sidebar, messages] = await Promise.all([
    sidebarPromise,
    messagesPromise,
  ]);

  // Pass fetched data as props
  const HTMLString = renderToString(
    <App messages={messages} sidebar={sidebar} />,
  );
};

然后我们的 App 组件需要修改以接受属性,并使用常规的 props drilling技术传递它们:

// That's the entry point to the beautiful app
export default function App({ sidebar, messages }) {
  return (
    <SomeLayout>
      <Sidebar data={sidebar} />
      <MainContent data={messages} />
    </SomeLayout>
  );
}

理论上,这应该可以工作。实际上,我们需要处理一些额外的事情。

首先,“hydration”。

  • 在 SSR(服务器端渲染)领域,“hydration”指的是 React 重新使用从服务器发送的现有 HTML 以附加事件监听器。
  • 为了使 hydration 正常工作,从服务器发送的 HTML 应该与客户端完全相同。
  • 但这是不可能的,因为客户端还没有获取到这些数据。只有服务器才有。

这意味着我们需要在发送 HTML 的同时,以某种方式将数据从服务器传递给客户端,以便在 React 初始化期间可用。

怎么把服务端得到的数据,给到客户端呢?

最简单的方法是将它嵌入到 HTML 中作为 script 标签,并将其作为对象附加到 window,这样,客户端可以通过Window这样全局对象,拿到数据

服务器端:

const htmlWithData = `
  <script>
    window.__SSR_DATA__ = ${JSON.stringify({ sidebar, messages })}
  </script>${HTMLString}
`;
  • JSON.stringify({ sidebar, messages }):把服务器拿到的真实数据(JS对象)转成字符串。
  • 然后直接插到 <script> 里。
  • 浏览器解析时,会执行这段 JS,创建一个全局变量 window.__SSR_DATA__

这样浏览器端的 React 就能访问同样的数据了。

客户端(React 组件):

export default function App({ messages, sidebar }) {const sidebarData =typeof window === 'undefined'
      ? sidebar
      : window.SSR_DATA?.sidebar;
const messagesData =typeof window === 'undefined'
      ? messages
      : window.SSR_DATA?.messages;
  • typeof window === 'undefined' → 这行是用来区分“当前运行环境”是 服务器 还是 浏览器
  • 在服务器上:window 不存在,所以用传进来的 sidebarmessages props。
  • 在客户端:window 存在,所以改用 window.__SSR_DATA__ 里的数据。

换句话说:

SSR 阶段:React 组件用服务器传的 props。 Hydration 阶段:React 组件从全局变量 window.__SSR_DATA__ 拿到同样的数据。

实际上,这确实有效!性能结构将再次改变:

现在整个页面,包括之前动态的内容,将在 CSS 完成下载后立即可见。然后我们仍然需要等待与之前完全相同的 JavaScript,只有到那时页面才会变得可交互。

渲染方式特点优点缺点
Client-Side Rendering (CSR)浏览器下载一个空HTML,等JS加载后再请求数据、生成DOM架构简单用户看到内容晚(要等JS执行完)
SSR + Client Data Fetching服务器只渲染空壳HTML,数据在客户端加载首屏比CSR快一点内容仍然是异步加载(数据慢)
SSR + Server Data Fetching服务器连数据都提前拿好,直接生成完整HTML返回页面内容几乎立刻可见LCP可能略高(因为要等待服务器取数据)
  • SSR + Server Fetching 页面内容几乎立刻可见(因为服务器渲染时就把数据放进去了)。
  • LCP(主要内容加载时间)变慢了,因为服务器要先请求数据再渲染。
  • 换句话说,渲染开始变慢,但渲染结果更完整
  • “Sidebar 和 Messages” 提前显示出来,就是因为服务端提前取好了数据。
export const serveStatic = async (c) => {
  // 向 API 请求两个数据
  const sidebarPromise = fetch(`/api/sidebar`).then((res) => res.json());
  const statisticsPromise = fetch(`/api/statistics`).then((res) => res.json());

  // 等待两个请求都完成
  const [sidebar, statistics] = await Promise.all([
    sidebarPromise,
    statisticsPromise,
  ]);

  ... // 接下来用这些数据进行SSR渲染
};

我们确实需要等待它们,因为我们需要这些数据才能开始渲染任何内容。

  • 如果你在意“用户能尽快看到完整页面”,那这是改进;
  • 如果你更在意“最快看到首屏内容(LCP)”,那可能会稍微变差;
  • 所以要根据产品需求权衡:
    • 比如,只让服务器预取 Sidebar,而让 Messages 仍由客户端请求

Nextjs Page Router

要将我的自定义 SSR 实现迁移到 Next.js Pages Router,我只需要将获取逻辑移动到 getServerSideProps 中。这是旧版 Next.js 用于在服务器上获取页面数据的 API。其他所有内容,包括属性穿透,都保持不变!Next.js 只是抽象掉了 renderToString 调用和我们为手动实现所做的查找和替换逻辑。

export const getServerSideProps = async () => {
  const sidebarPromise = fetch(`/api/sidebar`).then((res) =>
    res.json(),
  );
  const messagesPromise = fetch(`/api/messages`).then(
    (res) => res.json(),
  );

  const [sidebar, messages] = await Promise.all([
    sidebarPromise,
    messagesPromise,
  ]);

  // Pass data to the page via props
  return { props: { messages, sidebar } };
};
模式LCP(无缓存 / JS缓存)Sidebar(无缓存 / JS缓存)Messages(无缓存 / JS缓存)可交互时间(无缓存 / JS缓存)无交互间隙
Client-Side Rendering4.1s / 800ms4.7s / 1.5s5.1s / 2s4.1s / 800ms
Server-Side Rendering(客户端fetching)1.61s / 800ms4.7s / 1.5s5.1s / 2s4s / 900ms2.39s / 100ms
Server-Side Rendering(服务端fetching)2.16s / 1.24s2.16s / 1.24s2.16s / 1.24s4.6s / 1.4s2.44s / 150ms
Next.js Pages(客户端fetching)1.76s / 800ms3.7s / 1.5s4.2s / 2s3.1s / 900ms1.34s / 100ms
Next.js Pages(服务端fetching)2.15s / 1.15s2.15s / 1.15s2.15s / 1.15s3.5s / 1.25s1.35s / 100ms

Next.js 的 LCP 值(页面主要内容出现时间)比作者自己写的定制实现还要稍微差一点。 但另一方面,在 Next.js 的这个用例中,Sidebar 和 Messages 的出现时间 比作者的实现 提前了整整 1 秒

在“Server Data Fetching”的情况下,LCP / Sidebar / Messages 这三项的时间在两种实现之间几乎完全一致;

不过,“无交互间隙(no interactivity gap)” ——也就是“页面可见但尚未可操作”的时间差——在 Next.js 中要 短整整 1 秒

这正是一个非常典型的例子,说明了当 代码分割(code splitting)策略不同 时会发生什么。Next.js 会把 JavaScript 分割成 更多的小块(chunks)

结果是:

  • 在初次加载时,更多的 JS 文件同时并行下载;
  • 它们会 稍微“抢占”一点带宽,让 CSS 下载时间更长,因此 LCP 值稍微变差
  • 但同时,这些更小的 JS 文件整体下载完成得更快,→ 导致 页面更早变得可交互 → 于是 “无交互间隙”显著缩短

RSC的原理

传统的React组件,在当前的 SSR 实现中,从 React 组件中提取这棵element树的过程都会发生两次。

第一次是在服务器上进行预渲染时。第二次是完全从头开始,在初始化客户端 React 时。

如果我们在服务器上首次生成那棵树时将其保留并发送给客户端呢?如果 React 能够从那个对象中重新创建虚拟 DOM 树,我们就能一石二鸟

  • 我们不需要将这个组件发送到 JavaScript 打包文件中,从而减少了 JavaScript 的大小
  • 我们不需要迭代调用所有这些函数并将它们的返回值转换为树,从而减少了编译和执行 JavaScript 所需的时间。

如何将数据发送到客户端?我们已经知道怎么做,我们之前已经为 SSR 获取的数据这样做了!通过将其嵌入到 <script> 标签中并将其附加到 window 上。

// return this in the server response
const htmlWithData = `
        <script>window.__REACT_ELEMENTS__ = ${JSON.stringify({
          "type": "div",
          "props": { "children": [...] }
        })}</script>
        ${HTMLString}`;

我们刚刚作为一个理论可能性所发明的是 React Server Components

如果我将项目迁移到 Next.js App Router(服务器组件),我会在服务器提供的 HTML 中看到这一点:

<script>self.__next_f.push([1,"6:[\"$\",\"div\",null,{\"className\":\"w-full h-full flex flex-col lg:flex-row\",\"children\":[\"$\",\"div\",null,{\"className\":\"flex flex-1 h-full overflow-y-auto flex..."
</script>

可以查看 Chrome 底部的 Elements 标签页。你会在其中一个 <script> 标签中看到完全相同的内容。

理论上,这就是关于服务器组件的所有你需要知道的内容。

这些组件在“服务器”端预先运行,它们的代码以及它们使用的所有库都保留在服务器端。只有生成的 RSC 有效payload,即上面那个奇怪的结构,才会发送到客户端。

Async Components

在传统 React 中,组件必须是同步函数,不能直接 await fetch()。 如果想请求数据,你要么用 useEffect,要么手动在外层加载完数据再渲染组件。

但在 React Server Components (RSC) 体系里,React 允许你写:

async function MyComponent() {
  const data = await fetch('/api/data')
  return <div>{data.title}</div>
}

这是因为:

  • 该组件在服务器上执行;
  • React 会自动识别它是异步的;
  • fetch() 结束后再继续生成 HTML 或 RSC payload;
  • 这些结果会被流式发送(或缓存)到客户端。

因此: 不需要在客户端再发请求。 不会阻塞整个渲染,只会等待该组件的部分。

Streaming

传统 SSR(renderToString)流程是:

  1. React 把整个组件树渲染成完整 HTML;
  2. 等全部完成后;
  3. 一次性把 HTML 发给浏览器。

问题:如果有一个组件在等数据,全局都卡住了。

Streaming SSR(流式渲染)则是:

  1. React 不等所有数据都准备好;
  2. 哪个部分先准备好了,就立即发送对应 HTML 块
  3. 浏览器收到这部分后就能立刻显示;
  4. 其他块(例如异步组件)准备好后,再陆续发送。

这样用户几乎立刻能看到页面内容(哪怕部分是占位符)。

关键点:Chunk 边界是由 <Suspense> 决定的 也就是说:

  • React 会以 Suspense 边界 作为分块单位;
  • 每个 <Suspense> 就像一个“可独立加载的区域”;
  • 数据没准备好时,它先显示 fallback(例如 Skeleton);
  • 数据准备好时,就把这块更新发送到客户端。

所以 <Suspense> 是流式渲染的“切片边界点”。

NextJs App Router

为什么要用框架(Next.js)

自己试着用原生 React API 去实现 Streaming SSR,结果非常麻烦(renderToPipeableStream 要处理超多细节、边界情况)。 所以用框架更现实。Next.js App Router basically = React Server Components + Streaming.

也就是说:

  • Next.js 的 App Router 模式 天然集成了

  • 服务器组件(RSC)

  • 异步组件(Async Component)

  • 流式渲染(Streaming SSR)

  • 而其他框架(如 React Router)才刚刚开始尝试支持。

目前:

“Next.js = React Server Components + Streaming 的代名词”

RSC(Next App Router with Streaming)

改为在服务器端取数据(Server Fetching),利用 React Server Components(RSC)。

模式LCPSidebarMessagesToggle interactive无交互间隙
Next.js App router (Lift-and-shift)1.28s4.4s4.9s3.8s2.52s
Next.js App router (Server Fetching)1.78s1.78s1.78s4.2s2.42s

奇怪的是:LCP、Sidebar、Messages 全变成相同的数值 (1.78s)。 这说明:

React 在等待所有 async 组件都完成后,才一次性发送整个 HTML。

这就和 传统 SSR 一样:

  • 没有任何「流式分块(Streaming)」;
  • 没有「边加载边显示」;
  • 没有 Suspense → 没有流。

React Streaming 的关键点是:

React 只会以 Suspense 边界为「chunk 边界」。

如果你不加 <Suspense>

  • React 会等待每个异步组件全部完成;
  • 没有一个“可以先发出去”的节点;
  • 整个页面变成一坨“大块 HTML”,一次性发送;
  • RSC 的 Streaming 效果被完全关闭。

正确的写法:加入 <Suspense>

<Suspense fallback={<div>Loading inbox...</div>}>
    <InboxWithFixedBundlePage messages={messages} />
</Suspense>

效果:

  • React 先渲染「关键路径」(上层内容),生成首个 HTML Chunk;
  • 同时等待 Suspended 的 Sidebar / Messages;
  • 数据一到,就把相应部分「填补」回页面;
  • 浏览器侧用户体验:先看到主要框架,再逐步出现内容。
模式LCPSidebarMessagesToggle interactive无交互间隙
Next.js App router (Server Fetching with Suspense)1.28s1.28s1.28s3.8s2.52s

LCP 又回到最快值 1.28s!

  • Sidebar 和 Messages 同步返回;
  • 说明 React 将它们打包进了同一批流式块中;
  • 所有内容「一起流到客户端」,快且高效。

性能总结

React Server Components + Streaming(即 Next.js App Router)确实能更快,但前提是你必须正确使用 Suspense 和服务端数据获取,否则反而会更慢。

模式特点优点缺点
Client-Side Rendering (CSR)数据在客户端用 useEffect 拉取页面交互最早可用;切页最快首次加载最慢(LCP 最差)
Server-Side Rendering (Client Data Fetching)HTML 在服务端生成,但数据仍在客户端请求首屏加载比 CSR 快JS 启动前页面“不可交互”(no interactivity gap)
SSR (Server Data Fetching)HTML 和数据都在服务端生成页面完整显示更早生成 HTML 时间变长;仍有交互延迟
Next.js Pages(传统版)等价于 SSR + 各页面预取稳定成熟,开发简单性能中规中矩
Next.js App Router(Server Components)支持 React Server Components + Streaming正确使用 Suspense 后首屏最快错误配置(忘加 Suspense)会比以前更慢;开发复杂度高
  1. CSR 是最慢的,但交互最流畅。 页面完全由客户端生成,加载时间最长,但一旦出现内容,用户马上能点、能滑,交互无延迟。

  2. SSR 极大提升了首屏速度,但引入“交互空白期”。 页面虽然很快出现,但 JS 还没加载完,此时按钮点击、输入框等全都“假死”,这是 SSR 常见的“no interactivity gap”。

  3. 服务端拉数据(Server Data Fetching)让“完整页面”更早出现。 服务端直接拿到数据再渲染 HTML,可以让用户更早看到完整内容(不用等前端拉取),但总体渲染时间略长。

  4. 迁移到 App Router 如果没加 <Suspense>,性能会退步。 因为 Streaming 渲染的分块点是由 <Suspense> 决定的。没加 Suspense,就会让整个页面变成一个大块,必须等所有异步数据都返回才能一次性发送,退化成传统 SSR。

  5. 加上 <Suspense> 后,性能才真正提升。 React 会先渲染“关键内容”,然后等待 Suspense 内的异步组件加载完成,再流式发给客户端。这样用户能更早看到主要内容(LCP 改善)。

  6. Server Components 本身不会显著加速。 它的“快”来自 Streaming + Suspense + 服务端数据获取, 而不是“组件在服务端渲染”这个事实本身。

  7. Next.js App Router 的 JavaScript 下载延迟,可能反而让交互更慢。 因为它拆分得更多,JS 加载逻辑复杂,有时导致“no interactivity gap”更大。


Source

文中的数据来源,该博客提供了GitHub 的仓库地址和她如何测量:

渲染方式LCP (no cache / JS cache)Sidebar (no cache / JS cache)Messages (no cache / JS cache)Toggle interactive (no cache / JS cache)No interactivity gap说明
Client-Side Rendering (CSR)4.1s / 0.8s4.7s / 1.5s5.1s / 2.0s4.1s / 0.8s完全由客户端渲染,加载最慢但立即可交互
Server-Side Rendering (Client Data Fetching)1.61s / 0.8s4.7s / 1.5s5.1s / 2.0s4.0s / 0.9s2.39s / 0.1sHTML 来自服务端,但数据仍在客户端请求;加载快但有“交互空白期”
Server-Side Rendering (Server Data Fetching)2.16s / 1.24s2.16s / 1.24s2.16s / 1.24s4.6s / 1.4s2.44s / 0.15s服务端先取数据再渲染,首屏完整但生成耗时稍高
Next.js Pages (Client Data Fetching)1.76s / 0.8s3.7s / 1.5s4.2s / 2.0s3.1s / 0.9s1.34s / 0.1s旧版 Next.js,客户端取数据;表现稳定
Next.js Pages (Server Data Fetching)2.15s / 1.15s2.15s / 1.15s2.15s / 1.15s3.5s / 1.25s1.35s / 0.1s旧版 Next.js,使用 getServerSideProps 获取数据;体验良好
Next.js App Router (Lift-and-shift)1.28s / 0.65s4.4s / 1.5s4.9s / 2.0s3.8s / 0.9s2.52s / 0.25s直接迁移到 App Router,但未优化;性能不理想
Next.js App Router (Server Fetching, Forgotten Suspense)1.78s / 1.2s1.78s / 1.2s1.78s / 1.2s4.2s / 1.3s2.42s / 0.1s使用服务端拉数据,但忘了 Suspense;退化为普通 SSR
Next.js App Router (Server Fetching with Suspense)1.28s / 0.75s1.28s / 0.75s1.28s / 1.1s3.8s / 0.8s2.52s / 0.05s正确使用 Suspense 和流式渲染;LCP 最快、整体最优