第 6 章

React Server Components:服务端渲染的革命

第6章:React Server Components:服务端渲染的革命

RSC 不是对现有 SSR 的修补,而是对“组件”这一概念本身的重新定义——组件可以运行在服务器上、永不水合、直接访问后端资源。

本章核心问题:RSC 与传统 SSR 的本质区别是什么?RSC Payload 是什么格式?服务器组件的能力边界在哪里?

读完本章你将理解


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

三种渲染模型的本质区别

在 RSC 之前,前端渲染有两种主流范式:

客户端渲染(CSR):服务器只返回一个空的 HTML 壳,浏览器下载 JavaScript bundle,执行 React,生成 DOM,再发起 API 请求获取数据。用户面对的是白屏等待。

传统服务端渲染(SSR):服务器在响应时执行 React,生成完整 HTML 发送给浏览器,用户看到内容的速度更快。但随后浏览器还需要下载同样的 JavaScript,执行"水合"(hydration)过程——React 重新遍历整颗组件树,将事件处理器绑定到已有 DOM 上。这意味着:每个组件的代码都会发送到客户端,无论它是否需要交互

React Server Components:组件在服务器上执行,但永远不会被水合。RSC 的输出不是 HTML,也不是 JavaScript——而是一种特殊的序列化格式(RSC Payload),描述了渲染结果的虚拟 DOM 树结构,供客户端 React 运行时消费。服务器组件本身的代码永远不会出现在客户端的 JavaScript bundle 中

这个区别至关重要。一个使用了 lodashmarkeddate-fns 的服务器组件,这些库的代码不会出现在浏览器的 network 请求里。

异步组件:直接在组件体内 await

RSC 允许组件函数是 async 的,可以在组件体内直接 await 任何 Promise。这彻底消除了对 useEffect + useState 获取数据的需求。

// app/products/page.tsx
import { db } from '@/lib/db'

// 这个组件在服务器上运行,永远不会发到客户端
export default async function ProductsPage() {
  // 直接访问数据库,无需 API 层
  const products = await db.query(
    'SELECT id, name, price, stock FROM products WHERE active = true ORDER BY created_at DESC'
  )

  return (
    <main>
      <h1>产品列表</h1>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <span>{product.name}</span>
            <span>¥{product.price}</span>
            <span>库存:{product.stock}</span>
          </li>
        ))}
      </ul>
    </main>
  )
}

注意这里没有任何 fetch('/api/products')。数据库连接字符串留在服务器,SQL 查询在服务器执行,结果直接传入 JSX。浏览器的 Network 面板里只会看到页面的 HTML 请求,不会有任何 /api/products 请求。

Level 2 · 它是怎么运行的(3-5年经验)

直接访问文件系统与私密资源

RSC 的服务器特权不止于数据库。你可以直接读取文件系统:

// app/docs/[slug]/page.tsx
import fs from 'fs/promises'
import path from 'path'
import { marked } from 'marked'  // 这个库不会进入客户端 bundle

interface Props {
  params: Promise<{ slug: string }>
}

export default async function DocPage({ params }: Props) {
  const { slug } = await params
  const filePath = path.join(process.cwd(), 'docs', `${slug}.md`)

  let content: string
  try {
    const raw = await fs.readFile(filePath, 'utf-8')
    content = marked(raw) as string
  } catch {
    return <div>文档不存在</div>
  }

  return (
    <article
      className="prose"
      dangerouslySetInnerHTML={{ __html: content }}
    />
  )
}

marked 是一个数十 KB 的 Markdown 解析库。在 CSR 中,它会被打包进客户端 bundle。在 RSC 中,它只在服务器上运行,客户端得到的是已经解析好的 HTML 字符串。

RSC Payload:不是 HTML,不是 JS

RSC 的网络传输格式是 React 团队设计的一种特殊流式格式。打开浏览器开发者工具,查看返回的响应体,你会看到类似这样的内容:

0:["$","main",null,{"children":[["$","h1",null,{"children":"产品列表"}],["$","ul",null,{"children":[["$","li","prod-1",{"children":[...]}]]}]]}]

这是 RSC Payload 的文本表示。它不是 HTML(没有标签闭合,是 JSON 式的树结构),也不是可执行的 JavaScript(它是数据,不是代码)。这种格式有以下特点:

服务器组件的能力边界

RSC 不能使用以下 API,使用时会在构建期或运行时报错:

// ❌ 这些在 RSC 中全部不可用
'use client' // 不需要,但也不能用在 RSC 内部逻辑里

import { useState, useEffect, useCallback } from 'react'
// useState、useEffect 等所有 Hooks 依赖客户端运行时

export default function ServerComp() {
  const [count, setCount] = useState(0) // ❌ 报错

  useEffect(() => { // ❌ 报错
    document.title = 'Hello'
  }, [])

  return <button onClick={() => setCount(c => c + 1)}>{count}</button> // ❌ 事件处理器报错
}

同样不可用的还有:windowdocumentlocalStoragesessionStorage,以及任何仅在浏览器中存在的 API。这是有意为之的设计——RSC 运行在 Node.js 环境(或 Edge Runtime),这些浏览器 API 根本不存在。

Level 3 · 规范怎么定义的(资深)

RSC 与客户端组件的组合:甜甜圈模式

RSC 和客户端组件不是互斥的,它们是互补的。正确的架构是让 RSC 负责数据获取和静态渲染,让客户端组件负责交互。

// components/ProductCard.tsx  —— 客户端组件,负责交互
'use client'

import { useState } from 'react'

interface Product {
  id: string
  name: string
  price: number
}

export function ProductCard({ product }: { product: Product }) {
  const [added, setAdded] = useState(false)

  return (
    <div className="card">
      <h3>{product.name}</h3>
      <p>¥{product.price}</p>
      <button
        onClick={() => {
          // 调用购物车 API
          fetch('/api/cart', {
            method: 'POST',
            body: JSON.stringify({ productId: product.id }),
          })
          setAdded(true)
        }}
      >
        {added ? '已加入购物车' : '加入购物车'}
      </button>
    </div>
  )
}
// app/shop/page.tsx  —— 服务器组件,负责数据
import { db } from '@/lib/db'
import { ProductCard } from '@/components/ProductCard'

export default async function ShopPage() {
  // 直接查询数据库
  const products = await db.product.findMany({
    where: { active: true },
    select: { id: true, name: true, price: true },
  })

  return (
    <div className="grid">
      {products.map(product => (
        // 将数据作为 props 传给客户端组件
        // product 对象会被序列化进 RSC Payload
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

这就是"甜甜圈模式"(Donut Pattern):外层是服务器组件(甜甜圈圈),中间的交互部分是客户端组件(甜甜圈洞)。服务器组件渲染客户端组件,把数据作为 props 传入。

重要约束:传给客户端组件的 props 必须是可序列化的。不能传函数、不能传类实例、不能传 Promise(特殊情况除外)。只能传基本类型、普通对象、数组。

更深层的组合:children 穿透

有一个更精妙的组合模式:把服务器组件的输出作为 children 传入客户端组件。

// components/ThemeProvider.tsx  —— 客户端组件
'use client'

import { createContext, useContext, useState } from 'react'

const ThemeContext = createContext<'light' | 'dark'>('light')

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}
// app/layout.tsx  —— 服务器组件
import { ThemeProvider } from '@/components/ThemeProvider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {/* ThemeProvider 是客户端组件,但 children 是服务器组件渲染的 */}
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

children 在这里起到了"穿透"的作用。ThemeProvider(客户端组件)持有 children 这个插槽,但它不知道也不关心 children 的内容——那些内容由服务器在更外层渲染好,作为不透明的 RSC Payload 传入。这样,children 里的服务器组件不会因为被放入客户端组件而变成客户端组件。

Network 面板的真实差异

在传统 SSR(无 RSC)的应用中,打开 Network 面板:

在 Next.js App Router(RSC)的应用中:

你可以在 Next.js 应用的 Network 面板中看到,客户端导航时的请求 URL 形如 /_next/data/... 或带有 ?_rsc=... 参数的请求,返回的是 RSC Payload 而非 HTML。

Level 4 · 边界与陷阱(所有人)

陷阱1:RSC 中不能使用 useState、useEffect 等所有 Hooks——这些依赖客户端运行时,在服务器组件中使用会报错。

陷阱2:传给客户端组件的 props 必须是可序列化的——不能传函数、类实例、Date 对象(需先转字符串)。

陷阱3:RSC 的输出不是 HTML,是 RSC Payload(一种特殊的序列化格式)——不要将 RSC 与传统 SSR 的 renderToString 混淆。

小结

RSC 的本质是一次架构范式的转移:组件不再是纯粹的客户端概念,而是可以运行在服务器上、永不水合、直接访问后端资源的第一类实体。它解决了三个长期困扰 React 应用的问题:不必要的 bundle 体积、数据获取的瀑布流、以及客户端与服务器之间的 API 层样板代码。但它也引入了新的心智模型:序列化约束、组件边界管理、以及对"这段代码在哪里运行"的持续意识。掌握这个模型,是理解 App Router 全部设计决策的前提。

本章评分
4.7  / 5  (55 评分)

💬 留言讨论