我要提问
ARTICLE / 001 · 前端开发

原创文章

资深开发者执笔的深度技术长文,从原理到工程落地,逐层拆解。

React Server Components 渲染机制深度解析

React Server Components 渲染机制深度解析

React Server Components(RSC)是 React 架构近十年来最根本的一次范式转移。它不再只是"服务端渲染(SSR)的增强版",而是把组件本身拆成了两个运行时:一个运行在服务器上,可以直连数据库与文件系统;一个运行在浏览器里,承担交互与状态。理解 RSC 的关键,不是记住几个 API,而是搞清楚"组件边界在哪里、数据如何流动、渲染如何分块"。

一、从 CSR/SSR 到 RSC:渲染模型的演进

传统的客户端渲染(CSR)把整个组件树放在浏览器里执行,首屏依赖 JS 加载与执行,TTV(Time to View)与 TTI(Time to Interactive)之间存在明显鸿沟。服务端渲染(SSR)把首屏 HTML 在服务端拼好,缩短了 TTV,但 hydration 时仍要把整棵树的 JS 全部下载并执行,水合成本随组件数量线性增长。

RSC 在此基础上引入了第三个维度:让一部分组件永远不进入客户端 bundle。服务端组件渲染产物不是 HTML 字符串,而是一个可序列化的组件树描述(RSC Payload),客户端根据这份描述决定哪些部分需要 hydrate、哪些部分只是纯静态节点。

核心区别:SSR 产出的是"已渲染好的 HTML + 需要重新执行的组件 JS";RSC 产出的是"组件树的序列化结构 + 仅交互部分需要的 JS"。前者是渲染结果,后者是渲染指令。

二、组件边界:Server 与 Client 的划分规则

RSC 的心智模型建立在"默认服务端"之上。在 App Router 中,所有组件默认都是 Server Components,只有文件顶部声明了 "use client" 的才是 Client Components。这条边界一旦划定,就决定了组件能做什么、不能做什么。

2.1 各自的能力边界

  • Server Components:可直接访问数据库、文件系统、内部 API;可以使用 async/await 获取数据;不能使用 useState、useEffect、事件监听与浏览器 API;不能导入仅在客户端可用的库。
  • Client Components:可以使用 hooks、事件、浏览器 API;不能直接访问数据库;可以接收从 Server Components 串行化传下来的可序列化 props。

2.2 边界穿越的规则

Server Components 可以导入并渲染 Client Components,并向其传递可序列化的 props(字符串、数字、数组、对象、React 元素等)。但 Client Components 不能直接导入 Server Components——它只能通过 children prop 接收由父级 Server Component 传入的服务端组件实例。这是 RSC 最容易被忽视的"单向数据流"约束。

// layout.tsx (Server Component)
import SearchBox from './SearchBox' // Client Component
import Results from './Results'     // Server Component

export default function Layout() {
  return (
    <main>
      {/* Server 渲染 Server,并把 Server 作为 children 传给 Client */}
      <SearchBox>
        <Results />
      </SearchBox>
    </main>
  )
}

这种"Server 作为 Client 的 children"模式,是 RSC 解决"在交互组件内部嵌入数据密集型组件"的标准手段。Results 仍在服务端渲染,它的数据获取不会被打包进客户端 bundle。

三、RSC Payload:可序列化的组件树

RSC 的渲染产物不是 HTML,而是一种行分隔的文本协议(RSC Payload)。每一行是一个 JSON 片段,描述一个节点:它的类型、props、以及子节点引用。客户端 React 运行时读取这份 Payload,重建虚拟树,再把其中需要交互的部分挂载为真实 DOM。

// 简化的 RSC Payload 形态
0:["$","div",null,{"className":"page"}]
1:["$","h1",null,{"children":"文章列表"}]
2:["$","ul",null,{"children":[
  3,5,7
]}]
3:["$","li",null,{"children":4}]
4:["$","$L","ArticleCard",{"id":101,"title":"RSC 深度解析"}]
5:["$","li",null,{"children":6}]
6:["$","$L","ArticleCard",{"id":102,"title":"限流方案对比"}]

其中 $L 表示这是一个 Client Component 的引用,客户端会在 hydration 时按需加载对应模块。$ 前缀是 React 内部的类型标记。这套协议使得服务端可以"逐块"产出,配合流式传输实现渐进式渲染。

RSC Payload 的妙处在于:它让"服务端渲染"和"客户端水合"共用同一份数据结构,避免了 SSR 中 HTML 与 hydration data 重复表达的问题,也天然支持按需加载客户端组件代码。

四、数据获取:async 组件与 fetch 的去重

在 RSC 中,Server Components 可以直接是 async 函数,用 await 获取数据。这彻底改变了过去"在 useEffect 里发请求、用 useState 存数据"的瀑布式获取模式。数据获取从"渲染后"前移到"渲染中"。

// app/articles/page.tsx (Server Component)
export default async function ArticlesPage() {
  // 直接 await,无需 hooks
  const res = await fetch('https://api.mrgr.cn/articles', {
    next: { revalidate: 60 } // ISR:60 秒增量再验证
  })
  const articles = await res.json()
  return (
    <ul>
      {articles.map(a => <li key={a.id}>{a.title}</li>)}
    </ul>
  )
}

4.1 fetch 的自动去重与缓存语义

React 扩展了原生 fetch,为其增加了缓存与去重能力。在同一棵组件树的一次渲染中,对同一 URL 的多次 fetch 会自动去重,避免子组件重复请求。通过 cachenext.revalidatenext.tags 可以声明缓存策略:

  • cache: 'force-cache':默认,永久缓存直到手动失效。
  • cache: 'no-store':禁用缓存,每次请求都重新获取。
  • next: { revalidate: 60 }:时间驱动的增量静态再生。
  • next: { tags: ['articles'] }:标签驱动,可按需 revalidateTag('articles') 失效。

五、流式渲染与 Suspense 的协同

RSC Payload 是流式的——服务端可以先把已就绪的部分发出去,把未就绪的部分用 Suspense 占位符表示,等数据到达后再追加发送对应的 chunk。浏览器收到占位符时渲染 fallback,收到后续 chunk 时无缝替换。这就是"流式 SSR + 选择性 hydration"的实现基础。

// 推荐列表(快)立即返回,热门榜(慢)流式返回
export default function Page() {
  return (
    <>
      <ArticleList /> {/* 已就绪,立即流出 */}
      <Suspense fallback={<Skeleton />}>
        <HotRanking /> {/* await 较慢,先发 fallback */}
      </Suspense>
    </>
  )
}

这种模式下,首屏 TTV 不再被最慢的组件拖累。慢组件的 chunk 会作为独立的流片段追加到响应末尾,客户端 React 收到后触发状态更新,把 Skeleton 替换为真实内容。整个过程没有额外的客户端请求往返。

六、落地实践与常见陷阱

6.1 选型建议

  • 把数据获取、静态展示、内容密集型组件尽量留在 Server 端。
  • 把交互(表单、拖拽、动画、状态机)收敛到最小的 Client Component 边界内。
  • 第三方库若依赖 DOM 或 useEffect,需用 "use client" 包裹一个适配层,避免污染整个路由。

6.2 高频陷阱

  • 把不可序列化的对象当 props 传给 Client Component:如函数、Class 实例、Date 对象会报错。Date 需序列化为 ISO 字符串。
  • 在 Server Component 中误用 hooks:useState/useEffect 在服务端无意义,会直接报错。
  • 跨边界共享状态:Server 与 Client 之间没有"实时状态同步",状态只能由 Client 自己持有,或通过 Server Action 触发重渲染。
  • 过度使用 "use client":在根布局加 "use client" 会让整棵树退化为 CSR,丧失 RSC 的全部收益。
判断边界的一条经验法则:如果一个组件不需要响应用户输入、不需要浏览器 API,它就应该是 Server Component。每多一个 Client Component,就多一份进入客户端 bundle 的代码。

结语

RSC 不是银弹,它解决的是"如何让服务端能力真正进入组件树",而非"如何让页面更快"这一单一问题。真正用好 RSC,需要重新建立"边界意识":哪些数据在服务端取,哪些交互在客户端做,哪些组件可以流式降级。当你开始用 RSC Payload 的视角而非 HTML 的视角去看待首屏,RSC 的设计意图就清晰了。mrgr.cn 前端组会持续跟进 React 19 在生产环境的落地实践,欢迎在问答社区交流你的踩坑经验。

返回文章列表