Menu

服务端和客户端组合模式

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

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

以下是服务端组件和客户端组件的不同使用场景的快速总结:

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

服务端组件模式

在选择使用客户端渲染之前,你可能希望在服务器上执行一些工作,比如获取数据或访问数据库或后端服务。

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

在组件之间共享数据

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

你不需要使用 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()
}
lib/data.js
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,首先安装该包:

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

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

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

使用第三方包和提供者

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

目前,许多来自 npm 包中使用仅客户端功能的组件还没有添加该指令。这些第三方组件在客户端组件中可以正常工作,因为它们有 "use client" 指令,但在服务端组件中不能工作。

例如,假设你已安装了假想的 acme-carousel 包,其中有一个 <Carousel /> 组件。这个组件使用了 useState,但它还没有 "use client" 指令。

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

app/gallery.tsx
TypeScript
'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)}>查看图片</button>
 
      {/* 可以工作,因为 Carousel 在客户端组件中使用 */}
      {isOpen && <Carousel />}
    </div>
  )
}

但如果你试图直接在服务端组件中使用它,你会看到一个错误:

app/page.tsx
TypeScript
import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>查看图片</p>
 
      {/* 错误:`useState` 不能在服务端组件中使用 */}
      <Carousel />
    </div>
  )
}

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

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

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

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

app/page.tsx
TypeScript
import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>查看图片</p>
 
      {/* 可以工作,因为 Carousel 是一个客户端组件 */}
      <Carousel />
    </div>
  )
}

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

使用上下文提供者

上下文提供者通常在应用程序的根部渲染,以共享全局关注点,比如当前主题。由于 React 上下文在服务端组件中不受支持,试图在应用程序的根部创建上下文将导致错误:

app/layout.tsx
TypeScript
import { createContext } from 'react'
 
// 服务端组件中不支持 createContext
export const ThemeContext = createContext({})
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

要解决这个问题,在客户端组件中创建你的上下文并渲染其提供者:

app/theme-provider.tsx
TypeScript
'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
TypeScript
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' 来优化你的包,允许导入的模块成为服务端组件模块图的一部分。

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

客户端组件

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

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

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

与其将整个布局变成客户端组件,不如将交互式逻辑移到一个客户端组件中(例如 <SearchBar />),并保持布局作为服务端组件。这意味着你不必将布局的所有组件 JavaScript 发送到客户端。

app/layout.tsx
TypeScript
// SearchBar 是一个客户端组件
import SearchBar from './searchbar'
// Logo 是一个服务端组件
import Logo from './logo'
 
// Layout 默认是一个服务端组件
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
TypeScript
'use client'
 
// 你不能将服务端组件导入到客户端组件中
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
TypeScript
'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
TypeScript
// 这种模式是可行的:
// 你可以将服务端组件作为客户端组件的子组件或 prop 传递
import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
// Next.js 中的页面默认是服务端组件
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

通过这种方法,<ClientComponent><ServerComponent> 是解耦的,可以独立渲染。在这种情况下,子组件 <ServerComponent> 可以在服务器上渲染,远在 <ClientComponent> 在客户端渲染之前。

值得注意的是

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