Chapter 03

Server Components 原理

深入理解 RSC 工作机制,掌握服务端与客户端组件的边界划分艺术

3.1 React Server Components 是什么

React Server Components(RSC)是 React 团队提出的一种全新组件模型,允许组件在服务器上渲染并序列化,将结果以特殊格式传输到客户端,客户端只需"接收并展示",无需重新执行渲染逻辑。

传统的服务端渲染(SSR)虽然在服务器上生成 HTML,但组件的 JavaScript 代码仍然需要下载到客户端进行"注水(Hydration)"——即将事件监听器重新附加到 DOM 上。RSC 的不同之处在于:Server Component 的代码永远不会发送到客户端,这意味着更小的 JS Bundle、更快的交互就绪时间(TTI)。

ℹ️

RSC 不是 SSRSSR 和 RSC 是两个独立的概念,可以组合使用。SSR 指在服务器生成 HTML;RSC 指组件在服务器运行且代码不发送到客户端。Next.js 同时使用两者:先 RSC 生成 React 树,再 SSR 将其渲染为 HTML,最后在客户端对 Client Component 进行 Hydration。

RSC 的核心优势

📦

零客户端 Bundle

Server Component 的依赖包(如 markdown 解析器、数据库客户端)不会出现在客户端 JS 中

🔒

直接访问后端

可以直接查询数据库、读取文件系统、访问环境变量,无需创建 API 中间层

自动代码分割

Client Component 的 import 被视为代码分割点,按需加载,减少初始 Bundle 体积

🌊

流式传输

配合 Suspense,可以流式传输 HTML,让用户更早看到页面内容

3.2 'use client' 与 'use server' 指令

Next.js(和 React)使用两个特殊的文件顶部指令来标记组件的渲染环境。理解这两个指令是掌握 App Router 的关键。

'use client' — 客户端组件

在文件顶部添加 'use client' 指令,该文件及其所有子组件都会成为客户端组件。客户端组件在服务端进行 SSR(生成初始 HTML),然后在客户端进行 Hydration。

需要使用 'use client' 的场景:

'use client'

import { useState } from 'react'

// 这是一个客户端组件
export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        增加
      </button>
    </div>
  )
}
TSX

'use server' — Server Actions

'use server' 用于标记服务端函数(Server Actions),将在第5章详细介绍。简而言之,它允许客户端组件调用服务端函数,Next.js 会自动创建对应的 POST 端点。

// app/actions.ts
'use server'

import { db } from './db'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.post.create({ data: { title } })
}
TS

3.3 服务端与客户端组件边界

服务端/客户端边界(Boundary)是理解 RSC 最重要的概念。在组件树中,'use client' 指令标记了边界的起点:从这个组件开始,它以及其所有子组件都成为客户端组件。

⚠️

关键规则Server Component 可以导入并渲染 Client Component。但 Client Component 不能直接导入 Server Component(因为 Server Component 不能在客户端运行)。但可以通过 children props 或 slots 模式将 Server Component 传入 Client Component。

// ✅ 正确:Server Component 包含 Client Component
// app/page.tsx (Server Component)
import { Counter } from './counter' // Client Component

export default async function Page() {
  const data = await fetchData() // 服务端获取数据
  return (
    <div>
      <h1>{data.title}</h1>
      <Counter /> {/* Client Component */}
    </div>
  )
}

// ✅ 正确:通过 children 将 Server Component 传入 Client Component
'use client'
function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children} {/* children 可以是 Server Component */}
    </div>
  )
}
TSX

边界最佳实践:将 Client 下推

最佳实践是尽量将 'use client' 组件下推到组件树的叶节点(Leaf Node),保持大部分组件为 Server Component。这样可以最大化服务端渲染的收益。

// ❌ 不推荐:整个页面都变成 Client Component
'use client'
export default function ProductPage() {
  const [added, setAdded] = useState(false)
  // 整个页面包括数据获取都在客户端
  return <div>...大量 JSX...</div>
}

// ✅ 推荐:只有"加入购物车"按钮是 Client Component
// ProductPage.tsx (Server Component)
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton id={product.id} /> {/* 仅此按钮是 Client */}
    </div>
  )
}
TSX

3.4 注水(Hydration)机制

Hydration(注水/水合)是客户端 React 将服务端渲染的 HTML 与 React 组件树对应起来、并附加事件监听器的过程。这个名字来源于"向静态 HTML 注入交互性"的比喻。

避免 Hydration Mismatch

// ❌ 会导致 Hydration Mismatch
function TimeSince() {
  return <span>{new Date().toLocaleString()}</span>
  // 服务端和客户端的时间不同!
}

// ✅ 方法1:使用 useEffect 只在客户端渲染时间
'use client'
function TimeSince() {
  const [time, setTime] = useState<string>('')
  useEffect(() => {
    setTime(new Date().toLocaleString())
  }, [])
  return <span>{time || '--'}</span>
}

// ✅ 方法2:使用 suppressHydrationWarning(慎用)
function TimeSince() {
  return <time suppressHydrationWarning>
    {new Date().toLocaleString()}
  </time>
}
TSX

3.5 Context 与第三方库的兼容性

许多第三方库使用 React Context(createContextuseContext)来提供全局状态,而 Context 只能在 Client Component 中使用。当需要在 Server Component 为主的 App Router 中使用这些库时,需要将 Provider 包装为 Client Component。

// app/providers.tsx — 将所有 Provider 封装为 Client Component
'use client'

import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider attribute="class">
        {children}
      </ThemeProvider>
    </QueryClientProvider>
  )
}

// app/layout.tsx — 在根布局中使用(children 仍是 Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
TSX
💡

Context 替代方案对于只读的全局数据(如当前用户信息),可以通过 Server Component 将数据作为 props 传递给 Client Component,或者使用 React 19 的 use() API 配合 cache() 函数在服务端共享数据,避免不必要的 Context。