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 中的三个职责:
- 构建时:找到
'use client'文件,创建 chunk 入口 - 服务端:教 React 如何序列化模块引用
- 客户端:教 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