跳到内容

服务器和客户端组合模式

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

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

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

你需要做什么?服务器组件客户端组件
获取数据
访问后端资源(直接地)
将敏感信息保留在服务器上(访问令牌、API 密钥等)
将大型依赖项保留在服务器上 / 减少客户端 JavaScript
添加交互性和事件监听器 (onClick(), onChange() 等)
使用状态和生命周期效应 (useState(), useReducer(), useEffect() 等)
使用仅浏览器 API
使用依赖于状态、效应或仅浏览器 API 的自定义 Hooks
使用 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 的一项新功能,生态系统中的第三方包和提供程序才刚刚开始向使用仅客户端功能的组件添加 "use client" 指令,例如 useStateuseEffectcreateContext

如今,许多来自 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 在服务器组件中不受支持,因此尝试在应用程序的根目录创建上下文将导致错误

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>
  )
}

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

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>
  )
}

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

须知:你应该尽可能在树的深处渲染提供程序 - 请注意 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。