Skip to content
工程与部署
Next.js 渲染策略:服务端组件与客户端组件
Next.js 渲染策略:服务端组件与客户端组件 的概念、用法、示例和注意点
2024/01/157 分钟工程与部署
概述
在 Next.js App Router 中,React 组件被划分为服务端组件与客户端组件,两者的运行时环境、能力边界和打包行为截然不同。理解 'use client' 指令如何以模块依赖树为边界切分服务端与客户端代码,以及两类组件如何通过组合规则协同工作,是构建混合渲染页面的起点。本篇不涉及数据获取策略和缓存细节,这些内容留给下一章。
基本概念
'use client' 划分模块边界
在 App Router 中,所有组件默认都是服务端组件。需要声明客户端组件时,在文件顶部写入 'use client' 指令。这条指令以模块依赖树定义分界线,与渲染树或组件树无关。
声明 'use client' 后,行为如下:
- 该文件成为从服务端到客户端的入口点。
- 被它导入的所有子模块无需重复声明
'use client',它们自动成为客户端模块。 - 不仅限于渲染到 DOM 的组件,入口依赖链上的一切代码——包括工具函数和常量——都会被打包进客户端 bundle。
反过来,没有 'use client' 且未被任何客户端入口导入的模块,就是服务端模块。分界线由 import 链决定,不由组件功能决定。
一个常见的误区是只给“包含交互的组件”加 'use client'。假设编写了一个纯工具模块 formatters.ts,它被某个客户端组件导入,这个工具模块也会随客户端组件一起进入浏览器,无论它本身多么希望在服务端运行。分界看的是依赖关系,不是组件的职责分类。
工作原理
服务端组件
服务端组件在 Next.js 的服务端进程中渲染。React 不会将它们源码发送到浏览器,也不会为其建立客户端 fiber 树。渲染输出是一种称作 RSC Payload(React Server Component Payload)的紧凑二进制格式,包含:
- 服务端组件的渲染结果(可序列化的元素树表示);
- 客户端组件在渲染树中的位置占位符,附带对应 JavaScript bundle 的引用;
- 从服务端组件传递给客户端组件的序列化 props。
RSC Payload 是 Next.js 内部使用的中间表示,不会直接出现在浏览器开发者工具中。最终发送给浏览器的是由这个 payload 组合出的 HTML 和客户端 JavaScript。
服务端组件的硬约束:
- 不能使用
useState、useEffect、useReducer等与交互状态和副作用相关的 Hook; - 不能访问
window、document、localStorage等浏览器 API; - 不能挂载
onClick、onChange等 DOM 事件处理器。
优势在于:
- 可以直接在组件体内使用
async/await获取数据; - 所依赖的大体积库(如 Markdown 解析器、ORM)不会进入客户端 bundle。
客户端组件
客户端组件的生命周期分为两个阶段。
第一阶段在服务端:Next.js 使用 React 的服务端渲染能力将客户端组件预渲染成 HTML 字符串。组件确实在服务端执行一次,能够接收到服务端传入的 props,但此时 useState、useEffect 等 Hook 的交互逻辑不会实际运行。
第二阶段在浏览器:HTML 到达客户端后,React 执行水合(hydration)——将服务端生成的 DOM 节点与客户端创建的 fiber 树关联,绑定事件处理器,激活交互状态。useEffect 的回调在水合完成后触发。
因此客户端组件拥有完整的交互能力,可以访问 useState、useEffect 以及浏览器 API。典型写法如下:
tsx
'use client';
import { useState, useEffect } from 'react';
export function SearchBox() {
const [query, setQuery] = useState('');
useEffect(() => {
const saved = localStorage.getItem('lastQuery');
if (saved) setQuery(saved);
}, []);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}服务端渲染出的页面中,这类交互组件常被称为交互岛(interactive island)——它们是服务端生成的静态内容区域内、具备独立客户端运行时的功能单元。
SSR、SSG 与两类组件的关系
SSR 和 SSG 处理的是渲染时机,服务端组件与客户端组件处理的是运行环境,它们是正交的概念。
完整的渲染路径如下:
- 在服务端(或构建时),服务端组件被渲染成 RSC Payload;
- 这个 payload 连同客户端组件的预渲染结果一起产生完整的 HTML;
- HTML 发送到浏览器,页面已经可见(包括服务端组件产生的静态内容和客户端组件基于 props 渲染出的初始 UI);
- 浏览器加载客户端 JavaScript bundle,执行水合,组件具备交互能力。
在 SSG 场景中,步骤 1 和 2 在构建时完成,生成的 HTML 可以直接托管在 CDN。SSR 场景则在每次请求时重新执行。不论采用哪种策略,服务端组件都不会出现在客户端 bundle 中。
基本用法
组合规则
混合组件树的核心规则有两条:
服务端组件可以导入客户端组件。 这是最自然的连接方向。服务端组件负责获取数据,把可序列化的结果通过 props 传递给客户端组件。
tsx
// app/posts/page.tsx — 服务端组件(默认)
import { LikeButton } from './like-button';
export default async function PostPage() {
const post = await getPost('hello-world'); // 服务端直接读取数据
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton initialLikes={post.likes} postId={post.id} />
</article>
);
}tsx
// app/posts/like-button.tsx
'use client';
import { useState } from 'react';
export function LikeButton({
initialLikes,
postId,
}: {
initialLikes: number;
postId: string;
}) {
const [likes, setLikes] = useState(initialLikes);
return (
<button onClick={() => setLikes((l) => l + 1)}>
👍 {likes}
</button>
);
}PostPage 在服务端获取数据并渲染标题与正文,然后将点赞数通过 props 交给 LikeButton。LikeButton 被声明为客户端组件,在浏览器中通过 useState 管理交互状态。
客户端组件可以通过 children 接收服务端组件生成的静态内容。 这利用了 React 的 children prop 机制:客户端组件只需提供一个框架(如布局、事件处理外壳),内部内容由服务端组件填充。
tsx
'use client';
import { type ReactNode } from 'react';
export function ClientCard({ children }: { children: ReactNode }) {
return <div className="interactive-card">{children}</div>;
}tsx
// 服务端组件
import { ClientCard } from './client-card';
import { MarkdownContent } from './markdown-content';
export default function Page() {
return (
<ClientCard>
<MarkdownContent slug="hello-world" />
</ClientCard>
);
}ClientCard 可以携带自己的交互逻辑,它的 children 则由服务端组件完全生成,不会进入客户端 bundle。
重要限制: 从服务端传递给客户端组件的 props 必须在 RSC Payload 中被序列化。函数、类实例、Promise 等不可序列化的类型不能作为 props 直接传递。如果需要客户端基于服务端数据执行某些操作,应传递数据值,由客户端组件内部定义处理函数。
组件选择
适合声明为客户端组件的场景:
- 需要 state(
useState、useReducer); - 需要生命周期或副作用(
useEffect); - 需要浏览器 API(
localStorage、window、navigator等); - 需要 DOM 事件处理(
onClick、onChange、onScroll); - 使用的第三方库依赖上述能力。
适合保持为服务端组件的场景:
- 直接读取数据库、文件系统或调用后端 API;
- 处理令牌、密钥等敏感信息;
- 引入大体积依赖,不希望打包进客户端;
- 内容在构建时即可确定(配合 SSG);
- 需要利用 React 的流式传输(streaming)能力逐步输出 UI。
实际项目中常以服务端组件作为页面骨架——负责布局、数据获取与静态内容渲染,再将需要交互的局部区域拆成客户端组件。这就是前文提到的交互岛模式。
示例
以下将服务端数据获取与客户端交互组合在一个页面中,展示混合渲染的典型结构。
tsx
// app/events/page.tsx — 服务端组件
import { EventFilters } from './event-filters';
import { EventList } from './event-list';
import { db } from '@/lib/db';
export default async function EventsPage() {
const events = await db.event.findMany({
orderBy: { date: 'asc' },
take: 50,
});
const categories = [...new Set(events.map((e) => e.category))];
return (
<main>
<h1>Upcoming Events</h1>
<EventFilters categories={categories} />
<EventList events={events} />
</main>
);
}tsx
// app/events/event-filters.tsx
'use client';
import { useState } from 'react';
export function EventFilters({ categories }: { categories: string[] }) {
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="filters">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelected(cat === selected ? null : cat)}
className={cat === selected ? 'active' : ''}
>
{cat}
</button>
))}
</div>
);
}tsx
// app/events/event-list.tsx
'use client';
import { useState } from 'react';
interface Event {
id: string;
title: string;
category: string;
date: string;
capacity: number;
}
export function EventList({ events }: { events: Event[] }) {
const [registered, setRegistered] = useState<Set<string>>(new Set());
return (
<ul>
{events.map((event) => (
<li key={event.id}>
<h3>{event.title}</h3>
<time>{event.date}</time>
<span>{event.category}</span>
<button
onClick={() =>
setRegistered((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
}
return next;
})
}
>
{registered.has(event.id) ? 'Cancel' : 'Register'}
</button>
</li>
))}
</ul>
);
}在这段代码中:
EventsPage在服务端查询数据库,获得事件列表和分类,数据库连接字符串等细节永远不会暴露到浏览器;EventFilters和EventList是客户端组件,接收序列化后的数组数据,在浏览器中各自管理selected和registered状态;- 首屏 HTML 已经包含事件列表和默认状态的按钮,JavaScript 加载并水合后按钮变为可交互。
注意点
'use client' 与 'use server' 是两套独立机制。 'use client' 划定客户端模块边界;'use server' 用于在函数体上声明服务端动作(Server Actions),与服务端组件的默认模式无关。服务端组件不需要也不应该添加任何指令。
React Context 不能在服务端组件中创建或使用。 Context 依赖 Provider 挂载和运行时值传递,而服务端组件实例在渲染后即销毁。正确的处理方式是将 Provider 封装在一个 'use client' 组件中,并把需要消费 context 的范围作为 children 传入。Provider 应尽量深入组件树,避免将整个页面标记为客户端组件。
函数不可作为 props 从服务端组件传向客户端组件。 事件处理函数应当由客户端组件内部定义。如果处理逻辑需要服务端数据,则把数据作为可序列化的 props 传递,由客户端组件本地绑定。
第三方组件库需要显式封装。 如果某个第三方组件内部使用了 useState、useEffect 等 Hook,它必须在客户端组件中渲染。但很多 npm 包未在 package.json 中提供入口标记,需要在项目中手动创建封装组件:
tsx
// app/components/chart-wrapper.tsx
'use client';
import { LineChart } from 'some-chart-lib';
export function ChartWrapper(props: { data: Series[] }) {
return <LineChart data={props.data} />;
}这样,服务端组件即可安全地导入并使用 ChartWrapper,而图表库的 JS 只加载到客户端。
参考链接
相关推荐
2024/01/17 · 5 分钟Next.js 数据获取:缓存、重新验证与请求
Next.js 数据获取:缓存、重新验证与请求 的概念、用法、示例和注意点
2024/01/13 · 7 分钟Next.js 基本概念:文件系统路由与导航Next.js 基本概念:文件系统路由与导航 的概念、用法、示例和注意点
2024/01/11 · 6 分钟Next.js 概述:从 React 到全栈框架Next.js 概述:从 React 到全栈框架 的概念、用法、示例和注意点
2025/03/07 · 7 分钟Nuxt.js 中间件、插件与服务端路由Nuxt.js 中间件、插件与服务端路由 的概念、用法、示例和注意点
