第 7 章

客户端组件与 'use client' 指令

第7章:客户端组件与 'use client' 指令

'use client' 不是在说“这个文件只在浏览器执行”,它的准确语义是:这是服务器组件树与客户端模块图之间的边界。理解这个边界的传播规则,是写出高性能 App Router 应用的核心。

本章核心问题:'use client' 的真实含义是什么?模块图边界如何传播?如何正确组合服务器组件和客户端组件?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

'use client' 的真实含义:边界声明

'use client' 不是在说"这个文件只在浏览器执行"。它的准确语义是:这是服务器组件树与客户端模块图之间的边界

React 和 Next.js 维护两个模块图:

当 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 传入 SidebarSidebar 只是将这个不透明的"洞"渲染到自己的 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):

服务器骨架(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 应用的核心。

本章评分
4.6  / 5  (49 评分)

💬 留言讨论