CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Next.js 实战入门/课时/Server 与 Client 组件
RawBlame
课时目录12 节

Next.js 实战入门

  1. 01Next.js 是什么
  2. 02项目创建与目录
  3. 03App Router 与文件路由
  4. 04Server 与 Client 组件
  5. 05数据获取与缓存
  6. 06渲染模式 SSR / SSG / ISR
  7. 07Route Handlers
  8. 08MDX 与课时渲染
  9. 09Tailwind 与样式
  10. 10认证与登录门控
  11. 11读 platform 源码
  12. 12综合练习与部署

Server 与 Client 组件

RSC 默认、use client、何时拆分 Client Component。

35 分钟

Server 与 Client 组件

这一课你将能做到

  1. 一眼判断一段代码该是 Server 还是 Client 组件,并说出判断依据
  2. 熟练使用 'use client' 声明边界,掌握「服务端取数据 + 客户端管交互」的标准拆分套路
  3. 能把 RSC 与 SSR、CSR 的区别讲到面试官点头的程度(本 track 面试权重最高的一课)

为什么要学

App Router 下,你写的每个组件默认都在服务器上执行——这是和过去 React 最大的不同。很多初学者迁移到 Next.js 后的第一个报错就是 useState 用不了,根因就是没理解 Server Component(RSC)。

为什么框架要这样做?看账本:一个中等 React 页面如果全在客户端渲染,JS 包动辄 300KB+,用户要下载、解析、执行完才能看到内容;而页面里真正需要交互的往往只有按钮、弹窗、表单这 20%。RSC 让那 80% 的「展示型代码」留在服务器上跑——不进 JS 包、不发到浏览器,HTML 直接带着内容到达。结果是首屏更快、包更小,还能在组件里直接用只有服务端能用的能力(读文件、查数据库、拿密钥)。

本站课时页就是教科书案例:页面主体(取课时数据、渲染 MDX、判断权限)全是 Server 组件,只有做题判分、进度打钩这些要点击的部分拆成了 Client 组件。

面试角度:「讲讲 Server Component 和 Client Component 的区别」是 Next.js 岗位的第一高频题,出现率不低于闭包之于 JS 岗。这课的目标是让你答出层次感。

先建立图像

两类组件的能力对照表:

能力Server 组件(默认)Client 组件
useState / useEffect / 事件监听❌✅
async/await 直接取数据✅❌(用 useEffect 或上移)
访问后端资源(数据库、文件、密钥)✅❌
打进客户端 JS 包❌(零包体积)✅
浏览器 API(window、localStorage)❌✅

水合(hydration)一图流:

服务器:执行 Server 组件树 → 输出完整 HTML ──→ 浏览器立即显示内容
                                                      ↓
Client 组件的 JS 包到达 → React「接水」:把事件、状态接到对应 DOM 上
                                                      ↓
                                            此刻起按钮才能点、输入框才能敲

组件树的组成规则:

app/page.tsx                (Server)
├── <Header/>               (Server:纯展示,零 JS)
├── <LessonContent/>        (Server:服务端读 MDX 渲染)
└── <QuizPanel/>            ('use client':答题交互)
    ├── <Question/>         (自动也是 Client:被 Client 组件导入即入包)
    └── <SubmitButton/>     (同上)

关键:边界是传染的。一个文件标了 'use client',它导入的所有组件都变成 Client 组件,除非作为 children 从外部传入。

跟着做

步骤 1:体会默认是 Server

新建 (不加任何指令):

src/app/rsc-demo/page.tsx
// 这就是 Server Component:没有 'use client',async 也合法
export default async function RscDemoPage() {
  const time = new Date().toLocaleString("zh-CN");
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold">服务端渲染时间</h1>
      <p className="mt-2">{time}</p>
    </main>
  );
}

new Date() 在服务器上执行,用户看到的是 HTML 里已经写死的时间字符串。这行代码如果在 Client 组件里,执行时机就变成了用户浏览器。理解这个差异比记规则重要。

步骤 2:第一个 Client 组件

新建 src/components/LikeButton.tsx:

"use client";

import { useState } from "react";

export default function LikeButton() {
  const [count, setCount] = useState(0);
  return (
    <button
      className="rounded border px-3 py-1 hover:bg-gray-50"
      onClick={() => setCount(count + 1)}
    >
      👍 点赞 {count}
    </button>
  );
}

在 rsc-demo/page.tsx 顶部下方引入:

import LikeButton from "@/components/LikeButton";

// export default async function RscDemoPage() 里加:
//   <div className="mt-4"><LikeButton /></div>

流程:Server 组件渲染页面 → 发现有 Client 组件 → 它的位置在 HTML 里留好,JS 单独下发 → 水合后可交互。Server 组件里可以直接 import Client 组件,这是最常用的组合。

步骤 3:反方向报错——故意踩一次坑

在 Server 组件里写状态:

// ❌ 這樣会编译报错
export default function Bad() {
  const [n, setN] = useState(0); // Error: useState only works in Client Components
  return <button onClick={() => setN(1)}>{n}</button>;
}

记住报错长相。修复方式:把有交互的叶子拆成独立文件加 'use client',页面保持服务端。

步骤 4:children 模式——Server 组件作为 Client 组件的孩子

需求:一个可折叠的 Client 容器,里面装 Server 渲染的重内容。

src/components/Collapsible.tsx:

"use client";

import { useState } from "react";

export default function Collapsible({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState(false);
  return (
    <div className="border rounded-lg">
      <button className="w-full p-3 text-left font-medium" onClick={() => setOpen(!open)}>
        {open ? "▾" : "▸"} {title}
      </button>
      {open && <div className="p-3 border-t">{children}</div>}
    </div>
  );
}

页面里使用:

import Collapsible from "@/components/Collapsible";
import HeavyMarkdownBody from "@/components/HeavyMarkdownBody"; // Server 组件

export default async function Page() {
  const body = await renderSomething(); // 服务端算好的 React 元素
  return (
    <Collapsible title="查看详细解析">
      <HeavyMarkdownBody content={body} />
    </Collapsible>
  );
}

精髓:HeavyMarkdownBody 由 Server 页面渲染成已完成的元素再传给 children,它不进入 Client 包。若 Collapsible 内部直接 import 它,就会被拖进客户端——两种写法包体积天差地别。这是面试进阶加分点。

步骤 5:把数据取在服务端,把交互留在客户端

模拟本站「服务端拿数据 → 客户端做交互」:

// src/app/counter-demo/page.tsx(Server)
import CountBox from "@/components/CountBox";

export default async function Page() {
  // 服务端调用 API(真实项目里对接 Spring Boot)
  const res = await fetch("https://api.example.com/stats");
  const stats = await res.json();
  return <CountBox initialCount={stats.visitors} />;
}
// src/components/CountBox.tsx(Client)
"use client";
import { useState } from "react";

export default function CountBox({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return (
    <button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={() => setCount(count + 1)}>
      当前 {count} 人在线,点我 +1
    </button>
  );
}

数据在服务端取好,作为 props 流进 Client 组件——这条「服务端取数 → props 下传」的单向管道,是 App Router 最核心的工程套路。

讲清楚要点

  1. 默认 Server,显式声明才是 Client:一个文件最高处的 "use client" 把整个模块标记为客户端边界。没有它,一切都在服务端跑。
  2. Server 组件的输出是序列化后的 UI,不是函数:它执行一次、产出元素,之后服务器不再保存它的实例。所以没有 state、没有 effect、没有浏览器内存——这些概念属于「持续存活的客户端实例」。
  3. props 必须可序列化:从 Server 传给 Client 的 props 只能是字符串/数字/普通对象/数组/Date 等,函数(除 Server Action)、class 实例、Symbol 不行。报错关键词 Functions cannot be passed directly to Client Components。
  4. children 模式是包体积优化利器:Client 组件通过 children 接收的 React 元素,由父层(往往是 Server)预先渲染,不进客户端包。复杂布局组件(侧边栏、Tabs)常用此模式收纳 Server 内容。
  5. 什么时候必须 Client:用了 useState/useEffect/useReducer、onClick/onChange 等事件、useRef、浏览器 API、第三方需要浏览器环境的库(图表、编辑器、地图)。没有这些需求就别加 'use client'——多余声明会把整棵子树拖进 JS 包。

你可能会懵的地方

  • RSC(Server Component)和 SSR 是一回事吗?(面试最爱追问) 不是。SSR 是「渲染时机」技术:把组件在服务器渲染成 HTML 发给浏览器,随后整棵树水合、JS 全量下发。RSC 是「组件架构」:组件树一部分永远留在服务器(Server 组件不水合、JS 不下发),只有标了 'use client' 的部分才水合。SSR 渲染的是「整棵树」,RSC 把树「劈成两半」,只把交互那一半送到浏览器。可以理解为:SSR 解决「首屏有没有内容」,RSC 解决「发多少 JS」。

  • Server 组件怎么响应点击? 它不响应。点击属于浏览器事件,只有水合后的 Client 组件能接。方案:把按钮拆成 Client 子组件;或者点击触发的是「服务端变更」(如表单提交),用 Server Action(第 7 课)。

  • 两个 Server 组件之间能传 onClick 吗? 不能。同理也不能在 Server 组件里把任何函数传给 Client 组件。需要在客户端间共享行为时,该行为定义在 Client 文件里。

  • 'use client' 是不是写在页面顶部就整页变客户端了? 是「从该文件向下游传染」:该文件及其导入的模块全部进客户端包,父级不受影响。所以工程惯例是边界尽量推到叶子:页面保持 Server,只把最小的交互单元声明为 Client。

  • Client 组件也能渲染 SEO 内容吗? 能。Client 组件同样会参与服务端首渲染(SSR),其初始 HTML 也带内容;只是它的 JS 必须下发并水合。所以「SEO 要求」不构成用 Client 的理由,「交互需求」才是。

错法对照

错法问题正解
在 Server 组件里 useEffect 取数据直接报错,且丧失服务端取数优势组件改 async Server Component,直接 await
const [s, setS] = useState(0) 写在 page.tsxApp Router 默认 Server,报错拆出 'use client' 子组件
给 Server 组件传 onClick={fn}函数不可序列化,报错把按钮整体挪进 Client 组件
为了「安全」把所有组件都标 'use client'密钥代码照样能被打包工具触及,且包体积暴涨按需最小化 Client 边界
Client 组件里读 process.env.DATABASE_URL客户端拿不到该值,运行时 undefined服务端读取后以 props 传值

卡点急救

  1. 报错 You're importing a component that needs useState...:当前文件是 Server 却引入了带状态的模块,或自己在 Server 文件里写了 Hook。解决:该文件顶部加 "use client",或把状态逻辑拆出去。
  2. 报错 Functions cannot be passed directly to Client Components:检查是否从 Server 往 Client 传了回调函数。要么把「带函数的那棵树」整体变成 Client,要么改传可序列化数据 + 用 Server Action。
  3. 页面按钮点了没反应、控制台无报错:多半组件在服务端渲染了但 JS 没水合成功——查浏览器 Console 是否有 hydration mismatch(常见原因:服务端与客户端渲染不一致,如随机数、Date.now()、typeof window 判断),或 JS 包被网络拦截。

面试常问

  • Server Component 和 Client Component 的区别与选型? Server 组件仅在服务器执行一次并序列化输出,不能使用状态与浏览器 API,但可 async 取数、访问后端资源、零客户端包体积;Client 组件在浏览器持久存活,可用状态、事件与浏览器 API,JS 会下发并水合。选型原则:默认 Server,只在需要交互/状态/浏览器 API 的最小单元声明 'use client',数据在服务端取好后经 props 流入。

  • RSC 与 SSR 的区别? SSR 是渲染策略——整棵组件树在服务器渲染成 HTML,浏览器端全量水合。RSC 是组件模型——组件树在构建时就被划分为 Server/Client 两部分,Server 部分永不进入客户端包,Client 部分才水合。RSC 叠加在 SSR 之上,本质优化点是「减少下发 JS + 允许组件直接取后端数据」。

  • 为什么 Server 组件不能有状态?Client 组件的 props 为什么不能传函数? Server 组件执行完即销毁,服务器不为它保存运行时实例,而状态本质是「跨时间存活的内存」,二者矛盾。函数不可跨网络边界传递,因为 Server → Client 的 props 要经过 JSON 序列化传输,函数没有可靠序列化形式(Server Action 例外,它走独立协议)。

自测三问

  1. 一段代码需要同时「读数据库」和「监听 input 输入」,你会怎么拆组件?
  2. 'use client' 写在中间层组件,对它的父组件和子组件分别有什么影响?
  3. 不写任何代码,说出 RSC 和 SSR 的两点本质区别。

小结一句话

App Router 默认一切皆 Server 组件,交互的最小单元加 'use client';数据在服务端取、状态在客户端管、组件用 children 穿越边界,是这套架构的全部心法。

延伸

  • Next.js 官方文档:Server and Client Components
  • 下一课:在 Server 组件里取数据——fetch、缓存与再验证。
加载本节练习…
← 上一课App Router 与文件路由下一课 →数据获取与缓存