跳到内容

服务器和客户端组合模式

在构建 React 应用程序时,您需要考虑应用程序的哪些部分应该在服务器或客户端渲染。本页涵盖了使用服务器和客户端组件时的一些推荐组合模式。

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

以下是服务器和客户端组件不同用例的快速摘要

你需要做什么?服务器组件客户端组件
获取数据
访问后端资源(直接访问)
将敏感信息保留在服务器上(访问令牌、API 密钥等)
将大型依赖项保留在服务器上 / 减少客户端 JavaScript
添加交互性和事件监听器 (onClick(), onChange(), 等)
使用状态和生命周期 Effects (useState(), useReducer(), useEffect(), 等)
使用仅浏览器 API
使用依赖于状态、effects 或仅浏览器 API 的自定义 Hook
使用 React Class 组件

服务器组件模式

在选择客户端渲染之前,您可能希望在服务器上完成一些工作,例如获取数据,或访问您的数据库或后端服务。

以下是使用服务器组件时的一些常见模式

在组件之间共享数据

在服务器上获取数据时,在某些情况下您可能需要在不同组件之间共享数据。例如,您可能有一个布局和一个页面依赖于相同的数据。

与其使用 React Context(服务器上不可用)或将数据作为 props 传递,您可以使用的 fetch 或 React 的 cache 函数在需要数据的组件中获取相同的数据,而无需担心为相同的数据发出重复请求。这是因为 React 扩展了 fetch 以自动记忆数据请求,并且当 fetch 不可用时,可以使用 cache 函数。

查看此模式的示例

将仅服务器端代码置于客户端环境之外

由于 JavaScript 模块可以在服务器和客户端组件模块之间共享,因此有可能仅打算在服务器上运行的代码偷偷进入客户端。

例如,以下数据获取函数

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

乍一看,getData 似乎在服务器和客户端上都可以工作。但是,此函数包含一个 API_KEY,其编写意图是仅在服务器上执行。

由于环境变量 API_KEY 没有以 NEXT_PUBLIC 为前缀,因此它是一个私有变量,只能在服务器上访问。为了防止您的环境变量泄露到客户端,Next.js 将私有环境变量替换为空字符串。

因此,即使可以在客户端导入和执行 getData(),它也不会按预期工作。虽然使变量公开可以使该函数在客户端上工作,但您可能不想向客户端公开敏感信息。

为了防止这种服务器代码的意外客户端使用,我们可以使用 server-only 包,以便在其他开发人员意外地将这些模块之一导入客户端组件时,给他们一个构建时错误。

要使用 server-only,首先安装该包

终端
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()
}

现在,任何导入 getData() 的客户端组件都将收到一个构建时错误,解释说此模块只能在服务器上使用。

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

使用第三方包和提供程序

由于服务器组件是 React 的一项新功能,生态系统中的第三方包和提供程序才刚刚开始向使用仅客户端功能(如 useStateuseEffectcreateContext)的组件添加 "use client" 指令。

目前,许多来自 npm 包的使用仅客户端功能的组件尚未具有该指令。这些第三方组件将在客户端组件中按预期工作,因为它们具有 "use client" 指令,但它们在服务器组件中将无法工作。

例如,假设您安装了假设的 acme-carousel 包,该包具有 <Carousel /> 组件。此组件使用 useState,但尚未具有 "use client" 指令。

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

app/gallery.tsx
'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>
 
      {/* Works, since Carousel is used within a Client Component */}
      {isOpen && <Carousel />}
    </div>
  )
}

但是,如果您尝试直接在服务器组件中使用它,您将看到一个错误

app/page.tsx
import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/* Error: `useState` can not be used within Server Components */}
      <Carousel />
    </div>
  )
}

这是因为 Next.js 不知道 <Carousel /> 正在使用仅客户端的功能。

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

app/carousel.tsx
'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel

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

app/page.tsx
import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/*  Works, since Carousel is a Client Component */}
      <Carousel />
    </div>
  )
}

我们不希望您需要包装大多数第三方组件,因为您很可能在客户端组件中使用它们。但是,一个例外是提供程序,因为它们依赖于 React 状态和上下文,并且通常需要在应用程序的根目录中使用。 了解有关第三方上下文提供程序的更多信息

使用 Context Providers

上下文提供程序通常在应用程序的根目录附近渲染,以共享全局关注点,例如当前主题。由于 React context 在服务器组件中不受支持,因此尝试在应用程序的根目录创建 context 将导致错误

app/layout.tsx
import { createContext } from 'react'
 
//  createContext is not supported in Server Components
export const ThemeContext = createContext({})
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

要解决此问题,请创建您的 context 并在客户端组件内部渲染其提供程序

app/theme-provider.tsx
'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>
}

您的服务器组件现在可以直接渲染您的提供程序,因为它已被标记为客户端组件

app/layout.tsx
import ThemeProvider from './theme-provider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

由于提供程序在根目录渲染,因此整个应用程序中的所有其他客户端组件都将能够使用此 context。

须知:您应该尽可能在树中更深层地渲染提供程序——请注意 ThemeProvider 如何仅包装 {children} 而不是整个 <html> 文档。这使得 Next.js 更容易优化服务器组件的静态部分。

给库作者的建议

以类似的方式,创建包供其他开发者使用的库作者可以使用 "use client" 指令来标记其包的客户端入口点。这允许包的用户直接将包组件导入到他们的服务器组件中,而无需创建包装边界。

您可以通过在树中使用 'use client' 更深层来优化您的包,从而允许导入的模块成为服务器组件模块图的一部分。

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

客户端组件

将客户端组件向下移动到树中

为了减少客户端 JavaScript 包的大小,我们建议将客户端组件向下移动组件树。

例如,您可能有一个布局,其中包含静态元素(例如,徽标、链接等)和一个使用状态的交互式搜索栏。

与其将整个布局设为客户端组件,不如将交互式逻辑移动到客户端组件(例如 <SearchBar />),并将您的布局保留为服务器组件。这意味着您不必将布局的所有组件 JavaScript 发送到客户端。

app/layout.tsx
// SearchBar is a Client Component
import SearchBar from './searchbar'
// Logo is a Server Component
import Logo from './logo'
 
// Layout is a Server Component by default
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

从服务器到客户端组件传递 props(序列化)

如果您在服务器组件中获取数据,您可能希望将数据作为 props 向下传递给客户端组件。从服务器传递到客户端组件的 Props 需要是 React 可序列化的

如果您的客户端组件依赖于不可序列化的数据,您可以在客户端使用第三方库获取数据,或者使用路由处理器在服务器上获取数据。

交错服务器和客户端组件

当交错客户端和服务器组件时,将您的 UI 可视化为组件树可能很有帮助。从根布局(服务器组件)开始,然后您可以通过添加 "use client" 指令在客户端上渲染某些组件子树。

在这些客户端子树中,您仍然可以嵌套服务器组件或调用服务器行为,但是有一些事项需要牢记

  • 在请求-响应生命周期中,您的代码从服务器移动到客户端。如果您需要在客户端访问服务器上的数据或资源,您将向服务器发出新的请求 - 而不是来回切换。
  • 当向服务器发出新请求时,所有服务器组件首先被渲染,包括嵌套在客户端组件中的服务器组件。渲染结果(RSC Payload)将包含对客户端组件位置的引用。然后,在客户端,React 使用 RSC Payload 将服务器和客户端组件协调到单个树中。
  • 由于客户端组件在服务器组件之后渲染,因此您不能将服务器组件导入到客户端组件模块中(因为它需要向服务器发出新的请求)。相反,您可以将服务器组件作为 props 传递给客户端组件。请参阅下面的不支持的模式支持的模式部分。

不支持的模式:将服务器组件导入客户端组件

以下模式不受支持。您不能将服务器组件导入到客户端组件中

app/client-component.tsx
'use client'
 
// You cannot import a Server Component into a Client Component.
import ServerComponent from './Server-Component'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

支持的模式:将服务器组件作为 Props 传递给客户端组件

以下模式受支持。您可以将服务器组件作为 prop 传递给客户端组件。

一种常见的模式是使用 React children prop 在您的客户端组件中创建一个“插槽”

在下面的示例中,<ClientComponent> 接受一个 children prop

app/client-component.tsx
'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}

<ClientComponent> 不知道 children 最终将被服务器组件的结果填充。<ClientComponent> 的唯一责任是决定 children 最终将放置在哪里

在父服务器组件中,您可以导入 <ClientComponent><ServerComponent>,并将 <ServerComponent> 作为 <ClientComponent> 的子项传递

app/page.tsx
// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

使用这种方法,<ClientComponent><ServerComponent> 是解耦的,可以独立渲染。在这种情况下,子项 <ServerComponent> 可以在服务器上渲染,远早于 <ClientComponent> 在客户端上渲染。

须知

  • “提升内容”的模式已用于避免在父组件重新渲染时重新渲染嵌套的子组件。
  • 您不限于 children prop。您可以使用任何 prop 来传递 JSX。