1.4 客户端与服务器组件


1.4 客户端与服务器组件

本节摘要:App Router 把组件分成两种:服务器组件(Server Components)与客户端组件(Client Components)。这是理解 Next.js 全栈能力的关键。本节讲清两者的本质区别、use client 指令、各自的适用场景,以及"默认服务器组件"的思维转变。

阅读收获

阅读完本节,你应当能够:

  1. 解释服务器组件与客户端组件的本质区别
  2. 用 "use client" 声明客户端组件
  3. 判断一个组件该用哪种
  4. 理解"组件可以在服务器上直接访问数据库"的含义
  5. 避免常见的"服务器组件用客户端 API"错误

问题与直觉:组件跑在哪,决定了它能做什么

React 组件传统上只在浏览器(客户端)运行——useStateuseEffect、事件处理都是浏览器的概念。Next.js 的 App Router 引入了一个突破性设计:组件默认在服务器上运行(服务器组件),只有明确声明("use client")才在浏览器运行。

为什么要在服务器上跑组件? 因为服务器能做的事,浏览器做不了:

  • 直接访问数据库(无需 API 层);
  • 直接读取文件、调用内部服务;
  • 持有密钥(环境变量,不外泄到浏览器);
  • 渲染成完整 HTML(SEO 友好)。

直觉类比:服务器组件像"后厨做好直接上桌的菜"(用户看到成品,看不到食材与配方);客户端组件像"餐桌上的火锅"(食材端上来,现场煮——交互就在眼前发生)。

💡 关键直觉:默认服务器组件 = 更少代码 + 更安全 + 更快。只有需要"交互"(状态、事件、浏览器 API)的组件才需要变成客户端组件。这个思维转变是 App Router 与传统 React 最大的不同。

核心原理:两种组件的边界

2.1 服务器组件(默认)

不需要任何指令,组件默认就是服务器组件:

// 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> ); }

特性

  • 可以 async(直接 await 数据获取);
  • 可以访问 Node.js 能力(fs、数据库、内部 API);
  • 可以使用环境变量(密钥不会发到浏览器);
  • 不能使用 useState/useEffect/事件处理/浏览器 API。

2.2 客户端组件("use client")

文件顶部加 "use client" 指令,该组件及其子组件变为客户端组件:

"use client"; // 必须放在文件第一行 import { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> 点击 {count} 次 </button> ); }

特性

  • 可以使用所有 React 交互能力(state、effect、事件);
  • 可以使用浏览器 API(window、localStorage);
  • 在客户端渲染并参与 hydration(水合);
  • 不能直接访问数据库/读取密钥。

2.3 组合规则

2.3 组合规则

三条关键规则

  1. 服务器组件可以包含客户端组件(把交互部分作为子组件传入);
  2. 客户端组件不能导入服务器组件(但可以通过 children 接收服务器组件渲染的结果);
  3. "use client" 标记的是边界——文件内的所有导入组件都变成客户端组件,直到你显式拆分。

2.4 判断清单:用哪种?

场景 组件类型
页面内容(静态/数据展示) 服务器组件
表单提交处理 服务器组件(配合 Server Actions)
计数器/开关/弹窗 客户端组件
需要 localStorage/window 客户端组件
数据获取(查库/调 API) 服务器组件
动画/第三方交互库 客户端组件

工程实践要点

3.1 常见错误:服务器组件里用 hooks

// ❌ 错误:服务器组件不能使用 useState export default function Bad() { const [count, setCount] = useState(0); // 报错 return <div>{count}</div>; } // ✅ 正确:拆出客户端子组件 "use client"; function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; }

3.2 服务器组件传数据给客户端组件

服务器组件获取数据,作为 props 传给客户端组件:

// 服务器组件(页面) export default async function Page() { const users = await getUsers(); // 服务器上取数 return <UserList users={users} />; // 传给客户端组件 } // 客户端组件(交互展示) "use client"; export function UserList({ users }: { users: User[] }) { const [selected, setSelected] = useState(null); return <ul>{/* 用 useState 做交互 */}</ul>; }

注意:传给客户端组件的 props 必须可序列化(JSON 兼容)——函数、Date 实例不能直接传。

3.3 为什么"默认服务器组件"让代码变少

传统 React 需要:前端组件 + fetch API + 后端 API 路由 + 数据格式化。Next.js 服务器组件直接:查库 → 渲染。省掉了 API 层(不需要时)。当然,需要被外部调用的接口仍用 API 路由(2.3)。

常见误区与排查

误区 现象 正解
服务器组件用 useState 编译/运行报错 拆成客户端子组件
忘写 "use client" hooks 报错/事件不生效 文件顶部加指令
客户端组件导入服务器组件 报错/数据丢失 用 children 传渲染结果
把密钥放客户端组件 环境变量暴露 密钥只在服务器组件用
props 传函数/Date 序列化报错 只传 JSON 兼容数据

动手演练:混合使用两种组件

// app/posts/page.tsx —— 服务器组件(页面) import { getPosts } from "@/lib/data"; export default async function PostsPage() { const posts = await getPosts(); // 服务器取数 return ( <div> <h1>文章列表</h1> <LikeButton postId={posts[0].id} /> {/* 客户端交互子组件 */} <ul> {posts.map((p) => <li key={p.id}>{p.title}</li>)} </ul> </div> ); } // components/LikeButton.tsx —— 客户端组件 "use client"; import { useState } from "react"; export function LikeButton({ postId }: { postId: number }) { const [liked, setLiked] = useState(false); return ( <button onClick={() => setLiked(!liked)}> {liked ? "已赞" : "点赞"} </button> ); }

页面主体在服务器渲染(SEO 友好、数据完整),点赞按钮在客户端交互(响应即时)——两种组件各司其职,这是 App Router 应用的典型形态。

重点提炼

  • 服务器组件(默认):async、可直接访问数据库与密钥、渲染成 HTML;不能使用 hooks/事件。
  • 客户端组件("use client"):可以使用 state/effect/事件/浏览器 API;不能访问数据库/密钥。
  • 组合规则:服务器组件可包含客户端组件;客户端组件不能导入服务器组件,但可用 children 接收。
  • props 序列化:跨组件边界传数据必须 JSON 兼容。
  • 判断清单:数据展示用服务器组件,交互用客户端组件。
  • 思维转变:默认服务器组件 = 更少代码、更安全、更快。

深入理解:组件边界的常见陷阱

组件边界的判断是 App Router 开发中最常出错的地方,几个高频陷阱展开讲。

陷阱一:把整个页面标成 use client。新手常犯——页面里有一个按钮要交互,就把整个 page.tsx 标成 use client。后果:页面所有内容都变成客户端渲染,数据获取、SEO 优势全部丢失。正解:只在需要交互的"叶子组件"标 use client,页面主体保持服务器组件。

陷阱二:在客户端组件里导入服务器组件。客户端组件不能直接 import 服务器组件(因为服务器组件含服务器代码,不能打包给浏览器)。正解:把服务器组件作为 children 传入客户端组件:

// 服务器页面 export default async function Page() { const data = await getData(); // 服务器取数 return ( <ClientShell> <ServerContent data={data} /> // 服务器渲染的内容作为 children </ClientShell> ); }

陷阱三:跨边界传不可序列化的 props。函数、Date、Map 不能直接传给客户端组件。正解:传 JSON 兼容数据(字符串、数字、普通对象),或先序列化。

陷阱四:在服务器组件里用事件。onClick 等事件在服务器组件中无效(服务器没有浏览器事件)。正解:交互逻辑必须放客户端组件。

判断口诀:"要交互 → 客户端;要数据/安全 → 服务器;纯展示 → 服务器"。记住:默认服务器,按需客户端——这是 App Router 的默认心智。

性能视角:客户端组件越多,下载的 JS 越多、首屏越慢。把"非交互部分"留在服务器组件,是性能优化(3.4)的第一步。每标一个 use client,就问自己:这个组件真的需要浏览器交互吗?

组件类型选择流程

默认服务器组件;需要交互、浏览器 API 才标 use client。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U