KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 服务端/客户端边界:`"use client"` 到底划在哪里 — keel 龙骨
这一章回答:"use client" 划出的那条边界到底在哪、什么能跨过去、跨错了会在哪一步炸——以及为什么一个带按钮、点得动的页面,在构建日志里被判成 ○ Static。
这一章回答:"use client" 划出的那条边界到底在哪、什么能跨过去、跨错了会在哪一步炸——以及为什么一个带按钮、点得动的页面,在构建日志里被判成 ○ Static。
现场:一个点了会动的页面,被判成"静态"
先看 /client-boundary 这一页。它里面挂了一个 "use client" 子组件——一个只有一个按钮的计数器。构建日志里它是这样的:
├ ○ /client-boundary
○ 是 (Static) prerendered as static content。也就是说,Next 认为这一页可以在构建期就定稿。可你打开它、点两下按钮,数字真的变了:
按钮初始文字: 点了加一: 0
点两次后文字: 点了加一: 2
console 消息数: 0
一个"构建期定稿"的页面,凭什么点得动?而且它点完之后 console 里一条消息都没有。
先别往下翻,给两个预测:
- 这一页到底是静态的还是动态的?——
○已经给了答案,但你先想清楚"静态"两个字在服务端和浏览器两个地方分别意味着什么。 - 那两次点击的"加一",是谁在算?是服务端重新渲染了一次,还是浏览器本地算的?如果是服务端,它得发起两次网络请求;如果是浏览器,那它算的时候用的是哪份数据?
答案这一章会一步步给。先记住这个组合:页面被判静态,而页面里有一个能点、能变、console 还干净的客户端组件。 这个组合不是巧合,它正好压在 "use client" 这条边界上。
一、两条运行位置:一边在 Node 里,一边在浏览器里
要理解"点得动"这件事,先得承认一件很简单、但常被忽略的事:同一个页面里的代码,不一定跑在同一个地方。
E7 在 /client-boundary 的服务端组件里同时读了两样东西:
服务端组件里 typeof window = undefined | process.pid = 32288
这两行是同一句代码、同一时刻、同一个进程里读出来的。typeof window 是 undefined——因为这里根本没有 window;但 process.pid 是 32288——因为这里有一个真实的 Node 进程号。这说明服务端组件那段代码确实在 Node 里跑。 如果它跑到浏览器里,process.pid 只会是 undefined,而 typeof window 会是 "object"。
反过来,浏览器里那份代码读不到 process.pid(浏览器没有 process),却能拿到 window、document、localStorage。这是两条完全不同的运行位置,它们看到的"全局环境"是两套。
"use client" 这个指令干的事,就是在模块图里划一条线:线的一边是服务端组件(只在 Node 里跑,代码不进浏览器包),线的另一边是客户端组件(代码会被打成 chunk 送到浏览器,在那里再跑一遍)。所以你会看到 /client-boundary 的产物里既有服务端静态 HTML,也有一份客户端 JS chunk——它同时存在于两个世界。
这里有一条判据值得立刻记下:你说"客户端组件"时,指的是"它的代码需要送到浏览器并在那里执行",不是"它只在浏览器里跑"。 客户端组件的首屏 HTML 恰恰是服务端给的(下一节就量给你看)。这句话是整章的地基,丢了它,后面所有现象都会看歪。
二、有客户端组件的页面,照样是 ○ Static
回到开头那个预测:/client-boundary 是静态的还是动态的?
E1 的路由表给了答案,而且它和 /hydration-mismatch 排在一起:
├ ○ /client-boundary
├ ○ /hydration-mismatch
两个都是 ○。E5 的产物清单里也确实有 server/app/client-boundary.html(8648 B)——这一页在构建期真的被渲染成了一份死 HTML。 E2 的响应头也一致:/client-boundary 第 2 次请求 x-nextjs-cache: HIT、cache-control: s-maxage=31536000,源一次都没被打到(区间 +0)。
所以第一个预测的答案是:它是静态的。 但"静态"只约束了它发给浏览器的第一份 HTML,不约束之后浏览器里发生的事。用 E8 第 7 条的话说:
有客户端组件的页面仍可能是 ○ Static ——
/client-boundary、/hydration-mismatch都是;客户端组件的首屏输出被预渲染进 HTML,代码另走 chunk,到浏览器才水合。
拆成三步就是:
- 构建期:Next 把客户端组件当成一个普通组件渲染进 HTML。它此刻在 Node 里跑,
typeof window是undefined,所以计数器只能渲染出初始的点了加一: 0。 - 发包:这份 HTML 作为静态产物直接回放(
x-nextjs-cache: HIT),客户端组件的 JS 另走static/chunks/*.js(E5 量到客户端 JS 共 10 个 chunk / 590069 B,gzip 后 180991 B)。 - 浏览器里:JS 到了之后,React 把这份 HTML 接上——绑事件、接管状态。这一步叫水合。接上之后,点击才会改变数字。
所以第二个预测的答案也出来了:那两次"加一"是浏览器本地算的,用的是水合时接管的那份状态,它没有回服务端。证据就是 console 消息数: 0——如果是服务端在响应点击,至少网络或 console 里会有动静;这里什么都没有,因为整个交互闭环都在客户端。
E7 的实测数字把这三步串起来了:
按钮初始文字: 点了加一: 0
点两次后文字: 点了加一: 2
console 消息数: 0
从 0 到 2 的两步,发生在页面已经"静态化"之后。这就是"静态页面里活着一块客户端组件"的标准样子:HTML 是死的,水合之后那块变成活的。
(顺带把第一个预测收干净:判定页面 ○ / ƒ 的分水岭是"渲染时有没有碰请求",不是"有没有客户端组件"。见 02 章。)
三、故障注入 A:把函数当 prop 传给客户端组件 —— 这是预渲染错误,不是编译错误
上面都是"该怎么用"。现在开始跨错。
最经典的跨错,是把一个函数从服务端组件当 prop 递给客户端组件。这是 React Server Components 的硬约束,但它炸出来的样子很值得单独讲——因为它炸在预渲染阶段,不炸在编译阶段。
E6a 记下了完整的构建输出。注意它的顺序:
✓ Compiled successfully in 1560ms
Collecting page data using 16 workers ...
Generating static pages using 16 workers (0/12) ...
Error occurred prerendering page "/fault-fn-prop". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Event handlers cannot be passed to Client Component props.
{onPick: function onPick}
^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
at ignore-listed frames {
digest: '4218742025'
}
Export encountered an error on /fault-fn-prop/page: /fault-fn-prop, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
最关键的区分在这里:第一行是 ✓ Compiled successfully in 1560ms。也就是说——
- 你的 TS/JS 全过了类型检查;
- 整个打包也成功了;
- 死掉的地方是静态页的生成(
Generating static pages using 16 workers (0/12))。
它报的是 Error occurred prerendering page,不是编译错误。所以你如果是靠"编译过没过"来当红灯,这个错你根本挡不住——它会一路走到"生成静态页"这一步才炸。
这条错误的文案本身也几乎在明说发生了什么:
Event handlers cannot be passed to Client Component props.
If you need interactivity, consider converting part of this to a Client Component.
翻译过来就一句:事件处理函数不能当 prop 传给客户端组件。 原因是显而易见的——函数不是可序列化的数据,它抓不住、也送不过那条边界。({onPick: function onPick} 下面那串 ^^^^^^^^^^^^^^ 指的就是那个函数值本身。)
同一条错误,两种命运
把上一条错误放在静态页上,它死在构建期。现在加一行:
export const dynamic = "force-dynamic";
同一份代码、同一个函数 prop,构建通过了——该路由从 ○ 变成 ƒ(server-rendered on demand)。但错误没有消失,它只是被推到了请求时:
=== 请求 /fault-fn-prop ===
HTTP 500 6522 B
响应体开头: <!DOCTYPE html><html id="__next_error__">...
响应体里只含 digest,不含错误消息
真正的原文——跟静态版一字不差的那段——出现在服务端日志(out-server2.log)里,只是 digest 换了:
⨯ Error: Event handlers cannot be passed to Client Component props.
{onPick: function onPick}
^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
at ignore-listed frames {
digest: '1496312302'
}
这是本章最重要的一段。 同一条错误、两种命运:
- 静态页(默认):构建期就被拦下(digest
4218742025),CI 里就该红。 - 动态页(
force-dynamic):构建通过,一路走到线上,请求时才 500(digest1496312302)。 - 而且浏览器只拿到
<!DOCTYPE html><html id="__next_error__">和那个 digest(1496312302)——拿不到错误的文字。原文只在服务端日志里。
这一点工程含义很大:在你看到 500 的那个浏览器里,是拼不出原因的。 你手上只有一个 digest,想定位必须回到服务端日志去按 digest 搜。这也解释了"为什么线上报错总是查不动"——因为报错文本压根没出服务器。
一个立刻可用的判断:静态页上的边界错误会在构建期拦住你,动态页上的同一错误会在请求时变成 500。 这两条路里,"加 force-dynamic"看着像是"修好了",其实是把一个可复现的构建错误,藏成了一个只在请求时才现形的 500。
四、故障注入 B:在客户端组件里 import fs from "node:fs" —— Turbopack 直接 panic
第二种跨错更直接:你在一个 "use client" 文件里引了一个 Node 内置模块。你以为会得到"模块找不到",实际得到的是打包器 panic。
E6b 的原话:
-----
FATAL: An unexpected Turbopack error occurred. A panic log has been written to
C:\Users\...\Temp\next-panic-5f6f89d0942b753a17890a058411639f.log.
...
> Build error occurred
Error [TurbopackInternalError]: Failed to write app endpoint /fault-node-fs/page
Caused by:
- the chunking context (unknown) does not support external modules (request: node:fs)
拆开看:
- 它不是
Module not found,而是Failed to write app endpoint /fault-node-fs/page; - 真正的因由是
the chunking context (unknown) does not support external modules (request: node:fs); - 它甚至写了一个 panic log 到
next-panic-5f6f89d0942b753a17890a058411639f.log; - panic 上报体里还写着
Turbopack version: b0fad0d4和Next.js version: 0.0.0——后者是它自己的上报字段 bug,不是你项目的版本。
这条报错里,完全没有"你把 Node 模块带进客户端了"这类指引。 它只说 does not support external modules。这就是为什么拿 webpack 时代的经验来搜搜不到——webpack 那套报错模板和这个词完全对不上。你得先知道"客户端 bundle 里不能出现 Node 内置模块"这条约束,才能把 node:fs 和 external modules 这两个词接起来。
把这两条注入并排看,边界的样子就清楚了:
| 你跨边界传了什么 | 谁先发现 | 报错长什么样 | 关键判据 |
|---|---|---|---|
一个函数(onPick) |
预渲染 / 运行时 | Event handlers cannot be passed to Client Component props. |
Compiled successfully 之后才报;静态页 digest 4218742025,动态页 1496312302 |
一个 Node 内置模块(node:fs) |
打包器 | Turbopack panic:does not support external modules |
连编译都过不去,直接 panic |
两条错误的发生阶段完全不同:一个是"编译都过了,栽在预渲染";一个是"编译期直接把打包器打崩"。你要排查的时候,第一步不是读错误文本,而是先看它死在哪个阶段。
五、边界上到底能传什么
把上面两条注入能推出来的规则收一下。注意——这条规则我只写到"实测支持"的边界,推不出来的都留给 ## 生产边界。
- 能跨的:可序列化的数据。
/client-boundary的按钮文字就是证据——点了加一: 0到点了加一: 2,跨过去的是数字与字符串这类可以直接变成 HTML、又能在浏览器里被还原的东西。它在服务端渲染成 HTML、在浏览器里被水合接管,两边对得上。 - 不能跨的:函数。
onPick的报错就是判据。函数不是可序列化的数据,它带着"运行位置"和"闭包环境",没法被写进 HTML,也没法被JSON.stringify。 - 不能跨的:Node 内置模块。
node:fs的 panic 是判据。客户端 chunk 要送到浏览器,而浏览器里没有fs。它不是"运行时找不到",是打包阶段就不认。
给一条能随身带的判断句式:问"这个东西能不能被序列化之后一模一样地还原",能,就大概率能跨;不能,就大概率不行。 但这个句式是从实测归纳的启发式,不是规范——它的边界(比如类实例、Date 对象、Map、Promise 到底哪几类能跨)本实验台没有逐类去测,别把它当保证用。
本章脉络
flowchart TD
A["实验台 12 条路由"] --> B["构建期判定:○ Static 还是 ƒ Dynamic"]
B --> C["/client-boundary ○ Static"]
C --> D["服务端组件在 Node 里跑<br/>typeof window = undefined<br/>process.pid = 32288"]
C --> E["客户端组件首屏被预渲染进 HTML<br/>代码另走 static/chunks"]
E --> F["浏览器水合后点两次<br/>点了加一: 0 变成点了加一: 2<br/>console 0 条"]
A --> G["跨错 A:把 onPick 函数当 prop 传"]
G -->|"静态页"| H["Compiled successfully 之后才报<br/>预渲染错误 digest 4218742025"]
G -->|"加 force-dynamic"| I["构建通过 路由变 ƒ<br/>请求时 HTTP 500 只拿到 digest 1496312302"]
A --> J["跨错 B:客户端里 import node:fs"]
J --> K["Turbopack panic<br/>does not support external modules"]
K --> L["报错里没有『把 Node 模块带进客户端』的指引"]
图里在说什么。 顶上那行是前提:一页会不会在构建期定稿,只由"渲染时碰没碰请求"决定,跟有没有客户端组件无关。左边一支是"边界用对了":服务端组件在 Node 里跑(能量到 process.pid),客户端组件首屏被一起预渲染进 HTML,代码另走 chunk,水合之后才活起来,点击闭环全在浏览器(console 干净)。右边两支是"跨错了":函数 prop 死在预渲染(静态页构建即红,动态页拖到请求时 500、且浏览器只拿 digest);Node 内置模块死在打包(Turbopack 直接 panic,且完全不给指引)。四条出路里只有两条是绿的,另外两条都在不同的阶段炸。
生产边界
- 本课的坐标是 Next.js 16.3.8(Turbopack 默认)+ react 19.3.0 + Node 22.22.2,平台 Windows(win32)。 文里所有 digest、
process.pid、字节数都是这个组合上的实跑值。换版本请重新量。 process.pid = 32288是那一刻那个进程的号,不是常量。 它证明的是"服务端组件在 Node 里跑"这件事,不是某个固定数字;在你机器上必然不同。- 同一份代码"加
force-dynamic就构建通过"这个对照,只在这套环境上跑过。 结论的方向(静态页构建期拦截、动态页推到请求时)我按实测写;但"别的框架或版本也一定如此"未验证。 - 边界上"能传什么",我只写到了三类:可序列化数据(能)、函数(不能)、Node 内置模块(不能)。 类实例、
Date、Map/Set、Promise 等类型本实验台未逐个测,别把第五节那个判断句式当规范用。官方对"可序列化 prop"的完整列表,请以你所用版本的文档为准。 "use client"对 import 下游的传播范围(下游模块是否整条链进客户端包)本实验台未单独测。 本章只测了"某个文件标了"use client"、它渲染出一个可交互组件"这一种形状。- panic 上报体里的
Next.js version: 0.0.0是 Turbopack 上报字段自己的 bug,不代表你的项目版本;别拿它去判断问题。 - 动态页 500 响应体里"只含 digest、不含消息"这条,是在默认生产配置下量的;如果你改了错误处理或加了自定义 error 边界,形状可能不同,未验证。
动手:可观察结果
自己复现本章的几个判据。每一步都要拿到命令输出,不能凭记忆。
| 产出 | 判断标准 |
|---|---|
| 一份服务端组件的运行位置证明 | 你能在服务端组件里同时读到 typeof window === "undefined" 与一个真实的 process.pid |
一条 /client-boundary 式路由的构建判定 |
构建日志里它带 ○,同时产物里能看到它的 .html,并且页面里那块客户端组件能点、能变、console 干净 |
| 一次"函数当 prop"的静态页失败 | 你看到 ✓ Compiled successfully 之后才出现 prerendering page 报错,并记下它的 digest |
| 同一次错误的动态页版本 | 加 force-dynamic 后构建通过(路由变 ƒ),请求时拿到 HTTP 500,响应体里只有 <html id="__next_error__"> 和一个 digest |
一次 node:fs 的 panic |
你看到 does not support external modules,并确认报错里没有任何"你把 Node 模块放进了客户端"的提示 |
完成标志:面对一条边界报错,你的第一个问题不是"它说什么",而是"它死在哪个阶段——编译、预渲染,还是请求时"。能分清这三段,你才知道该去翻构建日志、还是去翻服务端运行日志。
故障注入
下面每一步都是你自己能跑的。先写下预测,再跑,把预测和观察对齐。
| 注入 | 怎么做 | 观察什么 | 期望现象 |
|---|---|---|---|
| 函数当 prop(静态页) | 在服务端组件里写 onPick={() => {}},递给一个 "use client" 子组件,直接 next build |
报错出现在构建流程的哪一步 | 先出 ✓ Compiled successfully,之后才报 Event handlers cannot be passed to Client Component props.,带 digest(实测 4218742025)——它是预渲染错误,不是编译错误 |
| 同一条,改动态 | 给该路由加 export const dynamic = "force-dynamic",再 next build 再 next start |
构建过不过、请求返回什么 | 构建通过(路由变 ƒ);请求拿到 HTTP 500、响应体 <html id="__next_error__">、只有 digest(实测 1496312302),错误原文只在服务端日志 |
| Node 内置模块 | 在 "use client" 文件里加 import fs from "node:fs",next build |
打包器怎么反应 | Turbopack panic:does not support external modules (request: node:fs),并写出一个 panic log;报错里没有任何"放错位置"的指引 |
| 把可交互部分换个位置写 | 把那个可交互部分挪进一个不标 "use client" 的组件,仍然传函数 |
报错有没有变 | 仍然是同一条"事件处理函数不能跨"——边界是按模块图的"哪一侧"划的,不是按你把它写在哪一层 |
| 把 Node 模块换个名字引 | 把 node:fs 换成 fs(不带 node: 前缀),再 next build |
panic 的措辞有没有变 | 仍然指向同一个因由(external modules);前缀不是关键,"它是不是 Node 内置模块"才是 |
第三条跑完别急着删。把那句 import fs 留着,试着用你熟悉的 webpack 报错关键词去搜它——你会搜不到东西,因为这条报错根本不用那套词。这一课值得自己吃一遍:同一个"我在客户端里用了服务端的东西",换一代打包器,报错的措辞和阶段全变了。
自测题
/client-boundary被判○ Static,可它页面里的按钮点了会从点了加一: 0变成点了加一: 2,而且console 消息数: 0。请分别解释"静态"和"能点"各指什么,并说明那两次"加一"是谁算的。- 服务端组件里同一句代码读出了
typeof window = undefined和process.pid = 32288。这两个值合起来能证明什么?换成在浏览器里读,这两个值会变成什么? - 你写了个函数 prop 递给客户端组件,
next build报错了,但报错出现在✓ Compiled successfully之后。请说明这是哪一类错误,以及为什么"编译过了"不能当作"这段代码没问题"。 - 同一条函数 prop 错误,静态页和加了
force-dynamic的页会有两种命运。请分别说出它们的 digest,并解释为什么动态版在浏览器里"查不动"。 - 你在客户端组件里
import fs,得到的不是Module not found而是 Turbopack panic。请说出它的原文关键词,并解释为什么拿 webpack 时代的经验去搜会一无所获。 - 有人给你一份"能跨边界传的东西"的清单,第一条写的是"只要是数据就能传"。按本章的判据,这句话哪里不严谨?你会怎么把它改成一条可用的判断?
- 两条注入(函数、Node 模块)的发生阶段不同。如果让你只保留一个排查动作,你会保留哪个?为什么?
现在能解释什么
- 为什么一个页面带客户端组件、却仍然是
○ Static——判定看的是"渲染时碰没碰请求",不是"有没有客户端代码";客户端组件的首屏输出照样被预渲染进 HTML; - 为什么"构建期定稿的页面"点得动——HTML 是死的,客户端 chunk 到浏览器水合之后那块才活,点击闭环全在客户端,所以 console 干净;
- 为什么
typeof window是undefined那一刻还能读到process.pid = 32288——服务端组件确实跑在 Node 里,它和浏览器看到的是两套全局环境; - 为什么"把函数当 prop"是预渲染错误不是编译错误——它死在
Compiled successfully之后,静态页构建即拦、动态页拖到请求时 500,而浏览器只拿到一个 digest; - 为什么"加
force-dynamic"不是修好,而是把构建期的红灯挪成了一枚只在请求时引爆的雷; - 为什么客户端里
import fs会 panic 且报错毫不友好——边界是打包阶段就执行的硬约束,而 Turbopack 的措辞(does not support external modules)和 webpack 时代完全对不上。
下一步:06 章 · 水合:线上为什么一声不响 —— 这一章我们看到客户端组件被水合之后能正常点。但水合失败的时候呢?下一章量一件更反直觉的事:失配真的发生了,DOM 被改了,而线上一条日志都没有。