Files
app-261004/content/posts/react-server-components.mdx
2026-10-08 11:09:37 +08:00

144 lines
5.1 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 (
<main>
<PostList posts={posts} />
<ScrollProgress /> {/* 客户端组件 */}
</main>
);
}
```
反过来不行,因为客户端 bundle 里没有服务端组件的代码。
需要注意的是,**props 必须可序列化**。函数、类实例、Date 之外的复杂对象都传不过去。
```tsx
// ❌ 函数无法跨过边界
<ClientChart format={(v) => v.toFixed(2)} />
// ✅ 传数据,让客户端自己格式化
<ClientChart values={values} precision={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 (
<article>
<MDXContent source={post.content} />
<LikeButton liked={liked} onClick={() => setLiked(!liked)} />
</article>
);
}
```
问题在于:MDX 渲染器、语法高亮、整个文章正文——全部被拖进了客户端 bundle,只为了一个点赞按钮。
正确的拆法是让正文留在服务端:
```tsx
// ✅ 页面是服务端组件,只有按钮是客户端组件
export default async function ArticlePage({ params }) {
const post = await getPost(params.slug);
return (
<article>
<MDXContent source={post.content} />
<LikeButton />
</article>
);
}
```
`ArticlePage` 和 `MDXContent` 都不进 bundle,`LikeButton` 才是唯一需要下载的交互代码。**交互的粒度决定了 bundle 的大小。**
## 常见误区
**误区一:客户端组件只在浏览器运行。**
客户端组件同样会在服务端做一次预渲染,用来产出首屏 HTML。所以它里面不能直接访问 `window`——除非放进 `useEffect`。
**误区二:服务端组件能保留状态。**
服务端组件每次请求都会重新执行,没有状态、没有生命周期,也不能用 `useEffect`。
**误区三:加了 `"use client"` 就「更安全」。**
恰恰相反——它意味着代码会发送给用户。加上这个指令应该是**有意识的选择**,而不是遇到报错就顺手加的补丁。
## 一个判断口诀
拿不准组件该放哪边时,问三个问题:
1. 需要 `useState` / `useEffect` 吗?→ 需要就是客户端
2. 有事件处理(`onClick` 等)吗?→ 有就是客户端
3. 涉及密钥、数据库、文件系统吗?→ 涉及就必须是服务端
三个都不沾,就**留在服务端**。这通常是默认且正确的选择。
## 小结
Server Components 真正改变的不是渲染性能,而是**数据的归属**。它让「这份数据该在哪里被读取」重新成为一个需要思考的设计问题,而不是把所有逻辑都堆到客户端再想办法优化。
想清楚边界画在哪里,比记住哪个 API 可用更重要。