服务器和客户端组合模式
构建 React 应用程序时,您需要考虑应用程序的哪些部分应在服务器或客户端上渲染。本页介绍了使用服务器和客户端组件时的一些推荐组合模式。
何时使用服务器和客户端组件?
下面是服务器和客户端组件不同用例的快速摘要:
| 您需要做什么? | Server Component | Client Component |
|---|---|---|
| 获取数据 | ✔️ | ❌ |
| 访问后端资源(直接) | ✔️ | ❌ |
| 在服务器上保存敏感信息(访问令牌、API 密钥等) | ✔️ | ❌ |
| 保持对服务器的大量依赖/减少客户端 JavaScript | ✔️ | ❌ |
| 添加交互性和事件监听器(onClick()、onChange(),等) | ❌ | ✔️ |
使用状态和生命周期 Effects (useState(), useReducer(), useEffect(), 等) | ❌ | ✔️ |
| 使用仅限浏览器的 API | ❌ | ✔️ |
| 使用依赖于 state、effects 或仅限浏览器的 API 的自定义 hooks | ❌ | ✔️ |
| 使用 React Class 组件 | ❌ | ✔️ |
服务器组件模式
在选择客户端渲染之前,您可能希望在服务器上做一些工作,例如获取数据或访问数据库或后端服务。
以下是使用服务器组件时的一些常见模式:
在组件之间共享数据
在服务器上获取数据时,可能存在需要跨不同组件共享数据的情况。例如,您可能有依赖于相同数据的布局和页面。
您可以使用fetch 或 React 的cache功能在需要它的组件中获取相同的数据,而不是使用React Context或将数据作为属性进行传递,不必担心对相同数据发出重复请求。这是因为 React 扩展了fetch自动记忆数据请求的功能,并且可以在fetch不可用时使用cache函数。
将服务器专用代码置于客户端环境之外
由于 JavaScript 模块可以在服务器和客户端组件模块之间共享,所以因此原本只打算在服务器上运行的代码可能会潜入客户端。
例如, 采用下列数据获取方法:
export async function getData() {
const res = await fetch("https://external-service.com/data", {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
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 package 为他们提供构件时错误。
要使用server-only, 首先安装这个 package:
npm install server-only
然后将这个 package 引入到任何包含仅限服务器代码的模块中:
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()的客户端组件都会收到说明该模块只能用于服务器上的构件时错误。
相应的 packageclient-only可用于标记包含仅限客户端代码的模块– 例如, 访问window对象的代码。
使用第三方 Packages 和 Providers
因为服务器组件是 React 的一项新功能,生态环境中的第三方 packages 和 providers 只是向仅适用于客户端的功能(如: useState, useEffect, 和 createContext)的组件中添加"use client" 指令。
目前, 很多来自 npm 的包且使用仅限于客户端功能的组件还没有"use client"这个指令。这些第三方组件在客户端组件中会按预期工作,因为它们包含 "use client" 指令,但是不能再服务器组件中工作。
例如, 假设您安装了带有<Carousel />组件的acme-carousel package。该组件使用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>
{/* Works, since Carousel is used within a Client Component */}
{isOpen && <Carousel />}
</div>
);
}
"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>
);
}
不过,如果您尝试在服务器组件中直接使用它,您将看到错误:
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>
);
}
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 /> 正在使用仅限客户端的功能。
为了解决这个问题,您可以将仅依赖于客户端功能的第三方组件包装在您自己的客户端组件中:
"use client";
import { Carousel } from "acme-carousel";
export default 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>
{/* Works, since Carousel is a Client Component */}
<Carousel />
</div>
);
}
import Carousel from "./carousel";
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* Works, since Carousel is a Client Component */}
<Carousel />
</div>
);
}
我们不希望您需要包装大多数第三方组件,因为您很可能会在客户端组件中使用它们。但是是一个例外,因为它们依赖于 React state 和 context, 并且通常需要在应用程序的根节点。请在下面了解更多有关第三方 context providers .
使用 Context Providers
Context providers 通常渲染在应用程序的根目录附近,以共享全局关注点,例如当前主题。 因为 React context 在服务器组件中不受支持,尝试在应用程序的根节点下创建上下文将导致错误:
import { createContext } from "react";
// createContext is not supported in Server Components
export const ThemeContext = createContext({});
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
</body>
</html>
);
}
import { createContext } from "react";
// createContext is not supported in Server Components
export const ThemeContext = createContext({});
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
</body>
</html>
);
}
要解决此问题,请创建上下文并在客户端组件内渲染其 provider:
"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>;
}
"use client";
import { createContext } from "react";
export const ThemeContext = createContext({});
export default function ThemeProvider({ children }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
您的服务器组件现在将能够直接渲染 provider。因为它已被标记为客户端组件:
import ThemeProvider from "./theme-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
import ThemeProvider from "./theme-provider";
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
随着在根节点渲染 provider, 应用程序中的所有其它客户端组件都将能够使用此上下文。
您需要知道: 您应该在 树中尽可能深地渲染 providers – 注意
ThemeProvider仅包裹{children}而不是整个<html>文档。这使 Next.js 更容易优化服务器组件中的静态部分。
给 library 作者的建议
以此类推,创建供其他开发人员使用的 packages 的 library 作者可以使用"use client"指令来标记其 package 的客户端入口点。这使得 package 的用户将 package 组件直接导入到他们的服务器组件中,而不必创建包装边界。
您可以在树的深处使用'use client'优化您的 package,从而允许导入的模块成为服务器组件模块图的一部分。
值得注意的是,一些打包工具可能会移除"use client"指令。您可以在React Wrap Balancer 和 Vercel Analytics仓库中看到如何配置 esbuild 以包含"use client"指令的例子。
客户端组件
将客户端组件沿树向下移动
为了减少客户端 JavaScript 包的大小,我们建议将客户端组件从组件树中向下移动。
例如, 您可以有一个包含静态元素(例如 logo、链接等)的布局和一个使用状态的交互式搜索栏。
不要将整个布局设为客户端组件,而是将交互逻辑移至客户端组件(例如<SearchBar />),并将布局保持为服务器组件。这意味着您不必将布局的所有组件 Javascript 发送到客户端。
// 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>
</>
);
}
// 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 }) {
return (
<>
<nav>
<Logo />
<SearchBar />
</nav>
<main>{children}</main>
</>
);
}
从服务器向客户端组件传递 props(序列化)
如果您在服务器组件中获取数据, 您可能想要将数据作为属性传递给客户端组件。从服务器传递到客户端组件的 props 需要能够通过 React 序列化。
如果您的客户端组件依赖于不可序列化的数据, 您可以使用第三方库在客户端获取数据或者通过Route Handler在服务器上获取数据。
交错服务器和客户端组件
当交错使用客户端和服务器组件时,将 UI 想象为组件树可能会有所帮助。从根布局开始(它是服务器组件),然后您可以通过添加"use client"指令在客户端渲染某些组件子树。
在这些客户端子树中, 您仍然可以嵌套服务器组件或调用服务器操作, 然而,需要记住以下几点:
- 在请求-响应生命周期中,您的代码从服务器移至客户端。如果您需要在客户端上访问服务器上的数据或资源,您将向服务器发出新请求 - 而不是来回切换。
- 当向服务器发出新的请求时,首先呈现所有服务器组件,包括那些嵌套在客户端组件中的组件。渲染结果 (RSC Payload)将包含对客户端组件位置的引用。然后,在客户端上,React 使用 RSC Payload 将服务器和客户端组件协调到单个树中。
- 由于客户端组件是在服务器组件之后渲染的,因此不能将服务器组件导入到客户端组件模块中(因为它需要向服务器返回一个新的请求)。相反,您可以将服务器组件作为
props传递给客户端组件。请参阅下面不支持的模式 和 支持的模式部分。
不支持的模式: 将服务器组件引入到客户端组件
以下模式不被支持。您不能将服务器组件导入到客户端组件:
"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 />
</>
);
}
"use client";
// You cannot import a Server Component into a Client Component.
import ServerComponent from "./Server-Component";
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
);
}
支持的模式: 将服务器组件作为属性传递给客户端组件
支持以下模式。您可以将服务器组件作为属性传递给客户端组件。
在客户端组件中使用 React children属性创建*"slot"*是一种常见的模式。
在下面的例子中, <ClientComponent> 接收一个children属性:
"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}
</>
);
}
"use client";
import { useState } from "react";
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
);
}
<ClientComponent> 不知道children最终将由服务器组件的结果填充。<ClientComponent>仅有的职责是最终决定将children放哪。
在父服务器组件中, 您可以将<ClientComponent> 和 <ServerComponent>全部引入,并且将<ServerComponent>作为<ClientComponent>的子项传递:
// 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>
);
}
// 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属性。您可以使用任何属性来传递 JSX。