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 大小并保护敏感数据安全地留在服务器端。

nextjsreactserver-componentclient-componentapp-router