React Server Components 与 Streaming SSR
Server Components 是 React 近十年最大的一次模型变化,也是被误解最多的一次。它要解决的核心问题有两个:一是把"只为了取数据而写的组件"从客户端 bundle 里彻底删掉,二是让服务端能直接连数据库、不用再绕一圈 API。但它不是"SSR 的升级版",也不意味着"以后都写服务端组件"——它是一套服务端与客户端的分工约定。这一章讲清边界怎么划、数据怎么流、缓存怎么算,以及那几个"一写就报错"的经典坑。
RSC 到底在解决什么问题
先看传统 SPA 和 SSR 各自的痛,你才能理解 RSC 的设计动机。传统 SPA:首屏要等 JS 下载、解析、执行完才渲染;SSR:服务端渲染出 HTML 解决了首屏,但 hydrate 时又要把同样的组件在客户端跑一遍,而且数据还是要通过 API 拿。
| 维度 | 纯 CSR(SPA) | 传统 SSR | RSC + Streaming |
|---|---|---|---|
| 首屏 HTML | 基本没有内容,等 JS 执行 | 有完整 HTML,首屏快 | 有 HTML,且可以"先返回骨架、数据到了再流式补上" |
| 客户端 JS 体积 | 全部组件都打进 bundle | 全部组件都要 hydrate,bundle 一样大 | 服务端组件的代码完全不进 bundle,体积显著变小 |
| 数据获取 | 客户端 fetch,瀑布式请求 | 服务端预取,但组件里还是要写 getServerSideProps 之类的胶水 | 组件里直接 await,可以并行,可以流式 |
| 能否直连数据库 | 不能,必须走 API | 能,但要经过框架约定的数据函数 | 能,直接在组件里查库,不需要中间 API 层 |
| 密钥安全 | 危险:环境变量容易被打进前端包 | 安全 | 安全:服务端组件里的代码根本不会到浏览器 |
| 交互能力 | 完整 | 完整(hydrate 之后) | 只有客户端组件有交互;服务端组件不能有 state 和事件 |
论关键洞察:不是所有组件都需要在浏览器里跑
① 回想一下你的页面里有多少组件是"纯展示"的:商品标题、文章正文、列表项、页脚、面包屑……它们拿到数据只是渲染 HTML,没有任何交互。但传统 CSR/SSR 会把它们的代码连同依赖的库、格式化函数、甚至 markdown 解析器一起打进 bundle,然后让浏览器再执行一遍。
② 更糟的是,数据只能通过 API 绕一圈:组件想拿数据,就得先请求 /api/products,服务端再去查库。为了这一层 API,你要写路由、定 DTO、做鉴权、处理错误。如果这个"API"根本没有别的消费者,那它就是纯粹的中间层成本。
③ RSC 的思路:把这类组件标记为"服务端组件",它们在服务端执行、把渲染结果(一种叫 RSC Payload 的序列化格式)流给客户端。客户端只拿到"渲染结果",不需要这些组件的代码,也不需要它们的依赖。这就是 bundle 变小的根本原因,也是它和 SSR 的本质区别——SSR 是"在服务端先渲染一遍,客户端还要再渲染一遍";RSC 是"这个组件的代码永远不到客户端"。
Server / Client 边界与 'use client'
这是最容易搞混的一点:在 App Router 里,所有组件默认都是服务端组件。'use client' 不是"把某个组件变成客户端组件",而是在模块图上划一条边界线——从这一行开始往下,所有 import 进来的模块都进入客户端 bundle。
边界线的实际含义:一个页面里两种组件共存
| 能力 | Server Component(默认) | Client Component(加 'use client') |
|---|---|---|
| async / await 取数据 | 可以,直接在组件里 await | 不行。要用 useEffect 或数据请求库 |
| useState / useReducer | 不可以 | 可以 |
| useEffect / 生命周期 | 不可以 | 可以 |
| 事件处理(onClick 等) | 不可以 | 可以 |
| 浏览器 API(window / localStorage) | 不可以(服务端没有) | 可以 |
| 直接访问数据库 / 读取密钥 | 可以,安全 | 不可以(代码会到浏览器) |
| 能否被另一种组件渲染 | 可以被客户端组件通过 children 传入,但不能被 import | 可以被服务端组件渲染 |
| 代码是否进入客户端 bundle | 不进(这是最重要的收益) | 进(以及它 import 的一切) |
规则一:'use client' 是"边界",不是"标记"。你写在文件顶部,它声明的是"这个文件以及它 import 的所有模块都进入客户端"。所以千万不要在一个共享的工具文件顶部随手加 'use client'——它会把整条 import 链拖进 bundle。反过来,如果一个客户端组件要引用服务端组件,不能用 import,只能通过 props 把渲染好的元素传进去。
规则二:props 必须可序列化。服务端组件传给客户端组件的 props,要能被序列化(字符串、数字、布尔、数组、普通对象、Date、Promise 等)。函数、class 实例、Symbol 不能直接传(Server Actions 是例外,它本质是引用)。所以"把一个回调函数传给客户端组件让它在客户端调服务端方法"这种写法,必须用 Server Action 或 route handler。
规则三:把边界尽量往下推。这是实践中最重要的一条。反例:整个页面加 'use client',为了一个按钮的点击,把整个页面的数据获取和渲染都变成客户端逻辑,RSC 的收益全部丢掉。正例:页面(服务端)→ 卡片列表(服务端)→ 加入购物车按钮(客户端)。边界越靠叶子节点,越少代码进 bundle。
Streaming SSR 与 Suspense:先给骨架,再补内容
传统 SSR 有一个尴尬:必须等所有数据都准备好,才能返回 HTML。一个页面里有个慢接口(比如推荐模块要 800ms),整个页面的 TTFB(首字节时间)就被拖到 800ms,用户盯着白屏。Streaming 把这个模型改了:先把不依赖慢数据的部分立即流给浏览器,慢的部分用 Suspense 包起来,数据好了再流式补充,浏览器自动把它填到正确位置。
Streaming 的写法:用 Suspense 把慢的部分隔离出来
论Streaming 的两个容易忽略的前提
① 它依赖 HTTP 的分块传输。服务端先 flush 一部分 HTML,后续继续写。所以反向代理必须关闭响应缓冲(Nginx 里要确认没有开 proxy_buffering on 并且没有配 compression 把整个响应憋住),否则"流式"会在代理层被吃掉,退化成"等全部完成后一次性返回"。这是上线后"流式没生效"的第一大原因。
② Suspense 的边界要在"数据边界"上,而不是随便包。如果你把整个页面都包进一个 Suspense,那就等于没有流式(还是要等最慢的那个)。正确做法是按数据块的耗时分组:快的一起进首屏,慢的各自独立包 Suspense,互不阻塞。另外,多个 Suspense 并行时,谁先好谁先显示——这也是为什么要把"评论"和"推荐"分成两个 Suspense,而不是包在一起。
③ 别为了流式而流式。如果页面所有数据都在 100ms 内拿完,拆成一堆 Suspense 只会让骨架闪一下再被替换,反而造成视觉抖动。判断标准:某个数据块的耗时明显长于其他(比如超过 300ms 的差距),才值得单独包。
App Router 的约定文件与缓存语义
Next.js 的 App Router 用"约定文件"替代了手写配置。理解这张表,你就知道该把什么代码放在哪个文件里。同时,缓存是 App Router 里最反直觉的部分,必须单独拿出来讲。
| 约定文件 | 作用 | 要点 |
|---|---|---|
layout.tsx | 共享布局,在路由切换时保持挂载不重新渲染 | 适合放导航栏、侧边栏、全局 Provider。注意:layout 里不要放需要随路由变化的状态,它不会重新执行。可以做数据获取(比如取当前用户),但不会因导航而刷新。 |
page.tsx | 路由对应的页面内容 | 路由切换时重新渲染。是 async 组件的主要落点。 |
loading.tsx | 该路由段的加载态,自动包一层 Suspense | 用户点击导航后立刻显示,是感知速度的关键。它包住的是整个 page,所以想更细粒度就用页内的 <Suspense>。 |
error.tsx | 该路由段的错误边界 | 必须是客户端组件(需要 reset 函数来重试)。只捕获渲染期的错误,捕获不到 layout 自身的错误(那要用上层的 error)。 |
not-found.tsx | 404 页面 | 配合代码里调用 notFound() 触发。适合"资源不存在"这类语义明确的场景。 |
route.ts | 同目录下定义 API 端点 | 当你要给"外部系统或客户端 JS"提供接口时才需要它。服务端组件能直接查库的话,通常不必写 route.ts。 |
middleware.ts | 请求进入前的中间件(鉴权、重定向、A/B) | 运行在 Edge Runtime,能力受限(不能连数据库)。适合做"读 Cookie 决定跳转"这类轻逻辑。 |
Server Actions 与缓存:写操作怎么写、数据什么时候失效
第一:以为"用了 Server Action 页面就会自动更新"。不会。Server Action 执行完写库之后,当前页面上的数据还是旧的缓存,除非你显式调用 revalidatePath / revalidateTag。现象是"我提交了评论,刷新才看到"。所以 Server Action 的最后一步永远是"失效相关缓存",这是标准动作。
第二:以为用户相关数据可以缓存。如果你的页面用 cookies() 或 headers() 读了登录态,这段渲染就变成动态的、不可缓存的——这是对的,也是必须的。千万不要把"某个用户的数据"放进共享缓存,那会导致 A 用户看到 B 用户的内容,是严重的数据泄漏。凡是涉及身份的数据,一律走动态渲染或按用户维度区分缓存键。
RSC 与 SWR / React Query 的分工
学到这里很多人会问:"那我还需要 SWR / React Query 吗?"需要,但它们负责的领域不重叠。把两者混淆,会写出"明明已经是服务端数据了还在客户端重新请求一遍"的代码。
| 数据场景 | 用 RSC(服务端组件直接取) | 用 SWR / React Query(客户端) |
|---|---|---|
| 页面首屏的正文数据 | ✅ 最合适。减少 API 层、减少 bundle、TTFB 可控 | ❌ 会造成"先空壳、再请求、再渲染"的额外往返 |
| SEO 相关的数据 | ✅ 必须在服务端拿到并渲染成 HTML | ❌ 爬虫看不到客户端请求的数据 |
| 需要密钥的接口调用 | ✅ 密钥安全 | ❌ 密钥会暴露 |
| 实时性强的数据(股价、在线人数) | ❌ 服务端渲染的数据一到浏览器就"旧"了 | ✅ 客户端轮询 / WebSocket 更合适 |
| 用户交互触发的数据(搜索、筛选、无限滚动) | ❌ 每次都往返服务端,体验差 | ✅ 客户端缓存、乐观更新、自动重试 |
| 需要乐观更新(点赞立刻变红) | ❌ 服务端渲染不适合 | ✅ React Query 的 mutation + optimistic update 是为此设计的 |
| 列表分页 / 无限加载 | ❌ 每个分页都要服务端往返 | ✅ 客户端缓存分页结果,loading 状态更好控制 |
| 跨页面共享的缓存数据(当前用户信息) | ⚠️ 每次导航都要重新取(或依赖框架缓存) | ✅ 客户端缓存能跨路由复用 |
混合使用的正确姿势:服务端给首屏,客户端管交互
论什么数据适合放服务端、什么放客户端
① 判据一:数据在"这次页面渲染时"是否已经确定?商品详情、文章正文、权限菜单——渲染时就确定了,放服务端。搜索关键词变化后的结果、滚动加载的下一页——渲染时还不知道,放客户端。
② 判据二:数据变化频率高不高?一秒变一次的在线人数放服务端没意义(HTML 一到浏览器就过时了),应该客户端订阅。一天变一次的配置数据放服务端 + 定期 revalidate 最省事。
③ 判据三:请求是否依赖"用户此刻的操作"?依赖,就是客户端的事。RSC 擅长的是"给定 URL,渲染出确定的页面";交互过程中的数据流属于客户端。
常见坑:那些一写就报错的地方
报错信息一般是 You're importing a component that needs useState. It only works in a Client Component,或者更绕的 Only plain objects can be passed to Client Components。原因:文件里用了 useState/useEffect/onClick,但没加 'use client'。解决办法是加上指令,但加之前先想一秒:这个组件真的需要整体变成客户端组件吗?更好的做法常常是"把交互的那一小块抽成独立的客户端组件,页面其余部分留在服务端"。另外注意:'use client' 必须在该文件的最顶部(在所有 import 之前),写在中间会无效。
比如在服务端组件里 import { motion } from 'framer-motion'、import Swiper from 'swiper',或者在模块顶层访问了 window、document。表现是构建报错,或者运行时报 window is not defined。因为服务端没有浏览器环境。解法有三条:① 把用到这类库的部分抽成客户端组件(首选);② 确实需要在服务端组件里延迟到客户端才初始化,用 next/dynamic 配 ssr: false;③ 如果只是访问浏览器 API,确保这行代码在 useEffect 里(那只可能在客户端组件中)。养成习惯:看到 window is not defined,第一反应是"我是不是在服务端组件里碰了浏览器 API"。
把页面上每一个数据块都包一层 <Suspense>,结果是:页面刚出现时一堆骨架在闪,然后一个个被内容替换,每一次替换都会让下面的内容往下跳。用户想点某个按钮,手指刚要落下,按钮被顶上去了——这就是布局抖动(CLS),体验比"多等 200ms"更糟。解法:① 给 Suspense 的 fallback 预留与最终内容相近的高度(骨架尺寸要对齐真实内容);② 把耗时相近的数据放进同一个 Suspense(减少替换次数);③ 只给"明显更慢"的模块单独包(通常超过 300ms 差距才值得);④ 关键交互元素(比如主要按钮)尽量放首屏、别放在会跳动的区域里。
① RSC 解决两件事:把纯展示组件的代码从 bundle 里删掉;让服务端组件直连数据源,省掉中间 API 层。
② 它和 SSR 的核心区别:SSR 客户端还要把所有组件再渲染一遍,RSC 的服务端组件代码永远不到客户端。
③ App Router 里组件默认是服务端组件;'use client' 划的是一条边界线,它 import 的整条链都会进客户端。
④ 客户端组件的 props 必须可序列化;服务端组件不能 import 到客户端组件里,只能通过 children/props 传入。
⑤ 把边界尽量推到叶子节点,别给整个页面加 'use client'。
⑥ Streaming + Suspense 让慢数据不阻塞首屏;前提是代理不缓冲响应,且 Suspense 要按"数据边界"划。
⑦ 约定文件:layout 不重渲染、page 重渲染、loading 自动包 Suspense、error 必须是客户端组件。
⑧ Server Action 写完数据后必须 revalidate,否则页面还是旧数据;涉及身份的数据绝不能进共享缓存。
⑨ RSC 管"渲染时就确定的数据",SWR/React Query 管"交互产生的数据";交界处用 initialData 传递首屏数据,避免重复请求。
⑩ 三个高频坑:忘了 'use client'、在服务端组件里 import 浏览器专用库、过度 streaming 造成布局抖动。
本章自测
1.(概念题)RSC 和 SSR 到底有什么区别?
看答案
答案:SSR 是"在服务端先渲染一遍 HTML,然后客户端把同样的组件再执行一遍做 hydrate"——组件代码始终都在客户端 bundle 里,只是提前产出 HTML 让首屏快。RSC 是"服务端组件的代码永远不进入客户端 bundle"——服务端执行完把渲染结果序列化流给客户端,客户端只负责把结果填进 DOM。所以两者解决的问题不同:SSR 主要解决首屏渲染速度与 SEO;RSC 主要解决"bundle 太大"和"取数据要绕 API 层"。两者可以组合使用(App Router 里就是组合的)。
2.(边界题)我在页面组件里写了 useState 报错。直接加 'use client' 有什么问题?更好的做法是什么?
看答案
答案:加 'use client' 能修好报错,但副作用是这个文件以及它 import 的一切都进入了客户端 bundle——如果这是一个大页面,它依赖的格式化库、markdown 解析器、甚至一些大数据处理工具都会被打包给浏览器,RSC 的收益全丢。更好的做法:把需要交互的那一小块抽成独立的客户端组件(比如把这个按钮/表单抽到单独文件),页面本身保持服务端组件。心法:'use client' 应该尽量出现在叶子节点,而不是页面顶层。
3.(缓存题)我在 Server Action 里成功往数据库插了一条评论,但页面上看不到,要刷新才出现。为什么?
看答案
答案:因为写库成功了,但页面用的还是缓存的渲染结果,没有失效。App Router 的缓存不会自动感知你的数据库变了。解法是在 Server Action 里显式调用:① revalidatePath(`/products/${id}`) 让整段路由重新渲染;或 ② 更细粒度地给数据打 tag(fetch(url, { next: { tags: [...] } }))然后 revalidateTag(...)。标准动作:每个写操作的 Server Action,最后一行都应该是缓存失效。另外要检查这段渲染有没有被标记成静态——如果它读了 cookies(),本来就是动态的,那就说明是别的地方的缓存问题。
4.(性能题)我给页面每个模块都加了 Suspense 和骨架屏,结果产品说"页面一直在跳"。怎么改?
看答案
答案:这是过度 streaming 引起的布局抖动(CLS)。改法:① 给骨架预留正确的高度,让 fallback 和最终内容占位接近,替换时不推挤下方内容(最常见的原因是骨架只有一行、真实内容是五行);② 合并 Suspense,把耗时接近的数据放进同一个边界,减少替换次数(一次替换比三次好);③ 只给明显慢的模块单独包,快数据直接 await 进首屏;④ 把主要交互元素放在稳定的位置,别放在会被推挤的区域。判断标准:如果某个数据的耗时和其他模块差距不到 200~300ms,就不值得单独开一个 Suspense。