服务端和客户端组件
默认情况下,布局和页面是服务端组件,这让你可以在服务器上获取数据并渲染 UI 的部分内容,可选地缓存结果,并将其流式传输给客户端。当你需要交互性或浏览器 API 时,你可以使用客户端组件来分层添加功能。
本页解释了服务端和客户端组件在 Next.js 中的工作原理以及何时使用它们,并提供了如何在应用程序中将它们组合在一起的示例。
何时使用服务端和客户端组件?
客户端和服务器环境具有不同的能力。服务端和客户端组件允许你根据用例在每个环境中运行逻辑。
当你需要以下功能时使用客户端组件:
- 状态和事件处理程序。例如
onClick、onChange。 - 生命周期逻辑。例如
useEffect。 - 仅限浏览器的 API。例如
localStorage、window、Navigator.geolocation等。 - 自定义钩子。
当你需要以下功能时使用服务端组件:
- 从靠近数据源的数据库或 API 获取数据。
- 使用 API 密钥、令牌和其他秘密,而不将它们暴露给客户端。
- 减少发送到浏览器的 JavaScript 数量。
- 改善首次内容绘制 (FCP),并逐步将内容流式传输给客户端。
例如,<Page> 组件是一个服务端组件,它获取关于帖子的数据,并将其作为 props 传递给处理客户端交互性的 <LikeButton>。
- TypeScript
- JavaScript
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id)
return (
<div>
<main>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
</main>
</div>
)
}
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({ params }) {
const post = await getPost(params.id)
return (
<div>
<main>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
</main>
</div>
)
}
- TypeScript
- JavaScript
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }) {
// ...
}
服务端和客户端组件在 Next.js 中如何工作?
在服务器上
在服务器上,Next.js 使用 React 的 API 来编排渲染。渲染工作按 单个路由段(布局和页面)分成块:
- 服务端组件被渲染成一种称为 React 服务端组件负载 (RSC Payload) 的特殊数据格式。
- 客户端组件和 RSC 负载用于预渲染 HTML。
什么是 React 服务端组件负载 (RSC)?
RSC 负载是渲染的 React 服务端组件树的紧凑二进制表示。React 在客户端使用它来更新浏览器的 DOM。RSC 负载包含:
- 服务端组件的渲染结果
- 客户端组件应该渲染的位置的占位符和对它们的 JavaScript 文件的引用
- 从服务端组件传递给客户端组件的任何 props
在客户端(首次加载)
然后,在客户端:
- HTML 用于立即向用户显示路由的快速非交互式预览。
- RSC 负载用于协调客户端和服务端组件树。
- JavaScript 用于水合客户端组件并使应用程序具有交互性。
什么是水合?
水合是 React 将事件处理程序附加到 DOM 的过程,使静态 HTML 具有交互性。
后续导航
在后续导航中:
- RSC 负载被预取并缓存以实现即时导航。
- 客户端组件完全在客户端渲染,没有服务端渲染的 HTML。
示例
使用客户端组件
你可以通过在文件顶部、导入语句之上添加 "use client" 指令来创建客户端组件。
- TypeScript
- JavaScript
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} 次喜欢</p>
<button onClick={() => setCount(count + 1)}>点击我</button>
</div>
)
}
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} 次喜欢</p>
<button onClick={() => setCount(count + 1)}>点击我 </button>
</div>
)
}
"use client" 用于声明服务端和客户端模块图(树)之间的边界。
一旦文件被标记为 "use client",它的所有导入和子组件都被视为客户端包的一部分。这意味着你不需要为每个旨在用于客户端的组件添加指令。
减少 JS 包大小
为了减少客户端 JavaScript 包的大小,将 'use client' 添加到特定的交互式组件,而不是将 UI 的大部分标记为客户端组件。
例如,<Layout> 组件包含主要是静态元素,如徽标和导航链接,但包括一个交互式搜索栏。<Search /> 是交互式的,需要是一个客户端组件,但是,布局的其余部分可以保持为服务端组件。
- TypeScript
- JavaScript
// 客户端组件
import Search from './search'
// 服务端组件
import Logo from './logo'
// 布局默认是服务端组件
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
// 客户端组件
import Search from './search'
// 服务端组件
import Logo from './logo'
// 布局默认是服务端组件
export default function Layout({ children }) {
return (
<>
<nav>
<Logo />
<Search />
</nav>
<main>{children}</main>
</>
)
}
- TypeScript
- JavaScript
'use client'
export default function Search() {
// ...
}
'use client'
export default function Search() {
// ...
}
从服务端组件传递数据到客户端组件
你可以使用 props 从服务端组件传递数据到客户端组件。
- TypeScript
- JavaScript
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = await getPost(id)
return <LikeButton likes={post.likes} />
}
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
export default async function Page({ params }) {
const post = await getPost(params.id)
return <LikeButton likes={post.likes} />
}
- TypeScript
- JavaScript
'use client'
export default function LikeButton({ likes }: { likes: number }) {
// ...
}
'use client'
export default function LikeButton({ likes }) {
// ...
}
或者,你可以使用 use 钩子从服务端组件流式传输数据到客户端组件。查看示例。
提示: 传递给客户端组件的 props 需要被 React 序列化。
交错服务端和客户端组件
你可以将服务端组件作为 prop 传递给客户端组件。这允许你在客户端组件内视觉上嵌套服务端渲染的 UI。
一个常见的模式是使用 children 在 <ClientComponent> 中创建一个 插槽(slot) 。例如,一个在服务器上获取数据的 <Cart> 组件,位于使用客户端状态来切换可见性的 <Modal> 组件内。
- TypeScript
- JavaScript
'use client'
export default function Modal({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
'use client'
export default function Modal({ children }) {
return <div>{children}</div>
}
然后,在父服务端组件(例如 <Page>)中,你可以将 <Cart> 作为 <Modal> 的子级传递:
- TypeScript
- JavaScript
import Modal from './ui/modal'
import Cart from './ui/cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
import Modal from './ui/modal'
import Cart from './ui/cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
在这种模式中,所有服务端组件都会提前在服务器上渲染,包括作为 props 的组件。生成的 RSC 负载将包含客户端组件应该在组件树中渲染的位置的引用。
Context 提供者
React context 通常用于共享全局状态,如当前主题。但是,React context 在服务端组件中不受支持。
要使用 context,创建一个接受 children 的客户端组件:
- TypeScript
- JavaScript
'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>
}
然后,将其导入到服务端组件(例如 layout)中:
- TypeScript
- JavaScript
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>
)
}
你的服务端组件现在将能够直接渲染你的提供者,并且整个应用程序中的所有其他客户端组件都将能够使用这个 context。
提示: 你应该在结构树中尽可能深地渲染提供者——注意
ThemeProvider只包装{children}而不是整个<html>文档。这使得 Next.js 更容易优化你的服务端组件的静态部分。
第三方组件
当使用依赖仅限客户端功能的第三方组件时,你可以将其包装在客户端组件中以确保它按预期工作。
例如,<Carousel /> 可以从 acme-carousel 包导入。这个组件使用 useState,但它还没有 "use client" 指令。
如果你在客户端组件内使用 <Carousel />,它将按预期工作:
- TypeScript
- JavaScript
'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>
)
}
'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 /> 正在使用仅限客户端的功能。
要修复这个问题,你可以将依赖仅限客户端功能的第三方组件包装在你自己的客户端组件中:
- TypeScript
- JavaScript
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
'use client'
import { Carousel } from 'acme-carousel'
export default Carousel
现在,你可以直接在服务端组件内使用 <Carousel />:
- TypeScript
- JavaScript
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* 工作,因为 Carousel 是客户端组件 */}
<Carousel />
</div>
)
}
import Carousel from './carousel'
export default function Page() {
return (
<div>
<p>View pictures</p>
{/* 工作,因为 Carousel 是客户端组件 */}
<Carousel />
</div>
)
}
给库作者的建议
如果你正在构建组件库,请将
"use client"指令添加到依赖仅限客户端功能的入口点。这让你的用户可以将组件导入到服务端组件中,而无需创建包装器。值得注意的是,一些打包器可能会剥离
"use client"指令。你可以在 React Wrap Balancer 和 Vercel Analytics 仓库中找到如何配置 esbuild 以包含"use client"指令的示例。
防止环境污染
JavaScript 模块可以在服务端和客户端组件模块之间共享。这意味着可能意外地将仅限服务器的代码导入到客户端。例如,思考以下函数的实现:
- TypeScript
- 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()
}
这个函数包含一个永远不应该暴露给客户端的 API_KEY。
在 Next.js 中,只有以 NEXT_PUBLIC_ 为前缀的环境变量才会包含在客户端包中。如果变量没有前缀,Next.js 会用空字符串替换它们。
因此,即使 getData() 可以在客户端导入和执行,它也不会按预期工作。
为了防止在客户端组件中意外使用,你可以使用 server-only 包。
然后,将包导入到包含仅限服务器代码的文件中:
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 对象的代码。
在 Next.js 中,安装 server-only 或 client-only 是可选的。但是,如果你的 linting 规则标记了多余的依赖项,你可能需要安装它们以避免问题。
- npm
- yarn
- pnpm
- bun
npm install server-only
yarn add server-only
pnpm add server-only
bun add server-only
Next.js 在内部处理 server-only 和 client-only 导入,以在模块在错误环境中使用时提供更清晰的错误消息。Next.js 不使用来自 NPM 的这些包的内容。
Next.js 还为其自己的 server-only 和 client-only 提供类型声明,适用于 noUncheckedSideEffectImports 处于活动状态的 TypeScript 配置。