RSC 设计哲学:React 如何跨越两台计算机

从 'use client' 是什么到 Progressive JSON——Dan Abramov 2024-2025 年 RSC 系列核心思想提炼

React 面临一个根本矛盾:

  • 交互需要在用户电脑上即时响应(UI = f(state)
  • 数据处理需要在服务端访问资源(UI = f(data)

两个范式,两台计算机。RSC 要解决的问题,就是让一个组件树能同时跨越这两个环境。


一、两个 React

客户端范式:UI = f(state)

Counter 组件的 count 在用户电脑上,setState 必须零延迟响应。这类组件必须跑在客户端——状态是快照,闭包捕获当时的值。

服务端范式:UI = f(data)

PostPreview 组件需要读文件系统、查数据库。数据在服务端,组件应该跑在数据旁边,构建时执行完毕,客户端只拿渲染结果,零额外请求。

真正的公式:UI = f(data, state)

RSC 要做的事是把 f 拆分到两个运行环境,同时保持 React 的组合模型。


二、'use client''use server' 是什么

传统的前后端通信是 stringly-typed 的:

// 后端
app.post('/api/like', handler)

// 前端
fetch('/api/like', { body: JSON.stringify(...) })

字符串拼接,没有类型检查,找不到引用,重构困难。

RSC 把边界表达在模块系统里:

'use server':typed fetch()

// 后端
'use server'
export async function likePost(postId) { ... }

// 前端
import { likePost } from './backend'
await likePost(postId)  // 自动变成 HTTP 调用

import 不再只是“导入代码”,而是“跨网络调用”。类型安全,可静态分析,可以 Find All References。

'use client':typed <script>

// 客户端组件
'use client'
export function LikeButton({ postId, likeCount }) { ... }

// 服务端
import { LikeButton } from './frontend'
<LikeButton postId={42} likeCount={8} />

import 得到的不是函数本身,而是“客户端引用”——一个地址。服务端可以预渲染它,也可以只发引用让客户端按需加载。

两扇门:

'use client'  → 从服务端到客户端的门(发送 <script>)
'use server'  → 从客户端到服务端的门(发送 fetch)

同一个模块,从服务端 import 和从客户端 import,行为不同。RSC 扩展了 import 的语义,让两个环境共享模块系统但各自独立执行。


三、Impossible Components

RSC 让你能写出“不可能”的组件——同时包含服务端数据和客户端交互:

// 后端:读数据
async function GreetingBackend() {
  const myColor = await readFile('./color.txt', 'utf8')
  return <GreetingFrontend color={myColor} />
}

// 前端:处理交互
'use client'
function GreetingFrontend({ color }) {
  const [yourName, setYourName] = useState('Alice')
  return <p style={{ color }}>Hello, {yourName}!</p>
}

后端先执行,数据“已经在那了”,没有 loading 闪烁。前端接管交互,状态隔离,每个实例独立。一个标签,局部数据 + 局部状态,单次往返。

更进一步的例子:SortableFileList——后端 readdir() 获取文件列表,前端 useState 处理排序过滤。读文件系统只有服务端能做,实时过滤只有客户端能做,RSC 让这两件事共存于一个组件。


四、消灭客户端瀑布

数据获取一直是个权衡问题:

方案 优点 缺点
组件内 useEffect fetch colocation 好 客户端瀑布,性能差
React Query 缓存有帮助 仍然是客户端发起
路由级 Server Loader 一次往返,无瀑布 数据逻辑和组件分离
RSC colocation + 一次往返

Server Components 天然在服务端执行,数据获取在服务端靠近数据源,一次渲染 = 一次往返,客户端瀑布从根本上消失。Suspense 处理渐进式揭示,不阻塞其余内容。


五、Progressive JSON

RSC 不发送 HTML,发送的是渐进式 JSON

传统 JSON 的问题:必须等最后一个字节到齐才能 parse,一个慢的数据库查询阻塞整棵树。

RSC 的做法是广度优先传输 + 占位符:

第一帧(外壳立刻到达):
  { header: "$1", post: "$2", footer: "$3" }

第二帧(post 结构出来了):
  /* $2 */ { content: "$4", comments: "$5" }

第三帧(comments 填充完毕):
  /* $5 */ ["$6", "$7", "$8"]

占位符 $2 是一个 Promise,数据到了自动填充。一个慢的部分不阻塞其他部分。<Suspense> 控制用户看到什么,和数据传输是解耦的。


六、打包器为什么是 RSC 的一部分

RSC 不仅发送数据,还发送代码的引用

服务端渲染 <Counter initialCount={10} /> 时,不能把 Counter 的代码内嵌为字符串(不安全,重复传输)。正确做法是引用打包后的文件:'/chunk123.js#Counter',客户端按地址加载对应 chunk。

打包器在 RSC 中的三个职责:

  1. 构建时:找到 'use client' 文件,创建 chunk 入口
  2. 服务端:教 React 如何序列化模块引用
  3. 客户端:教 React 如何按引用加载模块

这也是为什么 RSC 需要框架(Next.js、Remix)才能用——打包器集成不是可选项,是协议的一部分。


七、“静态”只是提前运行的服务器

一个经常被忽视的洞察:

任何“服务器”框架都能输出“静态”站点——在构建时运行服务器,对每个页面发一次请求,把响应存到磁盘。

“静态”和“服务器”不是两种工具,而是同一套代码的两种运行模式。RSC 代码写一次,server 和 static 两种模式都能跑。

Dan 自己的博客(overreacted.io)就是例子:用 Next.js + RSC 写的,但输出是纯静态站点,部署在 Cloudflare 免费静态托管,成本 $0。Server Component 在构建时执行,不需要真正的服务器。


八、从 HTML 出发的推导

RSC 不是凭空发明的,而是 HTML 演化的必然结果。把这条演进路线走一遍:

Server Tags     → 自定义标签 = 函数,服务端调用并替换
Attributes      → 传参,支持对象
JSON 输出       → 序列化为 JSON 而非 HTML,保留对象结构
Async Tags      → 标签可以 async,读文件系统
Client Refs     → 'use client' = 有类型的 <script>
Server Refs     → 'use server' = 有类型的 fetch()
Streaming       → 异步标签不阻塞,Suspense 控制揭示时机

每一步都是自然延伸,没有魔法。最终得到的就是 RSC。


核心结论

'use client' 是有类型的 <script>'use server' 是有类型的 fetch()

RSC 把客户端/服务端的边界表达在模块系统里,让 import 成为跨越网络的桥梁。组件树跨越两台计算机,数据渐进式传输,colocation 和性能不再是矛盾。

“静态”只是提前运行的服务器。标签是潜在的函数调用。HTML 只是 JSON 的一种表现形式。

这些不是新概念,是旧概念的重新组合。


来源:Dan Abramov overreacted.io 系列(2024.01–2025.12),包括 The Two Reacts、React for Two Computers、What Does “use client” Do、Impossible Components、JSX Over The Wire、Functional HTML、Static as a Server、One Roundtrip Per Navigation、Why Does RSC Integrate with a Bundler、How Imports Work in RSC、Progressive JSON、Introducing RSC Explorer