--- 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 可用更重要。