项目跑了两年,四十多个页面,用的是 Next.js Pages Router。首屏 JS 均值 280KB,业务逻辑里塞了一堆只为拿数据的 useEffect。我们决定迁移到 App Router + RSC,目标只有一个——把数据获取挪回服务器。
三周后回头看,RSC 并没有让一切变快,它只是把「快」的实现路径换了。这篇文章记录迁移中真正踩过的坑,以及哪些场景该用、哪些别碰。
我们为什么上 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。
RSC 和 SSR 是两套机制,可以共存。App Router 默认所有组件都是 Server Component,只有声明了 "use client" 的才是 Client Component。SSR(HTML 预渲染)仍然作用于首次请求。
RSC 的核心收益不是「更快地渲染」,而是「让一部分代码根本不进浏览器」。
— 迁移两周后的复盘
迁移第一周:文档没写的事
官方教程会告诉你加 "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 |
迁移前先跑一次依赖扫描。用 npm ls 加上对 window / document 的全文搜索,把会访问浏览器 API 的库标记出来,能省掉一周的"为什么报错"。
真正受益的场景
迁移完成后我们用 Lighthouse 和真实用户监控对比了每个路由。真正拿到收益的是下面三类页面。
数据密集的列表与详情页
文章列表、商品详情、报表——这类页面的特征是「数据量大、交互少」。迁移前客户端要下载 60KB 的过滤与排序逻辑,迁移后这部分代码完全留在服务端:
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 次。
过度工程的红线
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 解决的是「代码不该进浏览器」的问题,不是「交互该怎么写」的问题。
— 给团队定的取舍原则
复盘与迁移路线图
三周迁移,最终数据如下(取 P75 真实用户数据,仅统计迁移到 RSC 的路由):
但代价也很实在——团队花了两周才稳定掌握边界划分。如果重来,我会按这条路线走:
- 第一步:只迁移数据密集的内容页。这类页面收益最大、风险最低,是建立信心的样板。
- 第二步:重构布局层共享数据获取。把用户态、配置这类全局数据挪到 Root Layout,消除重复请求。
- 第三步:交互密集页面保持 Client。不要为了"统一架构"硬拆,混合架构才是 App Router 的常态。
- 第四步:建立 Server / Client 边界审查清单。每次 PR 评审都对照检查,防止边界慢慢腐化。
不要为了"用上 RSC"而迁移。如果你的项目首屏已经很快、包体积不痛、数据获取方式健康,迁移的收益可能覆盖不了学习成本。先量化痛点,再决定。
最后一条经验,也是最难落地的一条——RSC 是一种心智模型的切换,不是 API 的替换。它逼着你重新思考「这段代码应该在哪个环境运行」。一旦团队形成了这个直觉,迁移的价值会超出性能数字本身。
我们仍在迭代边界,仍在把不该进浏览器的逻辑挪回服务端。如果你也在迁移的路上,欢迎交流踩坑心得。