MODO®
ISSUE 07 / 2026
06 架构手记 / Architecture

React Server Components
踩坑记录

把一个跑了两年、四十多个页面的 Next.js Pages Router 项目迁移到 App Router + RSC。三周下来,团队只有一个共识:它不是 SSR 的替代品,而是一种新的渲染心智模型。

作者 林知远
日期 2026.09.15
时长 14 分钟
标签 React · 架构

项目跑了两年,四十多个页面,用的是 Next.js Pages Router。首屏 JS 均值 280KB,业务逻辑里塞了一堆只为拿数据的 useEffect。我们决定迁移到 App Router + RSC,目标只有一个——把数据获取挪回服务器。

三周后回头看,RSC 并没有让一切变快,它只是把「快」的实现路径换了。这篇文章记录迁移中真正踩过的坑,以及哪些场景该用、哪些别碰。

01

我们为什么上 RSC

迁移前我们先列了一份动机清单。一个架构决策如果没有可量化的痛点,就只是「追新」:

痛点 现状 RSC 能做什么
客户端包体积 平均 280KB,列表页数据序列化逻辑占 60KB 数据获取与转换留在服务端,不下发到浏览器
瀑布请求 组件 mount 后再发请求,首屏空转 800ms 服务端 await 数据,渲染即完成
重复序列化 API 返回 JSON → 客户端再 parse → 再过滤 服务端直接消费,只下发渲染后的 RSC payload
SEO 与首屏 getServerSideProps 全量 SSR,交互页也走 SSR 按组件粒度选择 Server / Client,不再一刀切

动机明确后,第二个问题是——RSC 和 SSR 到底什么关系?团队里最常见的误解是「RSC 就是更好的 SSR」。

RSC ≠ 更好的 SSR

传统 SSR 是「请求来了 → 服务端渲染 HTML → 浏览器 hydration」。RSC 的运行模型完全不同:组件在服务端执行后产出的是一种序列化协议,浏览器拿到后由 React 的运行时重建组件树,但不重新执行服务端代码。

// Server Component:async,可以直接 await
async function ArticleList() {
  const articles = await db.query('SELECT * FROM articles');
  // 这段代码永远不会出现在浏览器里
  const sorted = articles
    .filter((a) => a.published)
    .sort((a, b) => new Date(b.date) - new Date(a.date));

  return (
    <ul>
      {sorted.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  );
}

关键区别在于:SSR 的产物是 HTML 字符串,RSC 的产物是结构化的组件树描述。这意味着客户端不需要重新执行数据获取与排序逻辑,也不需要把这些函数打进 bundle。

NOTE

RSC 和 SSR 是两套机制,可以共存。App Router 默认所有组件都是 Server Component,只有声明了 "use client" 的才是 Client Component。SSR(HTML 预渲染)仍然作用于首次请求。

RSC 的核心收益不是「更快地渲染」,而是「让一部分代码根本不进浏览器」。

— 迁移两周后的复盘
02

迁移第一周:文档没写的事

官方教程会告诉你加 "use client" 就能解决大部分问题。现实是——边界划错,全树失效。

"use client" 是单向门,不是开关

一旦某个文件声明了 "use client",它及其导入的所有子模块都会被纳入客户端 bundle。最容易踩的坑是:在 Client Component 里 import 了一个 Server Component。

// ❌ 报错:不能把 Server Component 当作 Client 的子组件直接 import
'use client';
import ServerOnlyChart from './ServerOnlyChart'; // 这个组件用了 fs 读取

function Dashboard() {
  return <ServerOnlyChart />; // React 会拒绝渲染
}

// ✅ 正确:Server Component 作为 children 传入
// app/dashboard.tsx(Server Component)
import ServerOnlyChart from './ServerOnlyChart';
import ClientToolbar from './ClientToolbar';

export default function Dashboard() {
  return (
    <ClientToolbar>
      <ServerOnlyChart />  {/* 作为 children 传递,合法 */}
    </ClientToolbar>
  );
}

这条规则背后是 RSC 的渲染契约:服务端组件的产出可以作为 props 传给客户端组件,但客户端组件无法在运行时主动创建服务端组件。理解了这一点,很多"为什么不工作"的困惑都会消失。

Context 失效:最痛的一刀

迁移前我们大量使用 React Context 做主题、用户态、i18n 注入。Pages Router 下 _app.tsx 里的 Provider 包裹一切,迁移后这个文件没了。

App Router 的根布局是 Server Component,没法直接放 createContext 的 Provider。被迫改造的方案是:建一个 Client 边界的 Provider 组件。

// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
import { I18nProvider } from './i18n';

export function Providers({ children, locale, messages }) {
  return (
    <ThemeProvider attribute="class">
      <I18nProvider locale={locale} messages={messages}>
        {children}
      </I18nProvider>
    </ThemeProvider>
  );
}

// app/layout.tsx(Server Component)
import { Providers } from './providers';
import { getMessages } from './i18n-server';

export default async function RootLayout({ children }) {
  const messages = await getMessages('zh');
  return (
    <html lang="zh-CN">
      <body>
        <Providers messages={messages}>
          {children}
        </Providers>
      </body>
    </html>
  );
}

这里有个微妙之处:messages 在服务端读取后作为 props 传给 Client Provider,整份文案会被序列化进 RSC payload。如果文案库很大,要按路由拆分,否则首屏 payload 会膨胀。

第三方库的兼容性盲区

不是所有库都能在 Server Component 里跑。我们的踩坑清单:

库 问题 对策
styled-components 依赖 Context 注册样式,RSC 无法注入 迁移到 CSS Modules 或换用零运行时方案
react-query QueryClient 是客户端单例,服务端无意义 RSC 里直接 await 数据,Client 交互页才用 RQ
moment / dayjs 插件 部分插件访问 window,服务端报错 locale 动态 import 或改用 Intl API
某些编辑器组件 构造期就访问 DOM,RSC 执行时无 DOM 声明 "use client" + dynamic import ssr:false
TIP

迁移前先跑一次依赖扫描。用 npm ls 加上对 window / document 的全文搜索,把会访问浏览器 API 的库标记出来,能省掉一周的"为什么报错"。

03

真正受益的场景

迁移完成后我们用 Lighthouse 和真实用户监控对比了每个路由。真正拿到收益的是下面三类页面。

数据密集的列表与详情页

文章列表、商品详情、报表——这类页面的特征是「数据量大、交互少」。迁移前客户端要下载 60KB 的过滤与排序逻辑,迁移后这部分代码完全留在服务端:

280→112KB
列表页 JS
820→180ms
首屏 LCP
0
客户端瀑布请求

SEO 敏感的内容页

之前用 useEffect 拉数据的页面,爬虫拿到的 HTML 是空壳。迁移后服务端直接 await 数据并渲染,爬虫一次请求即可拿到完整内容,收录率明显提升。

跨页共享的数据获取

App Router 的嵌套布局允许父布局获取一次数据,下发给多个子路由。我们把它用在了全局用户态上:

// app/(app)/layout.tsx
import { getUser } from '@/lib/auth';

export default async function AppLayout({ children }) {
  // 服务端一次获取,整棵子树共用
  const user = await getUser();
  return (
    <div className="app-shell">
      <Header user={user} />
      {children}  {/* 子路由无需再次请求用户态 */}
    </div>
  );
}

对比 Pages Router 下每个页面各自 getServerSideProps 重复请求用户接口,这里把 N 次请求合并为 1 次。

04

过度工程的红线

RSC 不是银弹。下面三类页面我们迁移后又退回了 Pages Router 或 Client 模式——因为收益为负。

交互密集的管理后台

看板配置页:拖拽排序、实时校验、联动筛选,几乎每个像素都在响应用户。把这类页面强行拆成 Server + Client 后,状态在两侧来回穿梭,可读性反而下降。

指标 迁移前(Client) 迁移后(RSC 拆分)
JS 体积 140KB 135KB
状态跨边界次数 0 7
维护成本 低 高

JS 只少了 5KB,却换来了 7 处跨边界状态传递。这类页面的正确做法是整页声明 "use client",让 RSC 不参与。

纯客户端的小工具

颜色拾取器、JSON 格式化、正则测试——这类页面没有服务端数据需求,也不在乎 SEO。迁移到 RSC 后唯一的变化是多了一层 "use client" 标注,纯属增加心智负担。

依赖实时交互状态的展示

比如「根据筛选条件实时渲染图表」的页面。数据在客户端筛选,如果硬要把图表组件做成 Server Component,每次筛选都要触发服务端重渲染,延迟反而比纯客户端更高。

RSC 解决的是「代码不该进浏览器」的问题,不是「交互该怎么写」的问题。

— 给团队定的取舍原则
05

复盘与迁移路线图

三周迁移,最终数据如下(取 P75 真实用户数据,仅统计迁移到 RSC 的路由):

280→112KB
平均 JS 体积
820→210ms
平均 LCP
12→3
客户端请求数

但代价也很实在——团队花了两周才稳定掌握边界划分。如果重来,我会按这条路线走:

WARN

不要为了"用上 RSC"而迁移。如果你的项目首屏已经很快、包体积不痛、数据获取方式健康,迁移的收益可能覆盖不了学习成本。先量化痛点,再决定。

最后一条经验,也是最难落地的一条——RSC 是一种心智模型的切换,不是 API 的替换。它逼着你重新思考「这段代码应该在哪个环境运行」。一旦团队形成了这个直觉,迁移的价值会超出性能数字本身。

我们仍在迭代边界,仍在把不该进浏览器的逻辑挪回服务端。如果你也在迁移的路上,欢迎交流踩坑心得。

React RSC Next.js 架构迁移