服务端组件
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 是服务端组件渲染结果的紧凑二进制表示。它包含:
- 服务端组件的渲染结果:组件输出的 HTML 结构
- 客户端组件的占位符:标记客户端组件应该渲染的位置,以及它们的 JavaScript 文件引用
- 传递给客户端组件的 Props:序列化后的数据
渲染流程
首次加载(服务端):
- 服务端组件渲染为 RSC Payload
- RSC Payload 和客户端组件一起生成初始 HTML
- 发送 HTML 和 RSC Payload 到客户端
首次加载(客户端):
- 立即显示 HTML(快速首屏)
- 使用 RSC Payload 协调服务端和客户端组件树
- 加载 JavaScript 并水合客户端组件
后续导航:
- RSC Payload 被预取和缓存
- 客户端组件完全在客户端渲染,不依赖服务端 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. 避免在服务端组件中使用仅客户端的库
检查库的文档,确保它在服务端组件中使用时不会出错。
小结
本章我们学习了:
- 服务端组件的核心概念和优势
- 服务端组件与客户端组件的能力差异
- 默认服务端组件的设计理念
- 客户端边界的概念和最小化策略
- 组件组合模式:children 传递和 Provider 模式
- 防止环境泄露的安全措施
- RSC Payload 的工作原理
练习
- 创建一个服务端组件,直接从数据库获取并渲染用户列表
- 创建一个客户端组件,实现一个交互式计数器
- 实现一个 Modal 组件,使用 children 模式在客户端模态框中渲染服务端内容
- 使用 server-only 保护一个包含敏感 API 密钥的数据获取函数