本节摘要:App Router 把组件分成两种:服务器组件(Server Components)与客户端组件(Client Components)。这是理解 Next.js 全栈能力的关键。本节讲清两者的本质区别、use client 指令、各自的适用场景,以及"默认服务器组件"的思维转变。
阅读完本节,你应当能够:
React 组件传统上只在浏览器(客户端)运行——useState、useEffect、事件处理都是浏览器的概念。Next.js 的 App Router 引入了一个突破性设计:组件默认在服务器上运行(服务器组件),只有明确声明("use client")才在浏览器运行。
为什么要在服务器上跑组件? 因为服务器能做的事,浏览器做不了:
直觉类比:服务器组件像"后厨做好直接上桌的菜"(用户看到成品,看不到食材与配方);客户端组件像"餐桌上的火锅"(食材端上来,现场煮——交互就在眼前发生)。
💡 关键直觉:默认服务器组件 = 更少代码 + 更安全 + 更快。只有需要"交互"(状态、事件、浏览器 API)的组件才需要变成客户端组件。这个思维转变是 App Router 与传统 React 最大的不同。
不需要任何指令,组件默认就是服务器组件:
// 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 数据获取);文件顶部加 "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> ); }
特性:

三条关键规则:
children 接收服务器组件渲染的结果);| 场景 | 组件类型 |
|---|---|
| 页面内容(静态/数据展示) | 服务器组件 |
| 表单提交处理 | 服务器组件(配合 Server Actions) |
| 计数器/开关/弹窗 | 客户端组件 |
| 需要 localStorage/window | 客户端组件 |
| 数据获取(查库/调 API) | 服务器组件 |
| 动画/第三方交互库 | 客户端组件 |
// ❌ 错误:服务器组件不能使用 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>; }
服务器组件获取数据,作为 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 实例不能直接传。
传统 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 应用的典型形态。
组件边界的判断是 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。