如何使用服务端组件与客户端组件

默认情况下,布局和页面都是服务端组件 (Server Components),这允许您在服务端获取数据并渲染部分 UI,可选择缓存结果并将其流式传输到客户端。当需要交互性或使用浏览器 API 时,您可以使用客户端组件 (Client Components) 来添加功能层。

本页将解释 Next.js 中服务端组件与客户端组件的工作原理及适用场景,并通过示例展示如何在应用中组合使用它们。

何时使用服务端组件与客户端组件?

客户端和服务端环境具有不同的能力。服务端组件与客户端组件允许您根据使用场景在不同环境中执行逻辑。

在以下场景使用客户端组件

在以下场景使用服务端组件

  • 从数据库或靠近数据源的 API 获取数据
  • 使用 API 密钥、令牌等敏感信息而不暴露给客户端
  • 减少发送到浏览器的 JavaScript 体积
  • 提升首次内容绘制 (FCP),并逐步将内容流式传输到客户端

例如,<Page> 组件是一个服务端组件,它获取文章数据后,将数据作为 props 传递给处理客户端交互的 <LikeButton>

import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'

export default async function Page({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)

  return (
    <div>
      <main>
        <h1>{post.title}</h1>
        {/* ... */}
        <LikeButton likes={post.likes} />
      </main>
    </div>
  )
}

服务端与客户端组件在 Next.js 中如何工作?

在服务端

在服务端,Next.js 使用 React 的 API 来协调渲染。渲染工作按路由分段(布局和页面)拆分为多个部分:

  • 服务端组件会被渲染为一种特殊数据格式,称为 React 服务端组件负载 (RSC Payload)
  • 客户端组件和 RSC 负载用于预渲染 HTML

什么是 React 服务端组件负载 (RSC)?

RSC 负载是渲染后的 React 服务端组件树的紧凑二进制表示。React 在客户端使用它来更新浏览器的 DOM。RSC 负载包含:

  • 服务端组件的渲染结果
  • 客户端组件应渲染位置的占位符及其 JavaScript 文件的引用
  • 从服务端组件传递给客户端组件的任何 props

在客户端(首次加载)

然后,在客户端:

  1. HTML 用于立即向用户显示路由的非交互式快速预览
  2. RSC 负载用于协调客户端和服务端组件树
  3. JavaScript 用于水合 (hydrate) 客户端组件并使应用具有交互性

什么是水合 (hydration)?

水合是 React 将事件处理程序附加到 DOM 的过程,使静态 HTML 具有交互性。

后续导航

在后续导航中:

  • RSC 负载会被预取并缓存以实现即时导航
  • 客户端组件完全在客户端渲染,无需服务端渲染的 HTML

示例

使用客户端组件

您可以通过在文件顶部(导入语句之前)添加 "use client" 指令来创建客户端组件。

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>{count} likes</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>{count} likes</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

"use client" 用于声明服务端与客户端模块图(树)之间的边界

一旦文件被标记为 "use client"其所有导入和子组件都将被视为客户端包的一部分。这意味着您不需要为每个客户端组件都添加该指令。

减少 JS 包体积

为了减少客户端 JavaScript 包的体积,请将 'use client' 添加到特定的交互式组件,而不是将大部分 UI 标记为客户端组件。

例如,<Layout> 组件包含徽标和导航链接等静态元素,但包含一个交互式搜索栏。<Search /> 是交互式的,需要是客户端组件,而布局的其余部分可以保持为服务端组件。

'use client'

export default function Search() {
  // ...
}

从服务端组件传递数据到客户端组件

您可以通过 props 将数据从服务端组件传递到客户端组件。

import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'

export default async function Page({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)

  return <LikeButton likes={post.likes} />
}

或者,您可以使用 use Hook 将数据从服务端组件流式传输到客户端组件。参见示例

须知:传递给客户端组件的 props 需要能被 React 序列化

交错使用服务端与客户端组件

您可以将服务端组件作为 prop 传递给客户端组件。这允许您在客户端组件中嵌套服务端渲染的 UI。

常见模式是使用 children<ClientComponent> 中创建一个"插槽"。例如,在 <Modal> 组件(使用客户端状态控制可见性)内部嵌套一个在服务端获取数据的 <Cart> 组件。

'use client'

export default function Modal({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}

然后,在父级服务端组件(如 <Page>)中,您可以将 <Cart> 作为 <Modal> 的子组件传递:

import Modal from './ui/modal'
import Cart from './ui/cart'

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  )
}

在这种模式下,所有服务端组件(包括作为 props 传递的组件)都会提前在服务端渲染。生成的 RSC 负载将包含客户端组件在组件树中的渲染位置引用。

上下文提供者

React 上下文通常用于共享全局状态(如当前主题)。然而,React 上下文不支持服务端组件。

要使用上下文,请创建一个接受 children 的客户端组件:

'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

然后,将其导入服务端组件(如 layout):

import ThemeProvider from './theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

现在您的服务端组件可以直接渲染提供者,应用中的所有其他客户端组件都能消费此上下文。

须知:您应尽可能将提供者渲染在树的深层——注意 ThemeProvider 仅包裹 {children} 而非整个 <html> 文档。这使得 Next.js 能更轻松地优化服务端组件的静态部分。

第三方组件

当使用依赖客户端功能的第三方组件时,您可以将其包装在客户端组件中以确保正常工作。

例如,<Carousel /> 可以从 acme-carousel 包导入。该组件使用 useState,但尚未添加 "use client" 指令。

如果在客户端组件中使用 <Carousel />,它将按预期工作:

'use client'

import { useState } from 'react'
import { Carousel } from 'acme-carousel'

export default function Gallery() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
      {/* 正常工作,因为 Carousel 在客户端组件中使用 */}
      {isOpen && <Carousel />}
    </div>
  )
}

但如果直接在服务端组件中使用它,您会看到错误。这是因为 Next.js 不知道 <Carousel /> 使用了客户端专属功能。

要解决此问题,您可以将依赖客户端功能的第三方组件包装在自定义客户端组件中:

'use client'

import { Carousel } from 'acme-carousel'

export default Carousel

现在,您可以直接在服务端组件中使用 <Carousel />

import Carousel from './carousel'

export default function Page() {
  return (
    <div>
      <p>View pictures</p>
      {/* 正常工作,因为 Carousel 是客户端组件 */}
      <Carousel />
    </div>
  )
}

给库作者的建议

如果您正在构建组件库,请将 "use client" 指令添加到依赖客户端功能的入口点。这样用户可以直接将组件导入服务端组件而无需创建包装器。

值得注意的是,某些打包工具可能会移除 "use client" 指令。您可以在 React Wrap BalancerVercel Analytics 仓库中找到如何配置 esbuild 包含 "use client" 指令的示例。

防止环境污染

JavaScript 模块可以在服务端组件 (Server Components) 和客户端组件 (Client Components) 之间共享。这意味着可能会意外将仅限服务端的代码导入到客户端。例如,考虑以下函数:

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

该函数包含一个绝不应暴露给客户端的 API_KEY

在 Next.js 中,只有以 NEXT_PUBLIC_ 为前缀的环境变量会被包含在客户端代码包中。如果变量没有前缀,Next.js 会将其替换为空字符串。

因此,即使 getData() 可以被导入并在客户端执行,它也不会按预期工作。

为了防止在客户端组件中意外使用这些代码,可以使用 server-only

Terminal
npm install server-only

然后,在包含仅限服务端代码的文件中导入该包:

lib/data.js
import 'server-only'

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

现在,如果尝试将该模块导入到客户端组件中,将会出现构建时错误。

须知:对应的 client-only 可用于标记包含仅限客户端逻辑的模块,例如访问 window 对象的代码。