前言

很多刚接触 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 — 音乐播放器,依赖浏览器端 API
  • LyricBar — 歌词条,需要 AudioContext
  • PhotoWallPreview — 相册,依赖 window 尺寸

加上 ssr: false 后,这些组件只在浏览器端渲染,服务端不参与。好处是:

  1. 避免因为服务端没有 window / document 导致 hydration 报错
  2. 减少首屏 HTML 体积
  3. 组件代码被拆成独立 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%,但它们的代码量其实不大——大部分是轻量的交互层。这是我认为比较健康的界限。