This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,143 @@
|
||||
---
|
||||
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 可用更重要。
|
||||
Reference in new issue
Block a user