Next.js 中的服务器组件和客户端组件
1. 概述
Next.js App Router 默认在服务器上渲染组件,与之前 Pages Router “一切都在客户端” 的模型完全不同。这篇文章帮助你理解服务器组件和客户端组件的区别,何时必须使用 "use client",以及如何在同一个渲染树中组合这两种组件而不出现错误。读完后,你将能够自己决定一个组件应该放在服务器还是客户端,并知道如何正确地跨越这个边界传递数据。
2. 什么是服务器组件
服务器组件是 App Router(app/ 文件夹)中的默认组件类型。它完全在服务器上运行,不会将自身的 JavaScript 发送到浏览器。
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: number; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
需要注意的几点:
ProductsPage是一个async function— 服务器组件可以直接await,不需要useEffect或useState来获取数据。fetch调用在服务器上运行,API 密钥或敏感逻辑永远不会暴露给浏览器。- 文件开头没有
"use client"这一行,所以 Next.js 自动认为这是一个服务器组件。
2.1. 为什么应该优先使用服务器组件
服务器组件有助于减少发送到客户端的 JavaScript bundle 大小,因为组件本身(以及它的导入,如果不被客户端化的话)不会被打包进浏览器的 bundle 中。它还可以直接访问数据库、文件系统或机密环境变量,而不需要通过 API 路由暴露它们。
请求 → 服务器渲染(获取、数据库查询)→ HTML + RSC payload → 浏览器
浏览器只接收已渲染的 HTML 和一个描述组件树的 payload(RSC payload),不接收服务器组件的 JavaScript 代码。
3. 什么是客户端组件
客户端组件是需要在浏览器中运行的组件 — 用来使用状态、effects、事件处理程序或浏览器 Web API,如 localStorage、window。你通过在文件开头放置 "use client" 指令来声明它。
// app/components/LikeButton.tsx
"use client";
import { useState } from "react";
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️ 已点赞" : "🤍 点赞"}
</button>
);
}
解释:
"use client"必须是文件的第一行(在任何导入之前),它标记了一条边界 — 从这个文件导入的所有内容也会被打包给客户端。useState只能在客户端组件中工作,因为它需要在浏览器中运行的 JavaScript 来管理重新渲染。onClick是事件处理程序 — 这是你肯定需要客户端组件的一个迹象。
注意:
"use client"并不意味着组件只在客户端运行。Next.js 仍然会首次在服务器上渲染它(SSR)以获得快速的 HTML,然后在浏览器中“hydrate”使组件变成交互式的。
4. 何时必须使用客户端组件
在某些情况下,你必须添加 "use client",否则会遇到构建或运行时错误:
错误: useState 不能在服务器组件中使用。
这些情况包括:
- 使用 React hook:
useState、useEffect、useReducer、useContext等 - 附加事件处理程序:
onClick、onChange、onSubmit等 - 使用仅在浏览器中存在的 Web API:
window、document、localStorage - 使用在内部有上述内容的自定义 hook
- 使用仅在客户端运行的库(例如某些基于
window的图表库)
相反,如果组件只是接收 props 然后渲染纯 JSX,没有 hook 也没有事件 — 即使它嵌套在其他客户端组件中,也应该保持为服务器组件。
5. 组合服务器和客户端组件
这个模型最好的一点是你可以通过 children(组合)将服务器组件嵌套到客户端组件中,而不是直接导入。
// app/components/Modal.tsx
"use client";
import { useState } from "react";
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开详情</button>
{open && <div className="modal">{children}</div>}
</>
);
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";
async function getOrder(id: string) {
const res = await fetch(`https://api.shop.local/orders/${id}`);
return res.json();
}
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await getOrder(params.id);
return (
<Modal>
{/* OrderDetail 仍然是服务器组件,只是被作为 children 传入 */}
<p>订单号:{order.id}</p>
<p>总金额:{order.total.toLocaleString("zh-CN")} 元</p>
</Modal>
);
}
机制解释:
Modal是管理打开/关闭状态的客户端组件。- 内部的内容(
<p>订单号...)在OrderPage— 一个服务器组件 — 中渲染,然后作为children传入Modal。 - Next.js 先在服务器上渲染这部分
children,然后在 hydrate 时将结果“插入”到客户端组件的{children}位置。这样,OrderService.getOrder不需要被客户端化。
注意: 你不能在客户端组件中直接导入一个
async服务器组件然后使用它(例如在有"use client"的文件中import OrderDetail from "./OrderDetail")。Next.js 会将OrderDetail视为被拉入客户端 bundle,并报错说async function component在客户端上是无效的。通过children或props进行组合是正确的方式。
6. 从服务器组件向客户端组件传递数据
从服务器组件向客户端组件传递的 props 必须是可序列化的 — 也就是说,可以转换成 JSON。你不能传递函数、类实例或复杂的 Date 对象。
// app/components/PriceTag.tsx
"use client";
export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
return <span>{amount.toLocaleString("zh-CN")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<PriceTag amount={product.price} currency="CNY" />
</div>
);
}
这里 amount(数字)和 currency(字符串)都是原始数据类型,可以序列化。如果你尝试从服务器组件向 PriceTag 传递一个回调函数,Next.js 会在构建/运行时报错:
错误: 函数不能直接传递给客户端组件
除非你明确地用"use server"标记它。
如果想从客户端调用服务器端的逻辑(例如提交表单),使用 Server Action — 用 "use server" 标记 — 而不是传递普通函数。
7. Server Action — 反向通信的桥梁
当客户端组件需要回调服务器(写入数据、调用数据库)时,使用 Server Action 而不是创建单独的 API 路由。
// app/actions/order-actions.ts
"use server";
export async function cancelOrder(orderId: string) {
await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";
import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";
export default function CancelButton({ orderId }: { orderId: string }) {
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
await cancelOrder(orderId);
setPending(false);
}
return (
<button onClick={handleClick} disabled={pending}>
{pending ? "正在取消..." : "取消订单"}
</button>
);
}
cancelOrder被标记为"use server",Next.js 自动生成一个隐藏的端点供客户端组件调用服务器,你不需要编写路由处理程序。CancelButton导入并像调用普通函数一样调用它 — 代码看起来仍然很自然,尽管实际上是一个网络调用。
8. 为分离的逻辑编写测试
由于服务器组件通常包含数据获取/转换逻辑,将纯逻辑部分(不包含 JSX)分离出来会更容易测试,比测试整个组件渲染要简单。
// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
if (amount < 0) {
throw new Error("金额不能是负数");
}
return `${amount.toLocaleString("zh-CN")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";
describe("formatPrice", () => {
it("正确格式化有效的金额", () => {
expect(formatPrice(150000, "CNY")).toBe("150,000 CNY");
});
it("当金额为负时抛出错误", () => {
expect(() => formatPrice(-1, "CNY")).toThrow("金额不能是负数");
});
});
如果项目为客户端组件部分使用 Jest + Testing Library(例如 LikeButton),你可以像测试普通 React 组件一样进行测试 — Jest 不需要知道 "use client" 的任何信息,因为这个指令对 Next.js 的 bundler 有意义,但不影响测试运行时。
// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";
describe("LikeButton", () => {
it("点击时改变状态", () => {
render(<LikeButton />);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("点赞");
fireEvent.click(button);
expect(button).toHaveTextContent("已点赞");
});
});
9. 常见错误
迁移到 App Router 时容易遇到的几个错误:
- 使用 hook 时忘记
"use client":错误为useState can not be used in Server Components— 在包含 hook 的文件开头添加指令,而不是调用它的文件。 - 在组件树中设置
"use client"的位置过高:如果你将整个layout.tsx标记为客户端,其中的所有内容(包括服务器组件子组件)如果直接导入,也会被拉入客户端 bundle,导致 bundle 无谓地膨胀。只应该客户端化最小的必需组件(例如按钮、表单输入),而不是整个页面。 - 从服务器向客户端组件传递无法序列化的函数或对象 — 使用 Server Action(
"use server")而不是传递回调。 - 在有
"use client"的文件中导入服务器组件,然后期望它仍然在服务器上运行 — 不行,必须通过children/props进行组合。 - 在客户端组件中用密钥调用
fetch— API 密钥会暴露在发送到浏览器的 JS bundle 中。任何需要密钥的操作必须始终在服务器组件或 Server Action 中。
注意: 不是说“把所有东西都改成客户端组件以保持代码不变”就是安全的。每个不必要的客户端组件都会增加 bundle 大小、降低交互时间 (TTI),并失去服务器渲染的好处。
10. 总结
服务器组件是 App Router 中的默认选项,适合数据获取、访问敏感资源和减少 JS bundle。客户端组件仅在需要状态、effect 或事件处理程序时才需使用。这两种类型通过组合(children、可序列化的 props)和 Server Action 结合,后者是客户端组件回调服务器逻辑的桥梁。掌握这条边界线有助于优化 bundle 大小并保护敏感数据安全地留在服务器端。