Chapter 02
服务端组件与 'use client' 边界
上一章把 URL 拆成了文件夹树:每个段由 page 渲染、由 layout 包裹。但有个问题悬而未决——这些组件到底在哪台机器上运行?这一章给出 App Router 最反直觉、也最核心的答案,整份教程的其余部分都从这里推导出来。
本章你将建立的 schema
- Server Component 是默认;Client Component 要靠
'use client'显式声明 'use client'不是"这个组件跑在客户端",而是一条网络边界——它以下的整棵子树都进客户端 bundle- 服务端发给浏览器的是 RSC payload(一段 UI 描述),不是 HTML;这解释了 props 为什么必须可序列化、函数为什么不能跨界
- 组合模式:用
children把服务端内容"插进"客户端组件留出的洞
2.1两种组件,默认是服务端那种
App Router 里的组件默认在服务器上运行、不进浏览器 bundle;要让一个组件在浏览器里带状态和事件,必须用 'use client' 把它显式标成 Client Component。
纯客户端的 React(SPA)有两个绕不开的代价:所有组件代码都要打进 bundle 下载执行,首屏要等 JS 跑完才出内容;想读数据库得先开一个 API 端点,再让浏览器绕一圈去 fetch。Server Component 把"只负责展示、不需要交互"的那部分组件留在服务器:它们不进 bundle(省下载),能直接 await 数据源(省一次往返)。代价是它们没有浏览器运行时——没有 useState、没有 onClick、没有 window。
对一个 React 开发者,最别扭的一点是:你写的组件长得和以前一模一样,但默认行为反了。以前所有组件都在浏览器跑,现在默认都在服务器跑。下面两个组件放在一起,差别只有顶部那一行。
// 没有 'use client',所以这是 Server Component(默认)
import { db } from '@/lib/db'
import { AddToCart } from './add-to-cart'
export default async function ProductPage(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params // 16 起 params 是 Promise,要 await
const product = await db.product.find(id) // 直接读数据库,没有 API 中转
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 一个客户端组件,嵌在服务端组件里 */}
<AddToCart productId={product.id} />
</main>
)
}
'use client' // ← 这一行就是边界
import { useState } from 'react'
export function AddToCart({ productId }: { productId: string }) {
const [count, setCount] = useState(1) // 状态:只有客户端组件能有
return (
<button onClick={() => setCount(count + 1)}> {/* 事件:同上 */}
加入购物车({count} 件)
</button>
)
}
page.tsx没有 'use client',是 Server Component:它 async、能 await 数据库,但整段代码不会进浏览器。用户拿到的是已经渲染好的结果,看不到 db 的任何痕迹。
add-to-cart.tsx第一行 'use client' 让它成为 Client Component:它能用 useState、能绑 onClick,代价是这段代码会被打进 bundle 发到浏览器。
从 SPA 过来,本能是"组件都在浏览器,要服务端能力才特殊处理"。App Router 把它倒过来:默认在服务器,要浏览器能力(状态 / 事件 / 浏览器 API)才用 'use client' 升级。这个倒置是后面一切的根。
2.2'use client' 是一条边界,不是一个标签
'use client' 标记的不是"这一个组件在客户端",而是一条边界:从这个文件往下,被它直接或间接 import 的所有组件,整棵子树都会进客户端 bundle。
这是最常被误读的一点。很多人以为 'use client' 是给单个组件贴的"这个组件在客户端"标签,于是哪个组件要交互就给哪个加一行。真实语义是:'use client' 声明的是一个进入点。一旦某个模块标了它,这个模块、以及它 import 进来的子组件,全部落到客户端这一侧——哪怕子组件自己一行 'use client' 都没写。
'use client' 标在 <AddToCart> 上。注意:边界以下的 <Counter> 自己没写任何指令,却同样进了客户端 bundle——因为它被 island 内的组件 import 了。边界是"从这里往下",不是"就这一个"。你有一个 <Markdown> 组件,纯展示、无交互,体积不小(带了语法高亮库)。你把它 import 进一个 'use client' 的 <CommentBox> 里直接用。<Markdown> 和它那个高亮库,会进客户端 bundle 吗?
展开答案(先停 10 秒)
会。<CommentBox> 是客户端进入点,它 import 的 <Markdown> 落在边界以下,于是 <Markdown> 连同那个高亮库都被打进 bundle 下载到浏览器——即使它一行交互代码都没有。
这正是"边界最小化"重要的原因:边界往上挪一层,可能就把一整个重型展示库拖进了客户端。§2.5 的组合模式就是用来避免这件事的。
2.3服务端发的不是 HTML,是 UI 描述
Server Component 渲染后,服务端发给浏览器的是一段 RSC payload——一棵 UI 树的序列化描述,其中 Client Component 只以"模块引用"的形式出现,不是渲染好的 HTML。
如果服务端发的是死的 HTML 字符串,那导航到下一页时就只能整页替换,客户端那些已经存在的状态(输入框里打了一半的字、展开的菜单)会全丢。React 团队要的是:服务端能渲染,但导航时新内容要能融进当前还活着的客户端组件树,而不是推倒重来。HTML 做不到这件事——HTML 没有"这是一棵可被 reconcile 的 React 树"这层信息。所以 RSC 不发 HTML,发一种叫 Flight 的格式:一行行的 UI 描述。
Flight payload 长这样(结构示意,具体字节随版本变化):
0:["$","main",null,{"children":[
["$","h1",null,{"children":"机械键盘"}],
["$","$L1",null,{"productId":"42"}] // ← $L1 指向第 1 行
]}]
1:I["./add-to-cart.tsx",["chunk-9f.js"],"AddToCart"] // I = 模块引用
第 0 行是 UI 树本身:main 里有个 h1(服务端已经渲染成最终内容"机械键盘"),还有个 $L1——一个指向第 1 行的引用占位。
第 1 行以 I 开头,是一个模块引用:它不包含 AddToCart 的渲染结果,只告诉浏览器"去 chunk-9f.js 里加载名为 AddToCart 的组件,props 是 {productId:"42"}"。客户端组件在 payload 里永远是这种"去哪取、给什么 props"的引用,不是 HTML。
2.4边界的两个硬后果:可序列化 与 函数不能跨界
边界本质是一条网络边界——payload 要经网络传输。所以从 Server 传给 Client 的 props 必须能序列化,而函数、类实例这类不能序列化的东西,传不过去。
一旦接受"'use client' 是网络边界",两条看似奇怪的规则就变成必然:
| 从 Server 传给 Client 的 prop | 能否跨界 | 原因 |
|---|---|---|
| 字符串、数字、布尔、null | 能 | 可直接序列化 |
| 普通对象、数组、Promise | 能 | 可序列化(Promise 会被流式传输) |
| 已渲染的 JSX(如 children) | 能 | 它是 UI 描述,正是 payload 的内容 |
| 函数 / 事件处理器 | 不能 | 函数无法序列化过网络;唯一例外是 Server Action(见 06 章) |
类实例(如 new Date() 之外的自定义类)、Symbol | 不能 | 序列化后丢失原型与行为 |
把一个回调函数从 Server Component 传给 Client Component:<Client onSave={() => save()} />,其中外层是 Server Component。运行时会报错,提示函数无法作为 prop 传递。根因不是"框架挑剔",而是这个函数要跨网络边界——它序列化不了。正确做法:要么把需要回调的逻辑移进 Client 一侧,要么用 Server Action(一种被特殊编译成可跨界 RPC 的函数)。
一个 Server Component 里 const now = new Date(),然后 <Clock time={now} /> 传给 Client。now 能传过去吗?传过去之后客户端拿到的是什么?
展开答案(先停 10 秒)
能传——Date 是 React 支持序列化的内置类型之一,客户端会拿到一个等价的 Date 实例。但要注意:它是"渲染那一刻服务端的时间",被冻结进了 payload。客户端不会让它自己走动;想要走动的时钟,得在 Client Component 里用 useEffect + setInterval 自己更新。这也呼应了 03 章会讲的:服务端渲染的是某一时刻的快照。
2.5组合模式:把服务端内容插进客户端的洞
客户端组件不能 import 服务端组件(会把后者拖进 bundle),但可以通过 children(或任意 JSX prop)接收一段已在服务端渲染好的内容,把它"插进"自己留的洞里。
回到 §2.2 的难题:边界往上挪会把重型展示组件拖进客户端。但有时你确实需要一个客户端外壳(比如一个可折叠面板,需要 useState 控制展开),里面装的却是需要读数据库的服务端内容。直接 import 行不通——会把服务端内容拖到客户端,而且客户端根本读不了数据库。出路是把控制权倒过来:客户端组件留一个 children 洞,由服务端父组件决定往洞里填什么。
// panel.tsx —— 客户端外壳:只管交互,不关心里面装什么
'use client'
import { useState } from 'react'
export function Panel({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<section>
<button onClick={() => setOpen(!open)}>{open ? '收起' : '展开'}</button>
{open && children} {/* children 是服务端已渲染好的 UI 描述 */}
</section>
)
}
// page.tsx —— 服务端父组件:决定往洞里填什么
import { Panel } from './panel'
import { db } from '@/lib/db'
export default async function Page() {
const rows = await db.report.recent() // 仍在服务端读数据库
return (
<Panel> {/* Panel 是客户端 */}
<Report rows={rows} /> {/* Report 仍是服务端组件! */}
</Panel>
)
}
关键差别在 import 方向。Panel 不 import Report,它只声明一个 children 类型的洞。是服务端的 page.tsx 把渲染好的 <Report> 作为 children 传进去。于是 Report 留在服务端渲染,只有它的结果(UI 描述)跨界进了 Panel 的洞。"客户端组件不能用服务端组件"是错的;准确说法是"客户端组件不能 import 服务端组件,但可以 接收 它"。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 一个组件文件里没有
'use client',但它被一个写了'use client'的组件 import 进来。它在哪运行?为什么? - 为什么不能把一个事件处理函数(给
onClick用的那种)作为 prop 从 Server Component 传给 Client Component? - Server Component 渲染后发给浏览器的是 HTML 吗?如果不是,是什么?这么设计换来了什么 HTML 给不了的能力?
- (设计层)一个页面 90% 是静态展示,只有一个"收藏"按钮要交互。按边界最小化原则,
'use client'应该写在哪个层级?写错会有什么代价?
答案(先做完再展开)
- 客户端。
'use client'是进入点,它 import 的子组件全部落到边界以下,进客户端 bundle——哪怕子组件自己没写指令。运行位置由"谁 import 它"决定,不由它自己声明。 - 因为这条边界是网络边界,props 要序列化后经网络传给浏览器,而函数序列化不了。唯一的例外是 Server Action:它被编译成一个可跨界调用的 RPC 引用(见 06 章)。
- 不是 HTML,是 RSC payload(Flight)——一棵 UI 树的序列化描述,里面客户端组件只是"模块引用"。因为它是 UI 描述而非死 HTML,客户端导航时能把新内容 reconcile 进当前还活着的组件树,保留已有状态,而不是整页刷新。
- 写在那个"收藏"按钮自己身上(最叶子的位置)。这样只有按钮那一小段进客户端 bundle,其余 90% 留在服务端、不下载。写错(比如写在整个页面的顶层)会把整页所有展示组件和它们的依赖库都拖进客户端 bundle,首屏变慢、白白下载一堆永不交互的代码。
客户端外壳 + 服务端数据,但更刁钻一点
你要做一个 <Tabs> 组件:标签切换是交互(要 'use client'),但每个标签页的内容各自需要读不同的数据库表。如果用 <Tabs> 包 children,所有标签内容会被一次性全渲染。怎么设计,才能既让标签切换在客户端、又让每个标签页的服务端内容按需出现,而不是一次全查?
提示(卡住再展开)
想想"洞"不一定只有一个。<Tabs> 可以接收一个以 JSX 为值的 props 数组 / 对象(比如 tabs={[{label, content: <ServerThing/>}]})。但"一次全查"的根因是服务端父组件在渲染时就 await 了所有表——真正要引入的是 03 章的 <Suspense> 与流式渲染,让每个标签内容独立加载。这章先记住组合,下一章给你按需的工具。