随着 React 18 引入的 Server Components (RSC) 和 Next.js 13+ App Router 的推出,前端的渲染模式和组件组织方式发生了根本性变化。理解服务端组件 (Server Components)客户端组件 (Client Components) 之间的差异、它们的工作原理以及何时使用它们,是掌握现代 Next.js 应用开发的关键。这种分离旨在优化性能、减少客户端 JavaScript 包大小、提升用户体验并简化服务器端数据获取。

核心思想:将组件的渲染工作按需划分到服务器端和客户端,以最大化性能优势、最小化客户端JS负载,并提升开发者体验。服务端组件是默认行为,旨在执行不依赖浏览器API和交互的逻辑;客户端组件则用于处理用户交互和浏览器侧功能。


一、概述:为何需要区分服务端和客户端组件?

在传统的 React 应用中,所有组件(即使是那些只显示静态内容的组件)最终都会在客户端(浏览器)执行其渲染逻辑,并参与“水合”(hydration)过程。这意味着:

  1. 大 JavaScript 包:所有组件的代码都需要打包并发送到客户端,增加了初始加载时间。
  2. 性能开销:即使是静态内容,也需要在客户端执行一遍 React 渲染逻辑,消耗客户端资源。
  3. 数据获取瓶颈:如果数据获取在客户端进行,会增加额外的网络往返时间 (RTT)。如果数据在服务器端获取,需要通过 SSR 模式,且数据通常会作为 props 传递,可能导致 props 数据量大。
  4. SEO 挑战:虽然 SSR 可以解决部分 SEO 问题,但客户端 JS 代码的执行依然是搜索引擎爬虫的额外负担。

React Server Components 和 Next.js App Router 旨在解决这些问题,其核心思想是:

  • 默认运行在服务器端:App Router 中的所有组件默认都是 Server Components。
  • Opt-out 到客户端:只有当组件需要依赖浏览器 API (如 useState, useEffect, onClick, window 对象) 时,才明确将其标记为 Client Component。

这种模式在保持 React 开发模型熟悉性的同时,将渲染工作的计算和数据获取推向服务器端,从而实现了更快的初始加载、更小的客户端 JavaScript 包、更好的性能和更简化的数据获取

二、客户端组件 (Client Components)

2.1 定义

客户端组件是传统的 React 组件,它们在浏览器中执行其渲染逻辑并处理用户交互。它们需要 JavaScript 在客户端运行才能正常工作。在 Next.js App Router 中,你需要通过在文件顶部添加 "use client"; 指令来明确声明一个组件是客户端组件。

2.2 工作原理与特性

  1. 浏览器端执行:组件的代码会被打包并发送到客户端浏览器,并在其中运行。
  2. 交互性:可以包含状态 (useState)、副作用 (useEffect)、事件监听器 (onClick, onChange) 和其他交互逻辑。
  3. 访问浏览器 API:可以安全地访问 window, document, localStorage 等浏览器特有的全局对象和 API。
  4. 参与水合:当服务器首次渲染页面(SSR/SSG)并将 HTML 发送到客户端后,客户端组件会“水合”到这些预渲染的 HTML 上,使其变得可交互。
  5. 贡献客户端包大小:客户端组件的代码会增加最终发送到浏览器端的 JavaScript 包大小。
  6. Context 供应商:React 的 Context.Provider 必须在客户端组件中定义,因为它们管理客户端状态。

2.3 适用场景

  • 用户交互:任何需要响应用户点击、输入事件的组件(如按钮、表单、下拉菜单)。
  • 状态管理:需要管理组件自身状态的组件(如计数器、切换开关)。
  • 浏览器 API 依赖:依赖 useState, useEffect, window, document, localStorage, navigator 等浏览器 API 的组件。
  • 第三方库:大部分第三方 React 库(如动画库、图表库、日期选择器)依赖浏览器 API 或 React Hooks,因此需要作为客户端组件导入和使用。
  • Context 提供者:所有用于提供 Context 的组件 (Context.Provider)。

2.4 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// components/Counter.tsx
"use client"; // 📢 明确声明这是一个客户端组件

import { useState } from 'react';

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

return (
<div className="border p-4 rounded-md shadow-sm">
<h2 className="text-xl font-bold">客户端计数器</h2>
<p>计数值: {count}</p>
<button
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600"
onClick={() => setCount(count + 1)}
>
增加
</button>
<p className="mt-2 text-sm text-gray-500">
此组件包含状态和交互,必须是客户端组件。
</p>
</div>
);
}

三、服务端组件 (Server Components)

3.1 定义

服务端组件是 Next.js App Router 的默认组件类型。它们在服务器上进行渲染,不包含任何客户端 JavaScript,也不会参与客户端的水合过程。它们主要用于获取数据、渲染静态或不经常变化的 UI,并将渲染结果(序列化的 JSX)发送到客户端。

3.2 工作原理与特性

  1. 服务器端执行:组件的代码只在服务器上运行,并生成最终的 HTML 或一个被称为 React Server Components Payload (RSCP) 的中间格式。
  2. 零客户端 JavaScript:它们的代码不会被包含在客户端 JavaScript 包中,因此不会增加客户端的下载和解析负担。
  3. 直接数据获取:可以直接在组件内部使用 async/await 进行数据获取(例如,直接访问数据库、文件系统、内部 API),无需单独的 API 路由。这避免了客户端到服务器的网络往返。
    1
    2
    3
    // 直接访问数据库或文件系统
    import fs from 'fs/promises';
    const data = await fs.readFile('/path/to/data.json', 'utf-8');
  4. 访问服务器资源:可以安全地访问服务器端独有的资源,如文件系统、数据库连接、环境变量 (无需暴露给客户端)。
  5. 无状态、无交互:不能使用 useState, useEffect, onClick 等 React Hook 和事件处理器,因为它们不会被发送到客户端执行。
  6. 默认行为:在 Next.js App Router 中,所有组件都是默认的服务端组件,无需特殊指令。
  7. 传递 Props:可以向子组件传递 props,但这些 props 必须是可序列化的(例如,字符串、数字、对象、数组,不能是函数或 Symbol)。

3.3 适用场景

  • 布局组件:应用的根布局、导航栏、页脚等静态结构。
  • 数据获取:需要在服务器端获取数据并直接渲染的组件(如博客文章列表、产品详情)。
  • 静态内容组件:不含交互逻辑的纯展示性组件。
  • 敏感数据处理:需要访问敏感 API 密钥、数据库凭证等不应暴露给客户端的信息。
  • Markdown 渲染:在服务器上将 Markdown 转换为 HTML。

3.4 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// components/ArticleList.tsx
// 📢 在 App Router 中,组件默认是服务端组件,无需 "use server" 或其他指令
// "use server"; 指令用于标记 Server Action 函数,而不是组件

import Link from 'next/link';

// 模拟从数据库或文件系统获取数据
async function getArticles() {
// 真实场景中,这里可能是数据库查询或文件读取
await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
return [
{ id: '1', title: '理解服务端组件', author: '张三' },
{ id: '2', title: 'Next.js App Router 新特性', author: '李四' },
{ id: '3', title: '现代前端渲染模式', author: '王五' },
];
}

export default async function ArticleList() {
// 直接在服务端组件中await获取数据
const articles = await getArticles();

return (
<div className="border p-4 rounded-md shadow-sm bg-gray-50">
<h2 className="text-xl font-bold mb-4">服务端文章列表</h2>
<ul>
{articles.map((article) => (
<li key={article.id} className="mb-2">
<Link href={`/articles/${article.id}`} className="text-blue-600 hover:underline">
{article.title} - 作者: {article.author}
</Link>
</li>
))}
</ul>
<p className="mt-2 text-sm text-gray-500">
此组件在服务器端获取数据并渲染,不会增加客户端JS包大小。
</p>
</div>
);
}

// pages/articles/[id]/page.tsx (示例:如何使用 Server Component)
/*
export default async function ArticlePage({ params }: { params: { id: string } }) {
// 在 page 组件中直接使用 ArticleList
return (
<div>
<h1>文章详情页</h1>
<ArticleList /> // ArticleList 是一个 Server Component
<p>当前文章 ID: {params.id}</p>
</div>
);
}
*/

四、服务端组件与客户端组件的交互与混合

这是理解 Next.js App Router 的关键:组件树如何协同工作?

在 Next.js 的 App Router 中,组件树是从根布局开始,默认在服务器上进行渲染的。

  1. 服务器端渲染流程

    • Next.js 服务器开始渲染组件树。
    • 当服务器遇到一个服务端组件时,它会执行其渲染逻辑,包括数据获取,并生成该组件的 HTML 内容。
    • 当服务器遇到一个客户端组件时 (即带有 "use client"; 指令的组件),它不会执行该组件的 JavaScript 代码。相反,它将其视为一个“占位符”或“洞 (hole)”,并将其在页面中的位置(以及传递给它的可序列化 props)记录在特殊的 React Server Components Payload (RSCP) 中。客户端组件的文件本身会被标记为客户端 JavaScript bundle 的一部分。
  2. 客户端水合流程

    • 服务器将预渲染的 HTML 和 RSCP 一起发送到客户端。
    • 浏览器接收到 HTML 后,立即显示可见内容(提高 FCP)。
    • 然后,浏览器开始下载客户端 JS bundle。
    • React 在客户端使用 RSCP 来理解服务器渲染的结构,并填充那些占位符对应的客户端组件。
    • 客户端组件的 JavaScript 代码开始执行,并“水合”到服务器生成的 HTML 上,使其变得可交互(TTI)。

4.1 核心模式:将服务端组件作为 children 传递给客户端组件

这是整合 RSC 和 CC 的最常见且最推荐的方式。

  • 原理:在服务器上,服务端父组件在渲染时,会先完全渲染其服务端子组件,然后将这些已渲染的服务端组件内容作为 children prop 传递给客户端组件。客户端组件接收到的 children 已经是纯 HTML/RSCP,而不是一个需要客户端 JavaScript 执行的组件本身。

示例:服务端组件作为客户端组件的 children

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// components/ClientLayoutWrapper.tsx
"use client"; // 这是一个客户端组件

import { useState } from 'react';

export default function ClientLayoutWrapper({ children }: { children: React.ReactNode }) {
const [isActive, setIsActive] = useState(false);

return (
<div className={`p-6 border rounded-lg ${isActive ? 'bg-indigo-100' : 'bg-white'}`}>
<h3 className="text-xl font-semibold mb-3">这是一个客户端组件包装器</h3>
<button
className="bg-indigo-500 text-white px-4 py-2 rounded-md mb-4"
onClick={() => setIsActive(!isActive)}
>
{isActive ? '禁用' : '激活'} 样式
</button>
<div className="border border-dashed p-4 bg-gray-50">
<p className="text-sm italic mb-2">以下内容由服务端组件渲染并作为 children 传递:</p>
{children} {/* 📢 这里的 children 是一个服务端组件 */}
</div>
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// app/page.tsx
// 默认是服务端组件
import ClientLayoutWrapper from '@/components/ClientLayoutWrapper';
import ArticleList from '@/components/ArticleList'; // 此前定义的 ArticleList 是服务端组件
import Counter from '@/components/Counter'; // 此前定义的 Counter 是客户端组件

export default function HomePage() {
return (
<main className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">我的 Next.js 混合渲染应用</h1>

{/* 这是一个客户端组件,但它的所有子孙(包括 ArticleList 和 Counter)
会在服务器端被处理,然后传递给它 */}
<ClientLayoutWrapper>
{/* ArticleList 是服务端组件,它在服务器上完成数据获取和渲染 */}
<ArticleList />

<hr className="my-8" />

{/* Counter 是客户端组件,它将在客户端被水合并提供交互 */}
<Counter />
</ClientLayoutWrapper>

<hr className="my-8" />

{/* 如果没有交互,可以直接放置服务端组件 */}
<h2 className="text-2xl font-bold mb-4">直接放置的服务端组件</h2>
<ArticleList />

</main>
);
}

在这个示例中,ClientLayoutWrapper 是一个客户端组件,但它内部的 ArticleList 是一个服务端组件。当 HomePage (一个服务端组件) 在服务器上渲染时,它会先渲染 ArticleListArticleList 的渲染结果(HTML) 会被作为 children prop 传递给 ClientLayoutWrapper。然后, ClientLayoutWrapper 及其接收到的 children 内容会被发送到客户端。客户端的 ClientLayoutWrapper 将显示这些 children,并执行其自身的交互逻辑。

4.2 限制:服务端组件不能将函数或不可序列化数据作为 Props 传递给客户端组件

这是因为服务端组件的 props 需要跨网络从服务器发送到客户端。因此,函数、Symbol、自定义类实例等非JSON可序列化的数据类型不能直接作为 props 从服务端组件传递给客户端组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bad example: app/page.tsx (Server Component)
// DO NOT DO THIS
"use client";
import React from 'react';

// BAD practice: Server Component passing a function to Client Component
function MyClientComponent({ onClick }: { onClick: () => void }) {
// ...
return <button onClick={onClick}>Click Me</button>;
}
export default function Page() {
const handleClick = () => console.log("Clicked from Server Component logic");
return <MyClientComponent onClick={handleClick} />; // ❌ 这将失败,函数无法序列化
}

解决方案

  • 如果客户端组件需要一个函数来执行某些操作,这个函数应该在客户端组件内部定义。
  • 如果该操作需要触及服务器功能,可以使用 Server Actions (在 Next.js 14+ / React 19+)。Server Actions 允许你在客户端调用一个服务器端函数,Next.js 会自动处理序列化和网络请求。

五、关键差异对比

特性 服务端组件 (Server Components) 客户端组件 (Client Components)
运行环境 仅在服务器上执行 在浏览器中执行 (可能先在服务器上预渲染并水合)
指令 App Router 默认行为,无需指令 必须在文件顶部声明 "use client";
JavaScript 包 不会打包到客户端 JS Bundle,零客户端 JS 会打包到客户端 JS Bundle,增加客户端负载
交互性 无状态 (useState), 无副作用 (useEffect), 无事件监听 (onClick) 支持状态 (useState), 副作用 (useEffect), 事件监听 (onClick)
数据获取 可直接 async/await 访问数据库、文件系统、内部 API 只能通过网络请求(fetch, axios)访问外部 API
访问资源 可访问 服务器端文件系统、环境变量、数据库等 可访问 浏览器 API (window, document, localStorage)
水合 (Hydration) 不参与水合 参与水合
传递 Props 可以传递可序列化的 Props 给子组件 (包括客户端组件) 可以传递任何 Props (包括函数) 给子组件
Context 不能定义 Context.Provider 必须定义 Context.Provider
安全性 敏感信息(API Keys, DB Credentials)安全保存在服务器 敏感信息若通过变量使用,可能暴露给客户端
性能优势 减少客户端 JS、更快数据获取、更快 FCP 专注于交互,在水合后提供完整用户体验

六、何时使用服务端组件,何时使用客户端组件?

场景 推荐组件类型 理由
静态内容展示 服务端组件 (默认) 无需交互,减少客户端 JS。
获取数据并渲染 服务端组件 (默认) 消除客户端到服务器的 RTT,直接在服务器端获取数据。
敏感数据处理 服务端组件 确保 API 密钥、数据库凭证等信息不暴露给客户端。
布局组件 服务端组件 大部分布局是静态的,利用服务器渲染减少客户端负载(可以在内部嵌套客户端组件进行交互)。
用户交互逻辑 客户端组件 需要 useState, useEffect, onClick 等 Hook 和事件处理器。
浏览器 API 依赖 客户端组件 需要访问 window, document, localStorage 等浏览器特有对象。
第三方库/组件 客户端组件 大多数依赖浏览器环境或 Hooks 的第三方库都需要在客户端组件中引入。
Form 处理 客户端组件 绑定表单提交事件、处理表单验证、反馈用户输入。当然,Next.js 13+ 推出了 Server Actions 作为后端处理表单提交的新方式,可与客户端组件结合。
Context Providers 客户端组件 必须在客户端创建和管理 Context.Provider

最佳实践指导原则:

  1. “Use Server First”: 尽可能使用服务端组件。只有当需要交互或依赖浏览器 API 时,才显式地引入 "use client";。这被称为“在需要时逃逸到客户端 (Escape Hatch to the Client)”。
  2. 将客户端组件放在组件树的叶子节点:理想情况下,你的应用结构应该是大的服务端组件包裹着小的、特定的客户端组件。尽量避免将整个大型父组件标记为客户端组件,除非它包含大量页面级别的交互。
  3. 使用 children prop 模式:如果你需要在客户端组件中渲染由服务端组件生成的内容,将服务端组件作为 children prop 传递给客户端组件。这样,children 的内容会在服务器上渲染完成,然后才作为 HTML/RSCP 传递给客户端组件进行集成。

七、总结

Next.js App Router 引入的服务端组件和客户端组件范式是前端架构的一次重大演进,它为开发者提供了前所未有的灵活性,以针对性地优化 Web 应用的性能、用户体验和开发效率。

通过:

  • 将静态内容和数据获取卸载到服务器端(服务端组件),实现了更小的 JavaScript 包、更快的首次内容绘制 (FCP) 和更简化的数据流
  • 将交互性和浏览器特有功能保留在客户端(客户端组件),确保了丰富且响应迅速的用户体验

掌握这两种组件的差异和设计模式,尤其是在两者之间高效地进行组合和通信(特别是通过 children prop),是成功构建高性能和可维护的 Next.js App Router 应用的关键。这要求开发者转变思维,从传统的“一切都在客户端渲染”模式,转变为“优先在服务器渲染”的混合渲染模式。