陌上人如玉
公子世无双

9.3 Server Components 与 Client Components

9.3 Server Components 与 Client Components

一句话:Server Component 在服务端执行(可直接查数据库、代码不进客户端包,但不能有状态/事件/浏览器 API);Client Component 用 "use client" 标记(可交互、有状态);"use client" 是一条边界——它导入的所有模块都会变成客户端代码。

一、两类组件的对比

维度 Server Component Client Component
执行环境 服务端 浏览器(也会在服务端预渲染)
标记方式 默认(无需标记) 文件顶部 'use client'
能否 async/await ✅(可以直接取数据) ❌(组件本身不能是 async)
访问数据库/文件系统 ✅ ❌
useState / useReducer ❌ ✅
useEffect / useLayoutEffect ❌ ✅
事件处理(onClick) ❌ ✅
浏览器 API(window/localStorage) ❌ ✅
代码是否进客户端包 ❌(不进) ✅
能否读 cookie/header ✅(通过 cookies()/headers()) ⚠️(会变成动态渲染)
主要用途 数据获取、静态内容、减少包体积 交互、状态、副作用
// ✅ Server Component(默认)——可以 async、直接查库
// app/posts/page.tsx
import { db } from '@/lib/db';

export default async function PostsPage() {
  const posts = await db.post.findMany();       // ⭐ 服务端执行
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>           {/* 纯展示,不需要交互 */}
      ))}
    </ul>
  );
}
// ✅ Client Component——需要状态与事件
// app/posts/like-button.tsx
'use client';

import { useState } from 'react';

export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
  const [liked, setLiked] = useState(false);
  const [count, setCount] = useState(initialCount);

  return (
    <button onClick={() => { setLiked((v) => !v); setCount((c) => c + (liked ? -1 : 1)); }}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  );
}

二、"use client" 是一条「边界」

// layout.tsx(Server Component)
import { ThemeProvider } from './theme-provider';       // ⭐ 这个文件有 'use client'
import { db } from '@/lib/db';

export default async function Layout({ children }) {
  const config = await db.config.findFirst();
  return (
    <ThemeProvider defaultTheme={config.theme}>       {/* 可以渲染 Client Component */}
      {children}
    </ThemeProvider>
  );
}
// theme-provider.tsx
'use client';                                          // ⭐ 边界从这里开始

import { createContext, useState } from 'react';
import { helper } from './helper';                     // ⚠️ 这个模块【也变成客户端代码】
import { formatDate } from './format';                 // ⚠️ 同上

// 这个文件里的所有代码(含它导入的模块)都会进客户端包
⭐ 关键认知:「'use client' 是一个入口,不是一个标签」

① 它标记的是「**模块图的入口**」——从该文件开始,
   它导入的所有模块都会变成「客户端代码」(进 bundle)
② 所以「在高层组件里写 'use client'」会让「整个子树都变成客户端代码」
   → 失去 RSC 的收益(包体积变大)
③ 正确做法是「**把边界尽量下沉到叶子(叶子交互组件)**」
   —— 只有真正需要交互的部分才标 'use client'
// ❌ 边界开在高层:整个页面都成了客户端代码
'use client';                          // ⚠️ 放在 page.tsx 顶部
import { HeavyChart } from './heavy-chart';    // ⚠️ 它也进客户端包了
export default function Page() { /* ... */ }

// ✅ 边界下沉:页面保持 Server,只有交互叶子是 Client
// page.tsx(Server)
import { LikeButton } from './like-button';    // 只有这个进客户端包
import { HeavyChart } from './heavy-chart';    // ⭐ 不进客户端包(Server Component)

export default async function Page() {
  const data = await getData();
  return (
    <>
      <HeavyChart data={data} />               {/* 服务端渲染(代码不进客户端) */}
      <LikeButton postId={data.id} />          {/* 客户端交互 */}
    </>
  );
}

三、组合规则

✅ 允许的组合:

① Server Component 渲染 Client Component
   <Server>
     <Client />                              {/* ✅ */}
   </Server>

② Server Component 通过 props 把「可序列化数据」传给 Client Component
   <Client user={{ id: 1, name: 'a' }} />   {/* ✅ 普通对象可以 */}

③ Client Component 通过 children 接收 Server Component(⭐ 重要模式)
   <Client>
     <Server />                              {/* ✅ 由「上层 Server」创建并传入 */}
   </Client>

❌ 禁止的组合:

④ Client Component 直接 import Server Component
   'use client';
   import { ServerComp } from './server-comp';   // ❌ 会被当成普通模块打进客户端

⑤ 传「不可序列化的 props」给 Client Component
   <Client onSelect={() => {}} />               // ❌ 函数
   <Client date={new Date()} />                 // ⚠️ 能传但不推荐(改为字符串/时间戳)
   <Client instance={new Foo()} />              // ❌ 类实例
// ✅ ③ 的模式:Server Component 作为 children 传给 Client Component
// modal.tsx(Client Component)
'use client';
import { useState } from 'react';

export function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>打开</button>
      {open && <div className="modal">{children}</div>}
    </>
  );
}
// page.tsx(Server Component)
import { Modal } from './modal';
import { ServerContent } from './server-content';      // ⭐ Server Component

export default function Page() {
  return (
    <Modal>
      <ServerContent />                                {/* ✅ 由 Server 创建,作为 children 传入 */}
    </Modal>
  );
}
// ⭐ ServerContent 依然在服务端渲染(代码不进客户端包),
//    但它的输出会被 Modal 的 state 控制显示时机
// ❌ ④ 的反例
'use client';
import { ServerContent } from './server-content';      // ❌ 报错/被当客户端模块

四、props 的序列化限制

// ✅ 可以传给 Client Component 的 props
<string>、<number>、<boolean>、null、undefined、Array、纯对象、Date、
Promise(React 19 支持,配合 use())、Server Action(函数,特殊支持)
// ❌ 不能传的
函数(除 Server Action)         <Client onClick={() => {}} />        // ❌
类实例(非纯对象)               <Client instance={new Foo()} />      // ❌
Symbol                          <Client s={Symbol('x')} />           // ❌
DOM 节点                        <Client el={document.body} />        // ❌
// ✅ 需要「交互回调」时的做法
// 方案一:用 Server Action(可以传!)
<Client onDelete={deletePost} />       // deletePost 是 'use server' 的函数 ✅

// 方案二:把「交互逻辑」移进客户端组件(客户端自己定义回调)
// like-button.tsx
'use client';
export function LikeButton({ postId }: { postId: string }) {
  const handleClick = async () => {
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });   // 客户端自己发请求
  };
  return <button onClick={handleClick}>点赞</button>;
}

五、什么时候用哪个

✅ 用 Server Component(默认):
① 只是「展示数据」(不需要交互)
② 需要「直接访问数据库/文件系统」
③ 需要「减少客户端包体积」(引入大依赖但不希望它进 bundle)
④ 需要「读取 cookie/header 做初始渲染」
⑤ 静态内容(页头、页脚、文章正文)

✅ 用 Client Component('use client'):
① 需要 useState / useReducer(有状态)
② 需要 useEffect(副作用、订阅)
③ 需要事件处理(onClick / onChange / onSubmit)
④ 需要浏览器 API(localStorage、window、IntersectionObserver)
⑤ 需要「自定义 Hook」(自定义 Hook 通常依赖状态或副作用)
⑥ 需要 Context Provider(客户端 Context)
// ✅ 一个典型的「页面组合」:Server 取数据 + Client 做交互
// app/posts/[id]/page.tsx(Server)
import { LikeButton } from './like-button';
import { CommentForm } from './comment-form';

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await getPost(id);                        // ⭐ 服务端取数据

  return (
    <article>
      <h1>{post.title}</h1>                              {/* 静态内容(Server) */}
      <div dangerouslySetInnerHTML={{ __html: post.html }} />

      <LikeButton postId={post.id} initialCount={post.likes} />   {/* 交互(Client) */}
      <CommentForm postId={post.id} />                     {/* 表单(Client) */}
    </article>
  );
}
场景 选择
详情页正文 Server
点赞按钮 Client
评论表单 Client
导航栏(含当前路径高亮) Client(需要 usePathname)
页脚 Server
图表(纯展示 + 数据来自 props) Server(除非需要交互)
图表(可缩放/可切换) Client
主题切换按钮 Client
主题 Provider Client(createContext 需要在客户端)

六、常见坑

// ① 在 Server Component 里用 useState / onClick
export default function Page() {
  const [x, setX] = useState(0);              // ❌ 报错
  return <button onClick={() => {}} />;        // ❌ 报错
}

// ② 把 'use client' 放在顶层布局 / 页面 → 整个应用变客户端
// ③ 在 Client Component 里 import Server Component(会被打进客户端)
// ④ 传「不可序列化的 props」(函数、类实例、Symbol)
<Client onSelect={() => {}} />                 // ❌
// ⑤ 认为「Server Component 能接收 children 就代表能随意嵌套」
//    —— 必须由【上层 Server】创建 children 才行
// ⑥ 在 Client Component 里读「服务端环境变量」(未加 NEXT_PUBLIC_ 的读不到)
process.env.DB_PASSWORD                          // ❌ 客户端为空
process.env.NEXT_PUBLIC_API_URL                  // ✅ 客户端可用

// ⑦ 在 Server Component 里「长时间阻塞」(串行请求)
const a = await getA(); const b = await getB();  // ⚠️ 串行 → 用 Promise.all
// ⑧ 在 Client Component 里「用服务端专属 API」
import { db } from '@/lib/db';                   // ❌
// ⑨ 把「大依赖」放在 Client Component(体积暴涨)
'use client';
import moment from 'moment';                     // ⚠️ 进客户端包
// ⑩ 用 Server Action 做「需要返回大量数据」的事(应该用「数据获取」而不是 Action)
// ⑪ 认为「Server Component 不能有交互」→ 正确,但可以通过「组合」实现
// ⑫ 忘记「Client Component 也会在服务端预渲染(SSR)」→ 
//    所以它内部也不能在渲染时用 window(要用 useEffect)
'use client';
const w = window.innerWidth;                     // ❌ SSR 时 window 不存在
// ⑬ 在 Server Component 的 props 里传「promise 给客户端」(React 19 支持但要配合 use)
// ⑭ 把「状态提升到「跨 Server/Client 边界」的层级(做不到,状态只能在客户端)
// ⑮ 用「Context」跨 Server/Client 共享数据(Context 只存在于客户端树)
// ⑫ 的正确处理
'use client';
function Component() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);                 // ⭐ 只在客户端执行
  }, []);

  return <div>{width}</div>;
}

七、面试延伸

  1. 「Server Component 和 Client Component 有什么区别?」
    核心差异是「执行环境」(因此也决定了「能力」):① Server Component(默认)在服务端执行——可以是 async、直接访问数据库/文件系统、代码不进客户端包(减小 bundle);但不能有状态(useState)、事件处理(onClick)、副作用(useEffect)、浏览器 API(window)。② Client Component('use client')在浏览器执行——可以有状态、事件、副作用、浏览器 API,但代码会进客户端包。分工是「Server 取数据 + 静态展示,Client 做交互」。

  2. 「'use client' 的作用范围是什么?」
    它标记的是「模块图的入口」——从该文件开始,它导入的所有模块都会变成客户端代码(都会进 bundle)。所以:① 不要在高层组件里写 'use client'(那会让整个子树都变成客户端代码,失去 RSC 减少包体积的收益);② 要把边界下沉到「叶子交互组件」——只有真正需要状态/事件的部分才标记,页面与数据部分保持 Server。

  3. 「Server Component 和 Client Component 能互相嵌套吗?」
    三种合法组合:① Server 渲染 Client ✅(最常见);② Server 传「可序列化数据」给 Client ✅;③ Client 通过 children 接收 Server ✅(重要模式——由上层 Server 创建 <ServerComp /> 元素并作为 children 传入,它依然在服务端渲染)。禁止的组合是「Client 直接 import Server Component」(会被当作普通模块打进客户端包)。

  4. 「为什么 Client Component 不能直接 import Server Component?」
    因为「导入关系决定了模块属于哪一侧」——import 是「编译期的模块依赖」,如果一个客户端模块 import 了另一个模块,那个模块就必然被打进客户端 bundle(否则浏览器运行时会找不到它)。而 Server Component 的代码可能依赖「服务端专属能力」(数据库、fs、密钥),打进客户端会直接报错或造成安全隐患。所以跨边界的正确方式是「通过 props(children)传递」——那样 Server Component 由「Server 侧」创建,客户端只接收它的「渲染结果(RSC payload)」。

  5. 「哪些 props 不能从 Server 传给 Client?」
    不能传「非可序列化的值」:① 函数(除 Server Action 外——'use server' 的函数是特殊支持的);② 类实例(包括 Date 可用但复杂对象不行);③ Symbol;④ DOM 节点;⑤ 包含上述值的嵌套对象。可以传:字符串、数字、布尔、null/undefined、数组、纯对象、Promise(React 19)、Server Action。需要「交互回调」时,要么用 Server Action,要么把回调逻辑移进客户端组件里。

  6. 「怎么判断一个组件该用 Server 还是 Client?」
    从「能力需求」倒推:① 需要状态、事件、副作用、浏览器 API、自定义 Hook、Context Provider → Client;② 只是展示数据、需要访问数据库、或者「引入大依赖但不想进 bundle」 → Server(默认)。关键优化手法是「把边界下沉」——让「页面/布局/列表」保持 Server,把「按钮、表单、切换器、需要状态的图表」这些叶子标为 Client。

  7. 「Client Component 也会在服务端渲染吗?」
    会(在 SSR 场景下)——Next.js 会把 Client Component 预渲染成 HTML(这样首屏能立刻看到内容),只不过它还会「附带 JS 到客户端用于水合」。所以:① Client Component 内部不能在渲染期间访问 window/localStorage(服务端没有),要用 useEffect(只在客户端执行);② 这也会引入「hydration mismatch」的风险(见 9.4)。记住:「Client Component」不等于「只在客户端执行」,它只是「会在客户端执行(也要水合)」。

  8. 「RSC 有什么代价?」
    四点:① 心智模型复杂——要理解「两种组件、边界在哪、什么能传什么不能传」;② 调试与工具链——两层运行时(服务端/客户端),错误可能跨边界;③ 生态适配——很多库(Context、状态管理、UI 库)是「客户端假设」的,需要在边界上包装;④ 框架绑定——目前主要依赖 Next.js 这类框架的实现。收益(包体积、数据库直连、流式)是否值得,取决于「应用的类型」——内容型应用收益大,纯客户端应用收益小。

一句话速记

Server Component(默认、服务端执行):可以 async/await、直接查数据库、代码不进客户端包;不能有状态/事件/副作用/浏览器 API。Client Component('use client'):有状态、事件、副作用、浏览器 API;代码进客户端包,且SSR 时也会在服务端预渲染(所以渲染期不能用 window)。'use client' 是一条「模块图入口」边界——它导入的所有模块都变成客户端代码 → 要把边界下沉到叶子交互组件;嵌套规则:Server 可渲染 Client、可通过 children 把 Server 传给 Client(重要模式),但 Client 不能 import Server;props 必须可序列化(函数只有 Server Action 可以传;类实例/Symbol/DOM 不行);分工口诀是「Server 取数据与静态展示、Client 做交互」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.3 Server Components 与 Client Components

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫