---
title: "React Server Components 的心智模型"
date: "2025-01-12"
summary: "服务端组件不是「服务端渲染的组件」,它是一次关于数据边界的重新划分。理解这条边界,比记住 API 更重要。"
tags: ["React", "Next.js", "性能"]
draft: false
---
第一次接触 Server Components,最容易带着旧框架的直觉去理解它:**「这不就是 SSR 吗?」**
不是。SSR 是把同一个组件在服务端渲染成 HTML,然后在客户端**再跑一遍**完成 hydration。Server Components 是另一回事——它**只在服务端运行**,代码永远不会发送到浏览器。
## 两种组件,一条边界
在 App Router 里,组件默认是服务端组件。只有当文件顶部写了 `"use client"`,它才会成为客户端组件。
这条边界的关键含义是:
| | Server Component | Client Component |
| --- | --- | --- |
| 运行位置 | 仅服务端 | 服务端(首屏)+ 浏览器 |
| 能否用 `useState` | 否 | 是 |
| 能否直接读数据库 | 是 | 否 |
| 代码是否进 bundle | 否 | 是 |
所以选择不是「哪个更好」,而是「这个组件需要什么」:
- 需要读取数据、访问文件系统、使用密钥 → **Server**
- 需要 `useState`、事件处理、浏览器 API → **Client**
> 默认留在服务端,只在真正需要交互时才越过边界。
## 边界是单向的
数据只能从服务端流向客户端,不能反向。服务端组件可以渲染客户端组件,但客户端组件不能 `import` 服务端组件。
```tsx
// ✅ 服务端组件渲染客户端组件,可以把数据作为 props 传下去
export default async function Page() {
const posts = await getPosts();
return (
{/* 客户端组件 */}
);
}
```
反过来不行,因为客户端 bundle 里没有服务端组件的代码。
需要注意的是,**props 必须可序列化**。函数、类实例、Date 之外的复杂对象都传不过去。
```tsx
// ❌ 函数无法跨过边界
v.toFixed(2)} />
// ✅ 传数据,让客户端自己格式化
```
## 把服务端代码物理隔离
一个常见的 bug 是:某个工具函数本来只在服务端用,结果被链式 `import` 进了客户端组件,**连同数据库凭据一起**打进了浏览器产物。
`server-only` 包能在构建阶段拦住这类事故:
```ts
import "server-only";
export function getPosts() {
// 这里可以安全地读取内容目录或查询数据库
}
```
只要有任何客户端组件(直接或间接)导入这个模块,构建就会失败。这比靠代码评审去发现可靠得多。
## 交互要沉到叶子节点
既然客户端组件会进 bundle,就应该让**边界尽可能靠近叶子**。
```tsx
// ❌ 整个页面变成客户端组件,所有文章数据都要走 props 传
"use client";
export default function ArticlePage({ post }) {
const [liked, setLiked] = useState(false);
return (
setLiked(!liked)} />
);
}
```
问题在于:MDX 渲染器、语法高亮、整个文章正文——全部被拖进了客户端 bundle,只为了一个点赞按钮。
正确的拆法是让正文留在服务端:
```tsx
// ✅ 页面是服务端组件,只有按钮是客户端组件
export default async function ArticlePage({ params }) {
const post = await getPost(params.slug);
return (
);
}
```
`ArticlePage` 和 `MDXContent` 都不进 bundle,`LikeButton` 才是唯一需要下载的交互代码。**交互的粒度决定了 bundle 的大小。**
## 常见误区
**误区一:客户端组件只在浏览器运行。**
客户端组件同样会在服务端做一次预渲染,用来产出首屏 HTML。所以它里面不能直接访问 `window`——除非放进 `useEffect`。
**误区二:服务端组件能保留状态。**
服务端组件每次请求都会重新执行,没有状态、没有生命周期,也不能用 `useEffect`。
**误区三:加了 `"use client"` 就「更安全」。**
恰恰相反——它意味着代码会发送给用户。加上这个指令应该是**有意识的选择**,而不是遇到报错就顺手加的补丁。
## 一个判断口诀
拿不准组件该放哪边时,问三个问题:
1. 需要 `useState` / `useEffect` 吗?→ 需要就是客户端
2. 有事件处理(`onClick` 等)吗?→ 有就是客户端
3. 涉及密钥、数据库、文件系统吗?→ 涉及就必须是服务端
三个都不沾,就**留在服务端**。这通常是默认且正确的选择。
## 小结
Server Components 真正改变的不是渲染性能,而是**数据的归属**。它让「这份数据该在哪里被读取」重新成为一个需要思考的设计问题,而不是把所有逻辑都堆到客户端再想办法优化。
想清楚边界画在哪里,比记住哪个 API 可用更重要。