跳到主要内容

服务端组件

React Server Components(RSC)是 Next.js App Router 的核心特性之一。理解服务端组件的工作原理对于构建高性能的 Next.js 应用至关重要。本章将深入介绍服务端组件的概念、原理和最佳实践。

核心概念

什么是服务端组件?

服务端组件是一种特殊的 React 组件,它在服务器上渲染,其代码不会被打包发送到客户端。这与传统的客户端组件有本质区别:

  • 渲染位置:服务端组件在服务器上执行,客户端组件在浏览器中执行
  • 代码传输:服务端组件代码留在服务器上,客户端组件代码会打包到 JavaScript bundle
  • 能力差异:两种组件可以访问的资源和 API 不同

为什么需要服务端组件?

服务端组件解决了传统 React 应用的几个核心问题:

减少 JavaScript 包体积

传统的 React 应用需要将所有组件代码发送到客户端。即使某个组件只是在服务器上获取数据并渲染静态内容,它的代码也会被打包。服务端组件的代码永远不会发送到客户端,大幅减少了需要下载的 JavaScript。

直接访问后端资源

服务端组件可以直接访问数据库、文件系统、内部 API 等,无需通过公开的 HTTP 接口。这简化了架构,减少了中间层。

更好的首屏性能

服务端组件在服务器上完成渲染,客户端收到的是可以直接显示的 HTML。这意味着首屏内容更快可见,无需等待 JavaScript 加载和执行。

安全性提升

敏感逻辑(如 API 密钥、数据库查询)可以安全地留在服务器上,不会被暴露到客户端代码中。

服务端组件 vs 客户端组件

两种组件的能力对比:

能力服务端组件客户端组件
访问数据库
访问文件系统
使用 API 密钥
使用 useState
使用 useEffect
事件处理(onClick 等)
使用浏览器 API
使用自定义 Hooks
使用 Context

如何选择?

使用服务端组件的场景

  • 需要从数据库或 API 获取数据
  • 需要访问后端资源(文件系统、内部服务等)
  • 需要保护敏感信息(API 密钥、访问令牌)
  • 需要减少客户端 JavaScript 体积
  • 渲染大量静态内容

使用客户端组件的场景

  • 需要用户交互(点击、输入、拖拽等)
  • 需要使用 React 状态(useState、useReducer)
  • 需要使用生命周期逻辑(useEffect)
  • 需要使用浏览器 API(localStorage、window、geolocation 等)
  • 需要使用依赖状态的第三方库

默认为服务端组件

在 App Router 中,所有组件默认都是服务端组件。这是有意的设计决策,鼓励开发者优先使用服务端组件:

// 默认就是服务端组件
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

// 可以直接访问数据库
const product = await db.product.findUnique({
where: { id },
});

return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}

服务端组件的能力示例

直接访问数据库

import { db } from "@/lib/db";

export default async function UsersPage() {
// 直接查询数据库,无需 API 层
const users = await db.user.findMany({
select: { id: true, name: true, email: true },
});

return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}

读取文件系统

import { readFile } from "fs/promises";
import path from "path";

export default async function ReadmePage() {
const filePath = path.join(process.cwd(), "README.md");
const content = await readFile(filePath, "utf-8");

return (
<article className="prose">
<pre>{content}</pre>
</article>
);
}

使用服务端专用 SDK

import { stripe } from "@/lib/stripe";

export default async function PricingPage() {
// Stripe SDK 使用服务端密钥
const products = await stripe.products.list({
active: true,
});

return (
<div className="grid gap-4">
{products.data.map((product) => (
<div key={product.id} className="card">
<h3>{product.name}</h3>
<p>{product.description}</p>
</div>
))}
</div>
);
}

客户端组件

需要使用客户端特性时,添加 "use client" 指令:

"use client";

import { useState } from "react";

export default function Counter() {
const [count, setCount] = useState(0);

return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount(count - 1)}
className="btn"
>
-
</button>
<span className="text-2xl">{count}</span>
<button
onClick={() => setCount(count + 1)}
className="btn"
>
+
</button>
</div>
);
}

"use client" 指令的位置

"use client" 必须放在文件的顶部,在所有导入之前:

"use client";  // 必须是第一行

import { useState } from "react"; // 导入在指令之后
import Counter from "./counter";

// ...

客户端边界

理解客户端边界的概念对于正确使用服务端组件至关重要。

什么是客户端边界?

"use client" 指令创建了一个边界:

  • 边界所在的文件及其所有导入都被视为客户端代码
  • 边界内的所有组件默认都是客户端组件
"use client";

// 这些都是客户端组件
import ClientA from "./client-a"; // 客户端组件
import ClientB from "./client-b"; // 客户端组件

export default function ParentClient() {
return (
<div>
<ClientA />
<ClientB />
</div>
);
}

最小化客户端边界

为了最大化服务端组件的优势,应该尽量缩小客户端边界的范围:

// ❌ 不推荐:整个组件标记为客户端组件
"use client";

export default function InteractiveList({ items }: { items: Item[] }) {
const [selected, setSelected] = useState<string | null>(null);

return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelected(item.id)}
className={selected === item.id ? "bg-blue-100" : ""}
>
{item.name}
</li>
))}
</ul>
);
}
// ✅ 推荐:将交互部分提取为独立组件
// item-list.tsx(服务端组件)
import Item from "./item";

export default function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<Item key={item.id} item={item} />
))}
</ul>
);
}
// item.tsx(客户端组件)
"use client";

import { useState } from "react";

export default function Item({ item }: { item: Item }) {
const [selected, setSelected] = useState(false);

return (
<li
onClick={() => setSelected(!selected)}
className={selected ? "bg-blue-100" : ""}
>
{item.name}
</li>
);
}

组件组合模式

服务端组件和客户端组件可以通过组合模式协同工作。

服务端组件导入客户端组件

这是最常见的模式:

// page.tsx(服务端组件)
import LikeButton from "./like-button";
import { getPost } from "@/lib/posts";

export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* 客户端组件处理交互 */}
<LikeButton postId={id} initialLikes={post.likes} />
</article>
);
}
// like-button.tsx(客户端组件)
"use client";

import { useState } from "react";

export default function LikeButton({
postId,
initialLikes
}: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLoading, setIsLoading] = useState(false);

const handleLike = async () => {
setIsLoading(true);
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
setLikes((prev) => prev + 1);
setIsLoading(false);
};

return (
<button
onClick={handleLike}
disabled={isLoading}
className="btn"
>
❤️ {likes}
</button>
);
}

通过 Props 传递数据

服务端组件可以向客户端组件传递数据,但数据必须是可序列化的:

// ✅ 正确:传递可序列化数据
<LikeButton
postId="123"
initialLikes={42}
createdAt={new Date().toISOString()} // 日期转为字符串
/>

// ❌ 错误:传递函数
<LikeButton onSelect={(id) => console.log(id)} />

// ❌ 错误:传递类实例
<LikeButton user={new User("John")} />

// ❌ 错误:传递 DOM 元素
<LikeButton element={document.body} />

通过 children 传递服务端组件

一个重要的模式:可以将服务端组件作为 children 传递给客户端组件:

// modal.tsx(客户端组件)
"use client";

import { useState } from "react";

export default function Modal({
children,
trigger,
}: {
children: React.ReactNode;
trigger: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);

return (
<>
<div onClick={() => setIsOpen(true)}>{trigger}</div>
{isOpen && (
<div className="modal-overlay" onClick={() => setIsOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
)}
</>
);
}
// page.tsx(服务端组件)
import Modal from "./modal";
import CartContent from "./cart-content";

export default function Page() {
return (
<div>
<h1>商品列表</h1>

{/* CartContent 是服务端组件,但可以在客户端 Modal 中渲染 */}
<Modal trigger={<button>查看购物车</button>}>
<CartContent />
</Modal>
</div>
);
}

这个模式的关键点:

  • children 在服务端预先渲染
  • 客户端组件只负责控制显示/隐藏逻辑
  • 服务端组件可以正常获取数据

Context 和 Provider

React Context 不支持服务端组件,但可以在客户端组件中创建 Provider,然后包装服务端组件。

创建 Provider

// providers/theme-provider.tsx
"use client";

import { createContext, useContext, useState } from "react";

type Theme = "light" | "dark";

const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({
theme: "light",
toggleTheme: () => {},
});

export function useTheme() {
return useContext(ThemeContext);
}

export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<Theme>("light");

const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};

return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}

在布局中使用

// app/layout.tsx
import ThemeProvider from "./providers/theme-provider";

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}

在客户端组件中使用 Context

// components/theme-toggle.tsx
"use client";

import { useTheme } from "./providers/theme-provider";

export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();

return (
<button onClick={toggleTheme}>
当前主题: {theme}
</button>
);
}

防止环境泄露

问题场景

JavaScript 模块可以在服务端和客户端之间共享,这可能导致意外的代码泄露:

// lib/data.ts - 这个文件可能被错误地在客户端使用
export async function getData() {
const res = await fetch("https://api.example.com/data", {
headers: {
authorization: process.env.API_KEY, // 敏感信息!
},
});
return res.json();
}

如果不小心在客户端组件中导入这个函数,API_KEY 会变成空字符串(Next.js 会移除未公开的环境变量),但函数调用仍会失败,且可能暴露后端 API 端点。

使用 server-only 包

安装 server-only 包来防止服务端代码被客户端意外导入:

npm install server-only
// lib/data.ts
import "server-only"; // 如果在客户端导入,会抛出错误

export async function getData() {
const res = await fetch("https://api.example.com/data", {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}

现在,如果客户端组件尝试导入这个文件,构建时会报错。

使用 client-only 包

相反,client-only 包可以标记只能运行在客户端的代码:

import "client-only";

export function useLocalStorage(key: string) {
// 这里的代码只能在客户端运行
}

第三方组件

当使用的第三方组件依赖客户端特性但没有 "use client" 指令时,需要手动包装:

// components/carousel.tsx
"use client";

import { Carousel } from "acme-carousel";

// 重新导出,现在可以在服务端组件中使用了
export default Carousel;
// app/page.tsx
import Carousel from "@/components/carousel";

export default function Page() {
return (
<div>
<h1>图片展示</h1>
<Carousel images={[...]} />
</div>
);
}

RSC Payload 解析

了解 React Server Component Payload(RSC Payload)有助于深入理解服务端组件的工作原理。

RSC Payload 是什么?

RSC Payload 是服务端组件渲染结果的紧凑二进制表示。它包含:

  1. 服务端组件的渲染结果:组件输出的 HTML 结构
  2. 客户端组件的占位符:标记客户端组件应该渲染的位置,以及它们的 JavaScript 文件引用
  3. 传递给客户端组件的 Props:序列化后的数据

渲染流程

首次加载(服务端)

  1. 服务端组件渲染为 RSC Payload
  2. RSC Payload 和客户端组件一起生成初始 HTML
  3. 发送 HTML 和 RSC Payload 到客户端

首次加载(客户端)

  1. 立即显示 HTML(快速首屏)
  2. 使用 RSC Payload 协调服务端和客户端组件树
  3. 加载 JavaScript 并水合客户端组件

后续导航

  1. RSC Payload 被预取和缓存
  2. 客户端组件完全在客户端渲染,不依赖服务端 HTML

最佳实践

1. 默认使用服务端组件

除非需要客户端特性,否则使用服务端组件。

2. 将客户端组件放在叶子节点

交互逻辑应该尽可能靠近组件树的叶子节点:

// ✅ 好:布局是服务端组件,搜索框是客户端组件
// layout.tsx(服务端组件)
export default function Layout({ children }) {
return (
<div>
<nav>
<SearchBox /> {/* 客户端组件 */}
</nav>
{children}
</div>
);
}

3. 使用组合模式代替 Context

如果只需要在客户端组件间共享状态,使用组合模式:

// 服务端组件获取数据,通过 props 传递
export default async function Page() {
const user = await getCurrentUser();
return <UserProfile user={user} />;
}

4. 避免在服务端组件中使用仅客户端的库

检查库的文档,确保它在服务端组件中使用时不会出错。

小结

本章我们学习了:

  1. 服务端组件的核心概念和优势
  2. 服务端组件与客户端组件的能力差异
  3. 默认服务端组件的设计理念
  4. 客户端边界的概念和最小化策略
  5. 组件组合模式:children 传递和 Provider 模式
  6. 防止环境泄露的安全措施
  7. RSC Payload 的工作原理

练习

  1. 创建一个服务端组件,直接从数据库获取并渲染用户列表
  2. 创建一个客户端组件,实现一个交互式计数器
  3. 实现一个 Modal 组件,使用 children 模式在客户端模态框中渲染服务端内容
  4. 使用 server-only 保护一个包含敏感 API 密钥的数据获取函数