前言
很多刚接触 Next.js App Router 的人容易被一个概念搞晕:服务端组件和客户端组件的分界到底在哪里?
这篇文章不讨论理论,直接用我博客项目的实际代码来讲。
先看结果:我的项目里服务端和客户端各占多少
统计了一下整个项目的页面:
| 类型 | 数量 | 说明 |
|---|---|---|
| 服务端组件(页面) | 3 | RootLayout、首页、关于页 |
| 客户端组件(页面) | 37 | 文章、音乐、相册、子站等 |
先搞清楚两个概念的区别:
| 服务端组件 | 客户端组件 | |
|---|---|---|
| 运行位置 | 服务器,渲染完就结束 | 浏览器,持续运行 |
| 能做什么 | 读文件、查数据库、fetch API | 监听点击、用 useState、调浏览器 API |
| 不能做什么 | 不能用 useState/useEffect,不能交互 | 不能直接读本地文件 |
| 代码去哪了 | 渲染成 HTML 后丢弃,不发给浏览器 | 打包成 JS 发给浏览器 |
37 个页面都是客户端组件。这是不是意味着服务端组件没用?恰恰相反——服务端组件用对了地方,客户端组件才写得轻松。
关键边界一:Root Layout 和首页是服务端组件
这是项目的根布局,是不需要 "use client" 的顶层组件之一:
// app/layout.tsx — 服务端组件
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider> {/* 客户端 */}
<EffectProvider> {/* 客户端 */}
<BackgroundProvider> {/* 客户端 */}
<MusicProvider> {/* 客户端 */}
<ToastProvider> {/* 客户端 */}
<Navbar /> {/* 客户端 */}
<main>{children}</main>
<ClientWidgets />{/* 客户端 */}
</ToastProvider>
</MusicProvider>
</BackgroundProvider>
</EffectProvider>
</ThemeProvider>
</body>
</html>
);
}
这个文件清楚地展示了服务端 → 客户端的单向边界。
RootLayout 本身是服务端组件:它负责组装 HTML 结构、注入字体变量的 CSS、设置 lang 属性。但它内部包裹的所有 Provider(ThemeProvider、MusicProvider 等)都是客户端组件——因为它们依赖 useState、useContext 这些浏览器端 API。
这层的设计原则是: 服务端 Layout 只做最外层骨架,所有需要状态的逻辑都下沉到客户端 Provider 里。
关键边界二:Provider 是服务端能调用的最大客户端
每个 Provider 都是一个简单的客户端组件,只做状态管理,不做 UI 渲染:
"use client";
import { createContext, useState } from "react";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
// ...主题逻辑
return <ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>;
}
这意味着服务端 Layout 可以直接用 children 把任意服务端组件传入客户端 Provider,Provider 不需要知道 children 是服务端还是客户端。
所以 RootLayout 里 {children} 指向的具体页面,可以是服务端组件(如关于页),也可以是客户端组件(如文章列表页)。Provider 不关心它的 children 是什么类型。
关键边界三:关于页为什么可以是服务端组件
关于页和首页一样,是服务端组件页面:
// app/about/page.tsx — 服务端组件(没有 "use client")
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { unified } from "unified";
// ...更多 import
export default async function AboutPage() {
// 在服务端读取文件
const fullPath = path.join(process.cwd(), "app", "about", "about.md");
const fileContents = fs.readFileSync(fullPath, "utf8");
const { content } = matter(fileContents);
// 在服务端把 Markdown 转成 HTML
const processedContent = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeHighlight)
.use(rehypeStringify)
.process(content);
return (
<FadeIn> {/* FadeIn 是客户端组件 */}
<div className="...">
<div dangerouslySetInnerHTML={{ __html: processedContent }} />
</div>
</FadeIn>
);
}
它可以直接 import fs from "fs"、读本地文件、用 unified 转 Markdown——这些全在服务端完成,浏览器只收到最终的 HTML。
浏览器不需要下载 gray-matter、unified、remark、rehype 这些包,也不需要执行解析逻辑。这就是服务端组件的核心优势:让浏览器少干活。
FadeIn 是一个客户端组件,但它的职责只是包裹一层 motion.div 动画,本身不做数据获取:
"use client";
import { motion } from "framer-motion";
export default function FadeIn({ children, delay = 0 }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
>
{children}
</motion.div>
);
}
模式归纳: 服务端组件负责数据获取和静态渲染,客户端组件负责交互和动画,两者通过 children 组合。
关键边界四:首页从客户端改成服务端——一次重构
首页原本跟其他页面一样是客户端组件,最近重构时把它改成了服务端组件。这是修改后的结构:
// app/page.tsx — 服务端组件(没有 "use client")
import HomeClient from "./HomeClient";
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
async function fetchProfileData() {
try {
const [postsRes, chattersRes, albums] = await Promise.all([
fetch(`${API}/api/posts/count?status=published`, { next: { revalidate: 60 } }).then((r) => r.json()),
fetch(`${API}/api/chatters/count?status=published`, { next: { revalidate: 60 } }).then((r) => r.json()),
fetch(`${API}/api/albums`, { next: { revalidate: 60 } }).then((r) => r.json()),
]);
return {
postCount: postsRes.count ?? 0,
chatterCount: chattersRes.count ?? 0,
photoCount: Array.isArray(albums)
? albums.reduce((acc, a) => acc + (a.photo_count ?? 0), 0)
: 0,
};
} catch {
return { postCount: 0, chatterCount: 0, photoCount: 0 };
}
}
export default async function Home() {
const { postCount, chatterCount, photoCount } = await fetchProfileData();
return (
<HomeClient
postCount={postCount}
chatterCount={chatterCount}
photoCount={photoCount}
/>
);
}
// app/HomeClient.tsx — 客户端组件("use client")
"use client";
import dynamic from "next/dynamic";
const CloudPlayer = dynamic(() => import("@/components/music/CloudPlayer"), { ssr: false });
const LyricBar = dynamic(() => import("@/components/music/LyricBar"), { ssr: false });
const LatestPostsCarousel = dynamic(() => import("@/components/home/LatestPostsCarousel"), { ssr: false });
// ...其他 dynamic import
export default function HomeClient({ postCount, chatterCount, photoCount }: {
postCount: number; chatterCount: number; photoCount: number;
}) {
return (
<div className="...">
<ProfileCard postCount={postCount} chatterCount={chatterCount} photoCount={photoCount} />
<CloudPlayer />
<LyricBar />
<LatestPostsCarousel />
{/* ...其他交互组件 */}
</div>
);
}
重构后的数据流:
服务端 page.tsx
├── fetch API → 统计数据
└── 传入 props → HomeClient(客户端)
├── ProfileCard(纯展示,无 "use client")
├── CloudPlayer(dynamic + ssr: false)
└── 其他交互组件
这次重构说明一个模式:服务端组件负责数据获取,客户端组件只负责交互。首页虽然整体标记为客户端的时候多一些,但涉及数据获取的部分完全可以上提给服务端。
重构前后首页组件对比:
| 组件 | 重构前 | 重构后 |
|---|---|---|
| ProfileCard | 客户端,内部 useEffect 获取数据 | 纯展示组件,接收 props |
| CloudPlayer | 客户端,dynamic + ssr: false | 不变 |
| PhotoWallPreview | 客户端,dynamic + ssr: false | 不变 |
| 统计数据获取 | ProfileCard 内部 useEffect | page.tsx 服务端 fetch |
收益:
- ProfileCard 不再需要
"use client",移除了 useState/useEffect - 统计数据请求在服务端完成,浏览器不参与
- ProfileCard 的代码体积不发送到浏览器端
- 后续新增统计数据不需要改客户端代码
关键边界五:dynamic + ssr: false 的使用场景
可以看到代码里几个组件用了 dynamic(() => import(...), { ssr: false }):
CloudPlayer— 音乐播放器,依赖浏览器端 APILyricBar— 歌词条,需要AudioContextPhotoWallPreview— 相册,依赖window尺寸
加上 ssr: false 后,这些组件只在浏览器端渲染,服务端不参与。好处是:
- 避免因为服务端没有
window/document导致 hydration 报错 - 减少首屏 HTML 体积
- 组件代码被拆成独立 chunk,按需加载
什么时候用 dynamic + ssr: false:
- 组件直接使用了
window、document、localStorage等浏览器 API - 组件体积较大且首屏不可见(如弹窗、底部面板)
- 组件依赖第三方库只在浏览器端可用
从项目中总结的边界划分原则
原则一:能服务端就服务端,但不强行服务端
关于页可以做到纯服务端是因为它:读本地文件 + Markdown 渲染 + 没有交互。但大部分页面做不到——只要有用户操作、动画、状态管理,就必须是客户端。
原则二:用 Provider 封装状态,在服务端 Layout 里引用客户端 Provider
服务端 Layout
└── 客户端 Provider(Theme / Music / Toast…)
├── 服务端 Page(纯展示)
└── 客户端 Page(有交互)
Provider 是服务端和客户端之间的桥梁。
原则三:客户端组件尽可能薄
像 FadeIn 只有几行代码,它的职责就是包装一个 motion.div。不要在客户端组件里塞大量数据获取逻辑——如果数据能提前在服务端准备好,就提前在服务端准备好。
原则四:dynamic import 是优化手段,不是架构决策
不要把 dynamic(() => import(...)) 当成默认选择。先正常 import,等发现组件确实不需要 SSR、或者确实太大需要拆 chunk 时,再加 dynamic。
原则五:一个页面写 "use client" 不丢人
如果页面的大部分功能都需要交互,直接 "use client" 比强行拆成服务端 + 客户端要干净得多。Next.js 的服务端组件是给你用的,不是给你卷的。
总结
| 场景 | 推荐做法 |
|---|---|
| 页面纯展示,没有交互 | 服务端组件 |
| 页面有少量交互 | 服务端主体 + 客户端子组件 |
| 页面大部分有交互 | 客户端组件 + dynamic import |
| 全局状态管理 | 客户端 Provider 包裹在服务端 Layout 里 |
| 依赖 window/document | dynamic import + ssr: false |
| Markdown 渲染 | 服务端组件(避免下载解析库) |
App Router 的组件边界没有标准答案,关键是要理解:服务端组件减少的是浏览器端的 JavaScript 体积和计算开销,但增加了一些组件组织上的复杂度。哪个收益更大,选哪个。
从这个项目来看,纯服务端组件的页面只占了 7%,但它处理了 Markdown 解析、文件读取、数据获取这些最重的计算任务。客户端组件占了 93%,但它们的代码量其实不大——大部分是轻量的交互层。这是我认为比较健康的界限。

