客户端组件与 'use client' 指令
第7章:客户端组件与 'use client' 指令
'use client' 不是在说“这个文件只在浏览器执行”,它的准确语义是:这是服务器组件树与客户端模块图之间的边界。理解这个边界的传播规则,是写出高性能 App Router 应用的核心。
本章核心问题:'use client' 的真实含义是什么?模块图边界如何传播?如何正确组合服务器组件和客户端组件?
读完本章你将理解:
- 'use client' 是模块图的边界声明,不是运行时执行位置控制器
- 客户端组件在首次加载时仍然经过服务器 SSR
- 通过 children 和 props 穿透实现正确的组合模式
Level 1 · 你需要知道的(1-3年经验)
'use client' 的真实含义:边界声明
'use client' 不是在说"这个文件只在浏览器执行"。它的准确语义是:这是服务器组件树与客户端模块图之间的边界。
React 和 Next.js 维护两个模块图:
- 服务器模块图:所有没有
'use client'的文件,在构建时被分析为服务器代码 - 客户端模块图:所有带有
'use client'的文件,以及从它们出发import的所有文件
当 bundler 遇到 'use client' 时,它做的事情是:停止将该文件及其依赖包含进服务器端渲染逻辑,转而将其打包进客户端 JavaScript bundle,并在服务器侧留下一个对该模块的引用。
这是一个模块图切割点(module graph boundary),不是运行时的执行位置控制器。
客户端组件在首次加载时仍然在服务器渲染
这是第二个关键认知:标记了 'use client' 的组件,在用户首次访问页面时,仍然在服务器上进行 SSR。
用户首次请求 /dashboard
│
▼
Next.js 服务器
├── 执行 RSC(服务器组件)
├── 遇到客户端组件 → 也在服务器上 SSR(生成 HTML)
└── 返回:完整 HTML + RSC Payload(含客户端组件的 SSR 输出)
│
▼
浏览器收到 HTML → 立即显示内容(无白屏)
浏览器下载 JS bundle → 执行水合(hydration)
水合完成 → 客户端组件变为可交互状态
'use client' 的真正含义在后续客户端导航中才充分体现:RSC 在服务器重新执行,客户端组件在浏览器中直接执行(不再需要服务器参与渲染该组件)。
Level 2 · 它是怎么运行的(3-5年经验)
模块图边界的传播规则
边界具有单向传播性:
// lib/analytics.ts — 服务器模块(无指令)
export function trackEvent(name: string) {
// 只能在服务器上调用
console.log(`[Server] Event: ${name}`)
}
// components/Button.tsx — 客户端边界
'use client'
import { useState } from 'react'
// 注意:这里不能 import 服务器专用模块(如数据库驱动)
// 如果你 import 了,构建时会报错或将服务器模块打包进客户端
export function Button({ label }: { label: string }) {
const [clicked, setClicked] = useState(false)
return (
<button onClick={() => setClicked(true)}>
{clicked ? '已点击' : label}
</button>
)
}
// components/Icon.tsx — 没有 'use client',但被 Button.tsx import
// 因为 Button.tsx 是客户端边界,Icon.tsx 也会进入客户端 bundle
import { Sparkles } from 'lucide-react'
export function Icon() {
return <Sparkles size={16} />
}
关键规则:'use client' 文件 import 的所有模块(及其递归依赖),都会进入客户端 bundle,即使那些模块自身没有 'use client'。
这就是为什么你不应该在客户端组件中 import 数据库驱动、私钥配置等服务器专用模块——它们会被打包进客户端 bundle,导致安全漏洞或构建失败。
把服务器组件的输出作为 children/props 传入
这是 RSC 与客户端组件协作中最重要也最容易被忽视的模式。
错误做法:在客户端组件内部 import 并渲染服务器组件
// ❌ 错误:客户端组件内部 import 服务器组件
'use client'
import { HeavyServerWidget } from './HeavyServerWidget' // 这会把它拉入客户端 bundle!
export function Layout() {
return (
<div>
<HeavyServerWidget /> {/* 无法在客户端环境中作为 RSC 运行 */}
</div>
)
}
正确做法:通过 children prop 穿透
// components/Sidebar.tsx — 客户端组件(需要折叠/展开交互)
'use client'
import { useState } from 'react'
interface SidebarProps {
children: React.ReactNode // 接受服务器组件的输出
}
export function Sidebar({ children }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
return (
<aside className={collapsed ? 'w-12' : 'w-64'}>
<button onClick={() => setCollapsed(c => !c)}>
{collapsed ? '展开' : '折叠'}
</button>
{/* children 是服务器渲染的内容,不受 'use client' 边界影响 */}
{!collapsed && children}
</aside>
)
}
// app/dashboard/layout.tsx — 服务器组件
import { Sidebar } from '@/components/Sidebar'
import { NavMenu } from '@/components/NavMenu' // 服务器组件
import { db } from '@/lib/db'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// 服务器组件可以做数据库查询
const menuItems = await db.menuItem.findMany({ where: { active: true } })
return (
<div className="flex">
{/* Sidebar 是客户端组件,但它的 children 是服务器渲染的 */}
<Sidebar>
<NavMenu items={menuItems} />
</Sidebar>
<main>{children}</main>
</div>
)
}
为什么这样可以工作?因为 children 是在服务器组件(DashboardLayout)的渲染上下文中被计算的,它的值(RSC Payload 中的树节点引用)被作为 prop 传入 Sidebar。Sidebar 只是将这个不透明的"洞"渲染到自己的 JSX 中,并不需要知道或控制这个洞里的内容。
Level 3 · 规范怎么定义的(资深)
常见错误模式分析
错误一:将所有东西都包在 'use client'
// ❌ 把整个页面声明为客户端组件
'use client'
import { useSession } from 'next-auth/react'
import { ProductList } from './ProductList'
import { db } from '@/lib/db' // ⚠️ 这会被打包进客户端!
export default function Page() {
// 原本服务器做的数据获取现在变成客户端 fetch
const [products, setProducts] = useState([])
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts)
}, [])
// ...
}
正确做法是把页面保持为服务器组件,只把需要交互的最小单元标记为客户端:
// ✅ 正确:只在需要的地方使用 'use client'
// app/products/page.tsx — 服务器组件
import { db } from '@/lib/db'
import { AddToCartButton } from './AddToCartButton' // 这个是客户端组件
export default async function Page() {
const products = await db.product.findMany()
return (
<ul>
{products.map(p => (
<li key={p.id}>
{p.name}
<AddToCartButton productId={p.id} /> {/* 只有按钮是客户端组件 */}
</li>
))}
</ul>
)
}
错误二:在服务器组件中使用 Context
// ❌ 服务器组件不能使用 Context
import { useTheme } from '@/contexts/ThemeContext'
export default function ServerWidget() {
const theme = useTheme() // 运行时报错:hooks 在 RSC 中不可用
return <div className={theme}>...</div>
}
服务器组件无法访问 React Context,因为 Context 是客户端运行时的能力。需要把值作为 prop 显式传入,或者通过 cookie/header 在服务器上读取。
Context Provider 的正确架构
Context 在服务器组件中无法使用,但 Context Provider 本身可以被合理架构,使得服务器组件的子树能正常工作:
// providers/ThemeProvider.tsx
'use client'
import { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextValue {
theme: Theme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system')
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) setTheme(stored)
}, [])
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
document.documentElement.classList.toggle('dark', newTheme === 'dark')
}
return (
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
// app/layout.tsx — 服务器组件
import { ThemeProvider } from '@/providers/ThemeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh">
<body>
{/* ThemeProvider 是客户端,但 children(服务器组件树)穿透进来 */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}
// components/ThemeToggle.tsx — 客户端组件,可以使用 useTheme
'use client'
import { useTheme } from '@/providers/ThemeProvider'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '切换浅色' : '切换深色'}
</button>
)
}
这个架构的精妙之处:ThemeProvider 是客户端组件,它的 children 是服务器渲染的页面内容。ThemeToggle 也是客户端组件,它可以读写 Context。页面内容(服务器组件)不需要读 Context,它们通过 CSS 变量或 class 来响应主题变化,这些 class 由 ThemeProvider 在客户端动态添加到 document.documentElement。
交互岛屿架构
综合以上原则,App Router 的最佳实践是交互岛屿架构(Interactive Island Architecture):
- 页面的主体结构(layout、data-fetching)由服务器组件构成,形成静态骨架
- 需要交互的部分(按钮、表单、下拉、模态框)是客户端组件,像"岛屿"一样嵌入服务器骨架中
- 每个岛屿尽可能小,只包含必要的客户端逻辑
- 数据通过 props 从服务器组件传入岛屿,或通过 Server Actions 从岛屿发回服务器
服务器骨架(RSC)
├── Header(服务器组件:读取 session)
│ └── [SearchBar 岛屿:客户端,需要实时搜索]
├── ProductGrid(服务器组件:数据库查询)
│ └── ProductCard(服务器组件:静态展示)
│ └── [AddToCart 岛屿:客户端,需要点击交互]
└── Footer(服务器组件:纯静态)
这种架构将客户端 JavaScript 的体积压缩到最低,同时保持了完整的交互能力。
Level 4 · 边界与陷阱(所有人)
陷阱1:'use client' 文件 import 的所有模块(及其递归依赖)都会进入客户端 bundle——不要在客户端组件中 import 数据库驱动或私钥配置。
陷阱2:在客户端组件内部 import 服务器组件会把它拉入客户端 bundle——正确做法是通过 children prop 穿透。
陷阱3:不要把整个页面标记为 'use client'——应将交互逻辑拆分到最小的客户端组件中。
小结
'use client' 是模块图的边界声明,不是"只在客户端运行"的开关。客户端组件在首次加载时仍然经过服务器 SSR。正确的组合模式是通过 children 和 props 穿透,而不是在客户端组件内部 import 服务器组件。Context 需要封装在客户端 Provider 组件中,通过 children 让服务器组件的子树穿透其中。掌握边界的传播规则和正确的组合模式,是写出高性能 App Router 应用的核心。