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 里一条消息都没有。

先别往下翻,给两个预测:

  1. 这一页到底是静态的还是动态的?——○ 已经给了答案,但你先想清楚"静态"两个字在服务端和浏览器两个地方分别意味着什么。
  2. 那两次点击的"加一",是谁在算?是服务端重新渲染了一次,还是浏览器本地算的?如果是服务端,它得发起两次网络请求;如果是浏览器,那它算的时候用的是哪份数据?

答案这一章会一步步给。先记住这个组合:页面被判静态,而页面里有一个能点、能变、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,到浏览器才水合。

拆成三步就是:

  1. 构建期:Next 把客户端组件当成一个普通组件渲染进 HTML。它此刻在 Node 里跑,typeof window 是 undefined,所以计数器只能渲染出初始的 点了加一: 0。
  2. 发包:这份 HTML 作为静态产物直接回放(x-nextjs-cache: HIT),客户端组件的 JS 另走 static/chunks/*.js(E5 量到客户端 JS 共 10 个 chunk / 590069 B,gzip 后 180991 B)。
  3. 浏览器里: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。也就是说——

它报的是 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'
}

这是本章最重要的一段。 同一条错误、两种命运:

这一点工程含义很大:在你看到 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)

拆开看:

这条报错里,完全没有"你把 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

两条错误的发生阶段完全不同:一个是"编译都过了,栽在预渲染";一个是"编译期直接把打包器打崩"。你要排查的时候,第一步不是读错误文本,而是先看它死在哪个阶段。

五、边界上到底能传什么

把上面两条注入能推出来的规则收一下。注意——这条规则我只写到"实测支持"的边界,推不出来的都留给 ## 生产边界。

给一条能随身带的判断句式:问"这个东西能不能被序列化之后一模一样地还原",能,就大概率能跨;不能,就大概率不行。 但这个句式是从实测归纳的启发式,不是规范——它的边界(比如类实例、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,且完全不给指引)。四条出路里只有两条是绿的,另外两条都在不同的阶段炸。

生产边界

动手:可观察结果

自己复现本章的几个判据。每一步都要拿到命令输出,不能凭记忆。

产出 判断标准
一份服务端组件的运行位置证明 你能在服务端组件里同时读到 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 报错关键词去搜它——你会搜不到东西,因为这条报错根本不用那套词。这一课值得自己吃一遍:同一个"我在客户端里用了服务端的东西",换一代打包器,报错的措辞和阶段全变了。

自测题

  1. /client-boundary 被判 ○ Static,可它页面里的按钮点了会从 点了加一: 0 变成 点了加一: 2,而且 console 消息数: 0。请分别解释"静态"和"能点"各指什么,并说明那两次"加一"是谁算的。
  2. 服务端组件里同一句代码读出了 typeof window = undefined 和 process.pid = 32288。这两个值合起来能证明什么?换成在浏览器里读,这两个值会变成什么?
  3. 你写了个函数 prop 递给客户端组件,next build 报错了,但报错出现在 ✓ Compiled successfully 之后。请说明这是哪一类错误,以及为什么"编译过了"不能当作"这段代码没问题"。
  4. 同一条函数 prop 错误,静态页和加了 force-dynamic 的页会有两种命运。请分别说出它们的 digest,并解释为什么动态版在浏览器里"查不动"。
  5. 你在客户端组件里 import fs,得到的不是 Module not found 而是 Turbopack panic。请说出它的原文关键词,并解释为什么拿 webpack 时代的经验去搜会一无所获。
  6. 有人给你一份"能跨边界传的东西"的清单,第一条写的是"只要是数据就能传"。按本章的判据,这句话哪里不严谨?你会怎么把它改成一条可用的判断?
  7. 两条注入(函数、Node 模块)的发生阶段不同。如果让你只保留一个排查动作,你会保留哪个?为什么?

现在能解释什么

下一步:06 章 · 水合:线上为什么一声不响 —— 这一章我们看到客户端组件被水合之后能正常点。但水合失败的时候呢?下一章量一件更反直觉的事:失配真的发生了,DOM 被改了,而线上一条日志都没有。

进入 keel 阅读