- 发布日期
第 07 讲|Server / Client Components 的边界
Server / Client Components 边界的实现原理、序列化约束与最佳实践
阶段二:App Router 核心机制 · 第 7 / 40 讲 难度:⭐⭐⭐⭐⭐ · 预计耗时:3.5 小时 配套 fixture:
fixtures/lecture-07/
学习目标
学完本讲,你应当能:
- 默写
'use client'在 SWC transform → next-flight-loader → flight-client-entry-plugin 三层的产物。 - 解释 Next.js "双 module graph"(server graph / client graph)的物理含义。
- 用 webpack
layer这个术语描述每条 import 关系(rsc→ssr→app-pages-browser)。 - 在 fixture 里能跑通:Context Provider、
useStateHook、第三方 client-only 包、props 序列化失败等 5 种典型边界场景。 - 看到 hydration mismatch /
window is not defined/Module not found等错误时立刻知道是哪一层的问题。
1. 前置:React Server Components 的两层 module graph
RSC 不是"在服务端渲染 React"那么简单。它是一种模块系统:
┌──────────────────────┐
│ server graph (rsc) │ ← Server Components 在这里
│ ──────────────── │
│ - 不可有 useState │
│ - 可以 fetch │
│ - 可以 import 任何 │
│ server-only 代码 │
└─────┬────────────────┘
│ 遇到 'use client'
│ (跨边界引用)
▼
┌──────────────────────┐
│ client graph (ssr + │
│ app-pages-browser) │ ← Client Components 在这里
│ ───────────────── │
│ - 可以 useState 等 │
│ - 不能 import server-only │
│ - 在 server 与 client 都跑│
└──────────────────────┘
关键认知:
- 同一个 import 在两张图里被解析成不同的东西——这就是 React 19 引入的 "exports conditions"。例如
react在 server graph 用的是react.shared-subset,在 client graph 用完整版。 - 跨图引用必须经过显式边界:用
'use client'标记某文件,server graph 才能引用它(但只引用元数据,不引用实现)。 - server graph 是单向的:你可以 server → client,不能 client → server(除了 Server Actions,那是一种 RPC)。
Next.js 给每条物理 import 起了层名(layer)。这些层定义在:
reactServerComponents: 'rsc',
/**
* Server Side Rendering layer for app (ssr).
*/
serverSideRendering: 'ssr',
/**
* The browser client bundle layer for actions.
*/
actionBrowser: 'action-browser',
/**
* The Node.js bundle layer for the API routes.
*/
apiNode: 'api-node',
/**
* The Edge Lite bundle layer for the API routes.
*/
apiEdge: 'api-edge',
/**
* The layer for the middleware code.
*/
middleware: 'middleware',
/**
* The layer for the instrumentation hooks.
*/
instrument: 'instrument',
/**
* The layer for assets on the edge.
*/
edgeAsset: 'edge-asset',
/**
* The browser client bundle layer for App directory.
*/
appPagesBrowser: 'app-pages-browser',
/**
* The browser client bundle layer for Pages directory.
*/
pagesDirBrowser: 'pages-dir-browser',
记忆这 5 个最常用的:
| Layer | 谁在跑 | 跑在哪 |
|---|---|---|
rsc | Server Components | server (Node / Edge) |
ssr | Client Components 的"服务器影分身" | server (SSR pass) |
app-pages-browser | Client Components | browser |
action-browser | Server Actions 客户端引用 | browser → 转发到 server |
middleware | middleware.ts | Edge sandbox |
一个 'use client' 组件实际同时存在于 ssr 和 app-pages-browser 两层:第一次访问时 server 端 SSR 跑 ssr 版本生成 HTML;客户端 hydrate 时跑 app-pages-browser 版本。
这就是为什么"client component 也会在服务端执行"——它有两份 bundle,server 跑的那份是为了 SSR。
2. 'use client' 在编译期发生了什么
2.1 第一步:SWC parse 找指令
packages/next/crates/next-custom-transforms/src/transforms/react_server_components.rs 里有一个 visitor 专门扫源码顶部的 "use client" / "use server" directive:
crates/next-custom-transforms/src/transforms/react_server_components.rs
它做的事:
- 检查指令是否出现在文件最顶部(comments / imports 之前——React 协议要求);
- 给模块打标记:
isClientEntry/isServerEntry; - 注入一些 metadata 注释(如
// __client__:hello.js#default,foo),供后续 webpack loader 读取。
如果你查 source code 里 SWC transform 后的中间产物,会在文件尾看到注释类似:
/* __next_internal_client_entry_do_not_use__ default,Counter cjs */
这是给后续 loader 用的 edge-band 标记——既不会出现在用户代码视野,又能被 loader regex 捕获。
2.2 第二步:next-flight-loader 把 client module "替换"成 proxy
webpack 在加载 'use client' 文件时,会用 next-flight-loader 处理它。在 server graph 中,文件实际被替换为 proxy module:
export default function transformSource(
this: LoaderContext<undefined>,
source: string,
sourceMap: any
) {
// Avoid buffer to be consumed
if (typeof source !== 'string') {
throw new Error('Expected source to have been transformed to a string.')
}
const module = this._module!
// Assign the RSC meta information to buildInfo.
// Exclude next internal files which are not marked as client files
const buildInfo = getModuleBuildInfo(module)
buildInfo.rsc = getRSCModuleInformation(source, true)
let prefix = ''
if (process.env.BUILTIN_FLIGHT_CLIENT_ENTRY_PLUGIN) {
const rscModuleInformationJson = JSON.stringify(buildInfo.rsc)
prefix = `/* __rspack_internal_rsc_module_information_do_not_use__ ${rscModuleInformationJson} */\n`
source = prefix + source
}
prefix += `// This file is generated by the Webpack next-flight-loader.\n`
// Resource key is the unique identifier for the resource. When RSC renders
// a client module, that key is used to identify that module across all compiler
// layers.
//
// Usually it's the module's file path + the export name (e.g. `foo.js#bar`).
// But with Barrel Optimizations, one file can be splitted into multiple modules,
// so when you import `foo.js#bar` and `foo.js#baz`, they are actually different
// "foo.js" being created by the Barrel Loader (one only exports `bar`, the other
// only exports `baz`).
//
// Because of that, we must add another query param to the resource key to
// differentiate them.
let resourceKey: string = this.resourcePath
if (module.matchResource?.startsWith(BARREL_OPTIMIZATION_PREFIX)) {
resourceKey = formatBarrelOptimizedResource(
resourceKey,
module.matchResource
)
}
// A client boundary.
if (buildInfo.rsc?.type === RSC_MODULE_TYPES.client) {
const assumedSourceType = getAssumedSourceType(
module,
sourceTypeFromModule(module)
)
在 rsc layer,loader 把整个 client 模块替换成调用 createProxy(resourceKey) 的代码——proxy 来自 react-server-dom-webpack:
/* eslint-disable import/no-extraneous-dependencies */
import { createClientModuleProxy } from 'react-server-dom-webpack/server'
// Re-assign to make it typed.
export const createProxy: (moduleId: string) => any = createClientModuleProxy
Proxy 的本质:调用任何 export 时返回一个特殊对象 { $$typeof: react.client.reference, $$id: '<resourceKey>#exportName' }。这个对象不是真正的组件实现,而是**"占位符 + ID"**——server 渲染时遇到它就在 RSC payload 写出 $L<id> 引用。
判定函数最终长这样:
export function isClientReference(mod: any): boolean {
const defaultExport = mod?.default || mod
return defaultExport?.$$typeof === Symbol.for('react.client.reference')
}
2.3 第三步:flight-client-entry-plugin 把 client 模块拉成入口
'use client' 不只是在 server graph 里被代理;它还需要作为单独的入口被打到 client graph,否则浏览器拿到 $L<id> 引用后无从加载真正实现。
flight-client-entry-plugin 做这件事:
src/build/webpack/plugins/flight-client-entry-plugin.ts
它的工作:
- 扫所有 page 的 server graph,收集所有 client 边界(client reference id)。
- 对每个 client 边界,在 client compiler 里注册一个新的入口(叫
app-pages-browserlayer 的入口)。 - 把这些边界写进
client-reference-manifest(详见上一讲)。 - 同时收集所有
'use server'actions,写进server-reference-manifest。
2.4 整体流程图
你的代码 hello-button.tsx
↓ SWC parse
检测到 'use client' → metadata 注入
↓
┌─────────────────────────┬─────────────────────────┐
▼ rsc layer ▼ ssr layer ▼ app-pages-browser layer
next-flight-loader 原样保留 原样保留
替换为 createProxy (SSR 跑这份) (hydration 跑这份)
↓ ↓ ↓
server graph 中: server graph 中: client bundle 中:
import HelloButton import HelloButton 作为新的 entry
=> { $$typeof, $$id } => 真正的 React 组件 (.next/static/chunks/...)
(用于 SSR 渲染初始 HTML)
↓
写到 client-reference-manifest
这个三层的"分身术"是 RSC 的核心机制。你今天每写一个 'use client',都触发了上面这一整套。
3. 业务示例 1:Context Provider 的正确写法
需求:根 layout 里要挂一个 ThemeProvider(提供 dark/light 主题),下面任何 client component 都能用 useContext 读它。
3.1 错误写法(最常见的坑)
// app/layout.tsx
import { createContext, useState } from "react"; // ← server component 不能 import 这个!
const ThemeContext = createContext("light");
export default function RootLayout({ children }) {
const [theme, setTheme] = useState("light"); // ← 报错:useState in Server Component
return (
<ThemeContext.Provider value={theme}>
<html>
<body>{children}</body>
</html>
</ThemeContext.Provider>
);
}
这会立刻报错:"useState is not a function" 或 "React functions like useState only work in Client Components"。
3.2 正确写法:抽出 client-only Provider
// app/_components/theme-provider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{
theme: string;
toggle: () => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider
value={{
theme,
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}
// app/layout.tsx (server component)
import { ThemeProvider } from "./_components/theme-provider";
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
为什么这能 work:
layout.tsx是 server component,不导入useState,只导入ThemeProvider—— 后者经过上一节描述的"proxy 替换",在 server graph 里就是个占位符;server 不会执行useState。- 渲染时 server 端 SSR pass(
ssrlayer)跑ThemeProvider真实实现,输出 HTML。 - 客户端 hydration 时
app-pages-browser层接管,useState在浏览器里跑起来。
正确的 mental model:server component 是"骨架",client component 是"开关"。Provider 是开关,所以必须是 client。
4. 业务示例 2:第三方 client-only 包
需求:用 react-toastify 弹通知。这个库 import 时会引用 window、document。
4.1 错误写法
// app/some-page.tsx (server component)
import { toast, ToastContainer } from "react-toastify"; // ← 错!server 跑会报 window is not defined
export default function Page() {
return <ToastContainer />;
}
server 会立刻在 import 阶段就崩溃,因为 react-toastify 模块顶部就读了 window。
4.2 正确:用 client wrapper
// app/_components/toast-container-client.tsx
"use client";
export { toast, ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
// app/some-page.tsx (server component)
import { ToastContainer } from "../_components/toast-container-client";
export default function Page() {
return <ToastContainer />;
}
通过 'use client' boundary 把第三方包"圈"进 client graph,server 就不会去 require 它实现,只持有 client reference。
4.3 更彻底:动态导入
如果这个组件只在客户端用、首屏不显示,可以再加一层 next/dynamic:
"use client";
import dynamic from "next/dynamic";
export const Toaster = dynamic(
() => import("react-toastify").then((m) => m.ToastContainer),
{
ssr: false, // 完全跳过 SSR pass
},
);
ssr: false 让这个组件在 ssr layer 也不执行,只在浏览器 layer 加载——彻底避免任何 server 端解析。
这一招还能减小服务端 bundle 体积,详见第 26 讲。
5. 业务示例 3:Props 序列化的陷阱
Server Component 给 Client Component 传 props 时,props 必须能被 React 序列化(实际是 RSC 序列化协议)。
允许:number、string、boolean、null、undefined、Array、plain object、Date、Promise、Map、Set、TypedArray、React.Element、Server Action 函数。
不允许:自定义 class 实例、function(非 Server Action)、Symbol、Map 里的复杂 key。
5.1 错误写法
// app/products/page.tsx (server)
import { ProductDetail } from "./product-detail"; // client component
class Product {
constructor(
public id: string,
public name: string,
) {}
formatName() {
return this.name.toUpperCase();
}
}
export default function Page() {
const p = new Product("001", "Widget");
return <ProductDetail product={p} />; // ← 错!class 实例无法序列化
}
报错:Only plain objects can be passed to Client Components from Server Components.
5.2 正确写法
要么传 plain object:
return <ProductDetail product={{ id: p.id, name: p.name }} />;
要么把方法拆开传:
return <ProductDetail id={p.id} name={p.name} />;
5.3 传函数?必须是 Server Action
// app/orders/page.tsx (server)
async function cancelOrder(id: string) {
"use server";
// ... DB delete
}
return <OrderRow order={order} onCancel={cancelOrder} />; // OK
这里 cancelOrder 不是普通函数,编译期被 swc 转成 Server Action reference({ $$typeof: 'react.server.reference', $$id: ... }),客户端调用 = 发 RPC。详见第 12 讲。
6. 业务示例 4:Hooks 哪些能用、哪些不能
App Router 引入的 Server-friendly hooks 与传统 React hooks 有明确分界:
| Hook | Server Component | Client Component |
|---|---|---|
useState / useReducer | ❌ | ✅ |
useEffect / useLayoutEffect | ❌ | ✅ |
useRef | ❌ | ✅ |
useMemo / useCallback | ❌ | ✅ |
useContext | ❌ | ✅ |
use() | ✅ | ✅ |
usePathname / useRouter / useSearchParams | ❌ | ✅ |
cookies() / headers() / draftMode() | ✅(async fn) | ❌ |
notFound() / redirect() | ✅ | 也可(throws) |
useFormState / useFormStatus / useOptimistic | ❌ | ✅ |
速记:能 throw 异常、读 request 元数据的(cookies / headers / notFound)是 server-only;和 React state lifecycle 相关的全是 client-only。
7. 业务示例 5:Client → Server?只能走 Server Action
下面这个绝对不行:
"use client";
import { getUserData } from "../server-data"; // ← 错,假设 server-data.ts 是 server-only
export function Profile() {
const [data, setData] = useState(null);
useEffect(() => {
getUserData("123").then(setData); // 直接在 client 里调 server 函数
}, []);
}
server-data.ts 不会被打到 client bundle(甚至如果它 import 了 fs,client compile 直接报错)。
正确做法:把 server function 标 Server Action:
// app/_actions/get-user-data.ts
"use server";
export async function getUserData(id: string) {
// ... fetch DB
return { id, name: "Alice" };
}
"use client";
import { getUserData } from "../_actions/get-user-data";
// 这次 import 拿到的是 server reference proxy
// 调用它 = 发 POST 到 server
或者更自然:用 server 渲染 + 把数据作为 prop 传下去(推荐):
// app/profile/page.tsx (server)
import { getUserData } from "./data";
import { Profile } from "./profile-client";
export default async function Page() {
const data = await getUserData("123");
return <Profile data={data} />;
}
8. 重难点:client component 在 server 也跑
很多用户被这个 mental model 绕晕:
"我不是写了
'use client'吗?为什么 server log 里能看到这个组件的 console?"
答案:'use client' 不是"只在客户端运行",而是"这个文件是 client/server 边界"。
它造成两个具体效果:
- 这个文件作为客户端 entry,会被打到 client bundle(之前
flight-client-entry-plugin干的事)。 - 服务端 SSR 阶段(ssr layer)会也执行这个组件一次,把 HTML 输出给浏览器作为首屏。
所以SSR 阶段你的 useState 也会跑(用初始值),useEffect 不跑(effect 只在浏览器跑)。这是 React 18 SSR 流式渲染的标准行为。
8.1 派生:何时该用 dynamic({ ssr: false })
如果你确实需要"完全不在 server 跑":
- 组件依赖
window.requestAnimationFrame/IntersectionObserver等只有浏览器有的 API。 - 组件是 reactivity 极强的 chart 库、要等 layout 计算后才能工作。
- 想缩短 SSR 耗时(虽然代价是首屏没有这块内容)。
用 next/dynamic({ ssr: false })。
注意 server component 里不能直接
import dynamic然后ssr: false——这语义本身就是 client-only 行为,必须包在'use client'文件里再 dynamic。
8.2 派生:hydration mismatch 怎么定位
最常见原因:
- 服务端时间戳 vs 客户端时间戳:
new Date().toLocaleString()在 server 与 client 因时区或 locale 不同,hydration 不一致。 - localStorage 读取:server 没有 localStorage,初始值 fallback;client 读到真值,DOM 已经渲染完,mismatch。
- 随机 ID:
Math.random()在两边不同。Next.js 给了useId()解决。
定位手段:
- 关掉 React DevTools 的 hide-warning。
- 在 dev overlay 看具体哪个组件 mismatch(错误信息会指出 server / client 输出的具体差异)。
- 用
suppressHydrationWarning屏蔽确定无害的差异(如时间)。
9. 业务示例 6:把 React Query 接入
React Query 是典型"client-only 状态管理"。它的 Provider 必须挂在 client tree 顶端。
// app/_components/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
// app/layout.tsx (server)
import { QueryProvider } from "./_components/query-provider";
export default function RootLayout({ children }) {
return (
<html>
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
关键点:
useState(() => new QueryClient())用 lazy 初始化,避免每次渲染创建新实例。- 不要在模块顶层
const client = new QueryClient()——SSR 与浏览器会共享同一个实例,跨请求泄漏数据。
10. 业务示例 7:在 server component 里访问敏感数据
需求:渲染当前登录用户邮箱。
server-only:
// app/account/page.tsx (server)
import { cookies } from "next/headers";
import { getUserByToken } from "./data";
export default async function AccountPage() {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
const user = await getUserByToken(token);
return <p>欢迎 {user.email}</p>;
}
cookies() 是 server-only API(packages/next/src/server/request/cookies.ts)。它通过 ALS 读到当前请求的 cookie——不会被打到 client bundle。
但如果你不小心:
"use client";
import { cookies } from "next/headers"; // ← 编译期报错
Next.js 的 ESLint 规则(@next/eslint-plugin-next 里的 no-server-import-in-page 之类)会立刻报错。当前 server-only 模块通过 'server-only' 包做编译期检查:
import "server-only"; // 在 client layer 编译会失败
类似有 'client-only',反向防御。
11. webpack rule 是怎么决定一个文件去哪一层的
packages/next/src/build/webpack-config.ts 在配置 webpack 时,给每条 rule 加 issuerLayer 与 layer 字段。简化版逻辑:
- 文件路径在 app/* 下,且文件顶部有 'use client' → 进入 ssr & app-pages-browser
- app/* 下其他文件 → 进入 rsc layer
- 文件路径含 server-only 包 → 仅 rsc layer,client layer require 报错
完整规则在 webpack-config-rules/(第 23 讲深入)。
12. 配套 fixture 推荐实验
fixtures/lecture-07/ 包含 7 个边界场景,每个都是一个独立目录:
app/example-1-context/— ThemeProvider 抽到 client、layout 仍是 server。app/example-2-third-party/— 用next/dynamic({ ssr: false })包 client-only 库。app/example-3-serialization/— 故意传 class 实例,看 dev overlay 报错。app/example-4-hooks/—usePathname在 client,cookies()在 server。app/example-5-action/— Client 调 Server Action。app/example-6-react-query/— QueryClient Provider 接入。app/example-7-hydration-mismatch/— 故意制造时间不一致,看 dev 报警。
每个目录里都有详细注释解释"为什么这样写"。
12.1 必看实验:dump RSC payload
cd fixtures/lecture-07
pnpm dev
# 另一个 terminal
curl -s -H 'RSC: 1' http://localhost:3007/example-1-context?_rsc=1 | head -40
你会看到 RSC payload 里出现类似:
1:I["./app/_components/theme-provider.tsx",["app/example-1-context/page",...],""]
0:["$","$L1",null,{children:[...]}]
$L1 就是 client reference 占位符;1:I[...] 是 manifest 引用——浏览器拿到后会去加载对应 chunk。
13. 重难点小结
13.1 不要在 'use client' 模块里 import server-only
会编译失败或运行报错,依赖具体路径。最佳实践是:
- 所有 server-only utility 放
lib/server/,加import 'server-only'。 - 所有 client-only utility 放
lib/client/,加import 'client-only'。 - 共享代码(pure function)放
lib/shared/。
13.2 不要在 'use server' 文件里 export 非 function
Server Actions 文件只能 export async function:
"use server";
export const MY_CONSTANT = 42; // ← 错!server action file 只能 export 函数
export async function doIt() {
/* ... */
}
否则 build 报错。要分享常量到 client,单独写个共享 module。
13.3 'use client' 是整个文件级的
不是函数级。你不能:
function MyClientFn() {
"use client"; // ← 错,directive 必须在文件顶部
// ...
}
要拆出去单独一个文件。
13.4 多个 'use client' 边界的"传染"
A.tsx (server)
└─ imports B.tsx ('use client')
└─ imports C.tsx (no directive)
C.tsx 会被打到 client graph(被 B 引用),即使 C 没有 'use client'。这意味着 C 的代码会出现在浏览器 bundle 里——如果 C 用了 server-only API,会爆。
记住:'use client' 是"以下文件全部被打到 client"的语义边界。
13.5 dev overlay 的"层"诊断信息
dev 错误页会在堆栈上方显示形如 rsc:、ssr:、app-pages-browser: 的前缀——这就是 webpack layer。看到错误前缀立刻知道是哪层。
14. 检验问题
- 一个
'use client'组件在 server 端会被执行吗?什么时候?哪一层 layer? - SWC、next-flight-loader、flight-client-entry-plugin 三者各自做什么?
- server component 给 client component 传一个 class 实例,会发生什么?怎么修?
- 什么时候应该用
next/dynamic({ ssr: false })而不是'use client'? 'use server'文件可以 export 非 function 吗?为什么?- Context Provider 必须放在哪一层?为什么不能直接放 root layout?
import 'server-only'与import 'client-only'的工作原理是什么?- RSC payload 里看到的
$L1是什么?怎么从它跳到真正的 client component 实现? - 你的同事说:"我把组件标了
'use client'但window is not defined还在报",怎么排查? - Server Action 函数与普通函数在 props 序列化时为何被特殊处理?
15. 延伸阅读
- React 官方文档:Server Components / Client Components RFC
crates/next-custom-transforms/src/transforms/react_server_components.rs(Rust 端 SWC visitor)packages/next/src/build/webpack/loaders/next-flight-loader/(loader 完整目录)packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts(entry 注册逻辑,900+ 行).claude/skills/react-vendoring/SKILL.md(vendored React 与 entry-base.ts 约束).claude/skills/dce-edge/SKILL.md(在 edge runtime 下 RSC 行为差异)- 配套 fixture:
fixtures/lecture-07/
下一讲预告
第 08 讲|Layout、Template、并行路由与 LoaderTree:上一讲我们认识了 LoaderTree 数据结构,本讲深入 createComponentTree 的装配过程——server 怎么把 LoaderTree 转成 React Element tree,parallel route 与 intercepting route 在这里如何特殊处理。