渲染将你编写的代码转换到用户界面。React 和 Next.js 允许你创建混合 web 应用程序,其中部分代码可以在服务器或客户端上呈现。本节将帮助你了解这些渲染环境、策略和运行时之间的差异。
首先,下列对熟悉三个基本的网络概念是有帮助的:
有两种环境可以呈现 web 应用程序:客户端和服务器。
客户端是指用户设备上的浏览器,它向服务器发送应用程序代码请求。然后,它将服务器的响应转换为用户界面。
服务器是指数据中心存储应用程序代码、接收客户端请求并发回适当响应的计算机。
从历史上看,开发人员在为服务器和客户端编写代码时必须使用不同的语言(例如:JavaScript、PHP)和框架。使用 React,开发人员可以使用相同的语言(JavaScript)和相同的框架(例如:Next.js 或你喜欢的框架)。这种灵活性允许你在不切换上下文的情况下为这两种环境无缝地编写代码。
然而,每个环境都有自己地一组功能和约束。因此,你为服务器和客户端编写地代码并不总是相同的。有些操作(例如:数据获取或管理用户状态)更适合一种环境而不是另一种环境。
了解这些差异是有效使用 React 和 Next.js 的关键。我们将在服务器和客户端组件页面上更详细地介绍这些差异和用例,现在,让我们继续在我们的基础上进行构建。
总体而言, 所有网站都遵循相同的请求-响应生命周期:
构建混合 web 应用程序的一个主要部分是决定如何在生命周期中划分工作,以及将网络边界放置在哪里。
在 web 开发中,网络边界是一条概念线,它将不同的环境分隔开来。例如,客户端和服务器,或服务器和数据存储。
在 React 中,你可以选择将客户端-服务器网络边界放置在最合理的位置。
在后台,工作分为两部分:客户端模块图和服务器模块图。服务器模块图包含服务器上呈现的所有组件,客户端模块图包含客户端上呈现的全部组件。
将模块图视为应用程序中文件如何依赖的可视化表示可能会有所帮助。
你可以使用 React 的 "use client"
约定来定义边界。还有一个 "use server"
约定,告诉 React 在服务器上做一些计算工作。
在这些环境中工作时,将应用程序中的代码流视为单向的是很有帮助的。换句话说,在响应过程中,应用程序代码流向一个方向:从服务器到客户端。
如果需要从客户端访问服务器,则向服务器发送一个新请求,而不是重复使用同一请求。这样可以更容易地理解在何处渲染渲染零部件以及在何处放置 “网络边界”。
在实践中,该模型鼓励开发人员在将结果发送到客户端并使应用程序具有交互性之前,首先考虑它们希望在服务器上执行什么。
当我们研究如何在同一组件树中交错客户端和服务器组件时,这个概念将变得更加清晰。
React 服务器组件允许你编写 UI,这些 UI 可以在服务器上进行渲染和缓存。在 Next.js 中,渲染工作被路由段进行分割,以实现流式和部分渲染,并且有三种不同的服务器渲染策略:
本页将介绍服务器组件的工作方式、可能使用它们的时间以及不同的服务器程序策略。
在服务器上进行渲染有几个好处,包括:
默认情况下,Next.js 使用服务器组件。这允许你在无需额外配置的情况下自动实现服务器渲染,并且你可以在需要时选择使用客户端组件,请参阅客户端组件。
在服务器上,Next.js 使用 React 的 API 来编排渲染。渲染工作被分为多个块:按各个路由段和 Suspense Boundaries。
每个块分为两个步骤渲染:
然后,在客户端上:
什么是 React Server Component Payload(RSC)?
RSC Payload 是渲染的 React Server Components 树的紧凑二进制表示。React 在客户端上使用它来更新浏览器的 DOM。RSC Payload 包含:
- 服务器组件的渲染结果
- 客户端组件应在何处渲染的占位符及其 JavaScript 文件的引用
- 从服务器组件传递到客户端组件的任何 props
服务器渲染有三个方式:静态、动态和流式。
使用静态渲染,路由在构建时渲染,或在数据重新验证后在后台渲染。结果被缓存并可以推送到 Content Delivery Network(CDN)。此优化允许你在用户和服务器请求之间共享渲染工作的结果。
当路由包含的数据不是针对用户个性化,并且可能在构建时才得知(例如:静态博客或产品页面)时,静态渲染非常有用。
使用动态渲染,可以在请求时为每个用户渲染路由。
当路由具有针对用户个性化的数据或具有只能在请求时才知道的信息(例如:cookies 或 URL 的搜索参数)时,动态渲染非常有用。
具有缓存数据的动态路由
在大多数网站中,路由不是完全静态或完全动态的 - 这是一个范围。例如,你可以有一个电子商务页面,该页面使用缓存的产品数据,这些数据每隔一段时间重新验证一次,但也包含未缓存的个性化客户数据。
在 Next.js 中,你可以动态渲染同时具有缓存和未缓存数据的路由。这是因为 RSC Payload 和数据是单独缓存的。这允许你选择动态渲染,而无需单向在请求时获取所有数据会性能产生影响。
在渲染过程中,如果发现动态函数或未缓存的数据请求,Next.js 将切换到动态渲染整个路由。此表总结了动态功能和数据缓存如何影响路由是静态渲染还是动态渲染:
动态功能 | 数据 | 路由 |
---|---|---|
否 | 缓存 | 静态渲染 |
是 | 缓存 | 动态渲染 |
否 | 不缓存 | 动态渲染 |
是 | 不缓存 | 动态渲染 |
在上表中,为了使路由完全静态,必须缓存所有数据。但是,你可以有一个动态渲染的路由,它同时使用缓存和未缓存的数据获取。
作为开发人员,你无需在静态和动态渲染之间进行选择,因为 Next.js 会根据所使用的功能和 API 自动为每条路由选择最佳渲染策略。相反,你可以选择何时缓存或重新验证特殊的数据,也可以选择流式处理 UI 的部分内容。
动态功能依赖于只能在请求时知道的信息(例如:用户的 cookie、当前请求头或 URL 的搜索参数)。在 Next.js 中,这些动态功能是:
cookies()
和 headers()
:在服务器组件中使用它们将在请求时选择整个路由进行动态渲染useSearchParams()
:
useSearchParams()
的客户端组件封装在 <Suspense />
边界中。这将允许对其上面的任何客户端组件进行静态渲染。例子searchParams
:使用 Pages prop 将在请求时选择页面进行动态渲染使用这些函数中的任何一个都会在请求时将整个路由选择为动态渲染。
流式允许你能够从服务器逐步渲染 UI。工作被分割成块,并在准备就绪时流式传输到客户端。这允许用户在整个内容完成渲染之前立即查看页面的部分内容。
默认情况下,流式传输内置在 Next.js 应用程序路由器中。这有助于提高初始页面加载性能,也有助于改善获取数据较慢的 UI(这将阻碍整个路由的渲染,例如:产品页面上的评论)。
你可以使用 loading.js
和带有 React Suspense 的 UI 组件开始流式传输路由段。有关更多信息,请参阅加载 UI 和流式处理部分。
客户端组件允许你编写交互式 UI,这些 UI 可以在请求时在客户端上渲染。在 Next.js 中,客户端渲染是 opt-in,这意味着你必须明确决定 React 应该在客户端上渲染哪些组件。
本页将介绍客户端组件的工作方式、渲染方式以及何时可以使用它们。
在客户端上进行渲染工作有几个好处,包括:
要使用客户端组件,你可以在导入的文件顶部添加 React "use client"
指令。
"use client"
用于声明服务器和客户端组件模块之间的边界。这意味着,通过在文件中定义 "use client"
,导入其中的所有其他模块,包括子组件,都被视为客户端打包的一部分。
// app/counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
下图显示,如果未定义 "use client"
指令,在嵌套组件(toggle.js
)中使用 onClick
和 useState
将导致错误。这是因为,默认情况下,组件是在这些 API 不可用的服务器上渲染的。通过在 toggle.js
中定义 "use client"
指令,你可以告诉 React 在有 API 的客户端上渲染组件及其子级。
定义多个
use client
入口点:你可以在 React 组件树中定义多个 “use client” 入口点。这允许你将应用程序拆分为多个客户端包(或分支)。
然而,不需要在每个需要在客户端上呈现的组件中定义
"use client"
。一旦定义了边界,导入其中的所有子组件和模块都将被视为客户端包的一部分。
在 Next.js 中,客户端组件的渲染方式不同,这取决于请求是完整页面加载(首次访问应用程序或由浏览器刷新触发的页面重新加载)还是后续导航的一部分。
为了优化初始页面加载,Next.js 将使用 React 的 API 在服务器上为客户端和服务器组件渲染静态 HTML 预览。这意味着,当用户第一次访问你的应用程序时,它们将立即看到页面的加载,而无需等待客户端下载、解析和执行客户端组件 JavaScript 包。
在服务器上:
然后,在客户端上:
什么是水合作用?
水合是将 event listeners 附加到 DOM 的过程,以使静态 HMTL 具有交互性。在幕后,水合作用是通过
hydrateRoot
React API 完成的。
在后续导航中,客户端组件完全在客户端上渲染,而不是用服务器渲染的 HTML。
这意味着客户端组件 JavaScript 包被下载和解析。一旦包准备就绪,React 将使用 RSC Payload 来协调客户端和服务器组件树,并更新 DOM。
有时,在声明了 "use client"
边界之后,你可能需要返回服务器环境。例如,你可能希望减少客户端包的大小,在服务器上获取数据,或者使用仅在服务器上可用的 API。
你可以将代码保留在服务器上,即使理论上它嵌套在客户端组件中,方法是将客户端组件和服务器组件以及服务器操作交织在一起。有关更多信息,请参阅组合模式页面。
在构建 React 应用程序时,你需要考虑应用程序的哪些部分应该在服务器或客户端上渲染。本页介绍了使用服务器和客户端组件时推荐的一些组合模式。
以下是服务器和客户端组件的不同用例的快速摘要:
你需要做什么 | 服务器组件 | 客户端组件 |
---|---|---|
获取数据 | ? | ? |
访问后台资源(直接) | ? | ? |
在服务器上保留敏感信息(访问令牌、API 密钥等) | ? | ? |
保持对服务器的大量依赖性 / 减少客户端 JavaScript | ? | ? |
添加交互性和事件侦听器(onClick() 、onChange() 等) | ? | ? |
使用状态和生命周期副作用(useState() 、useReducer() 、useEffect() 等) | ? | ? |
使用仅浏览器可用的 API | ? | ? |
使用依赖于依赖于 state、effects 或 browser-only APIs 自定义 hooks | ? | ? |
使用 React 类组件 | ? | ? |
在选择客户端渲染之前,你可能希望在服务器上做一些工作,如:获取数据或访问数据库或后端服务。
以下是使用服务器组件时的一些常见模式:
在服务器上获取数据时,可能会出现需要在不同组件之间共享数据的情况。例如,你的布局和页面可能依赖于相同的数据。
你可以使用 fetch
或 React 的 cache
函数在需要的组件中提取相同的数据,而不用使用 React Context(在服务器上不可用)或将数据作为 props 传递,而不用担心对相同的数据发出重复请求。这是因为 React 扩展了 fetch
以自动存储数据请求,并且在 fetch
不可用时可以使用缓存功能。
在 React 中了解有关记忆的的更多信息。
由于 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
可以用于标记包含仅客户端代码的模块,例如,访问窗口对象的代码。
由于服务器组件是一个新的 React 功能,生态系统中的第三方包和提供商才刚刚开始将 "use client"
指令添加到使用仅客户端功能(如:useState
、useEffect
和 createContext
)的组件中。
如今,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() {
let [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 状态和上下文,并且通常是应用程序的根目录所需要的。在下面了解有关第三方上下文提供程序的更多信息。
上下文提供程序通常呈现在应用程序的根附近,以共享全局关注点,如:当前主题。由于服务器组件不支持 React context,因此尝试在应用程序的根目录下创建上下文将导致错误:
// app/layout.tsx
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
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({ children }) {
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’ 来优化包,允许导入的模块成为服务器组件模块图的一部分。
值得注意的是,一些构建后的包可能会去掉 "use client"
指令。你可以找到如何将 esbuild 配置为在 React Wrap Balancer 中包含 "use client"
指令的示例和 Vercel Analytics 存储库。
为了减少客户端 JavaScript 构建包的大小,我们建议将客户端组件向下移动到组件树中。
例如,你可能有一个具有静态元素(例如:徽标、链接等)的 Layout 和一个使用 state 的交互式搜索栏。
不要将整个布局作为客户端组件,而是将交互式逻辑移动到客户端组件(例如:<SearchBar />
),并将布局保留为服务器组件。这意味着你不必将布局的所有组件 JavaScript 发送到客户端。
// app/layout.tsx
// 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 需要通过 React 进行序列化。
如果客户端组件依赖于不可序列化的数据,则可以使用第三方库在客户端上或通过路由处理程序在服务器上获取数据。
在交错客户端组件和服务器组件时,将 UI 可视化为组件树可能会有所帮助。从根布局(即服务器组件)开始,然后可以通过添加 "use client"
指令在客户端上渲染组件的某些子树。
在这些客户端子树中,你仍然可以嵌套服务器组件或调用服务器操作,但需要记住以下几点:
props
传递给客户端组件。请参阅以下不支持的模式和支持的模式部分。不支持以下模式。你无法将服务器组件导入客户端组件:
// app/client-component.tsx
'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 />
</>
)
}
支持以下模式。你可以将服务器组件作为 prop 传递给客户端组件。
一种常见的模式是使用 React children
prop 在客户端组件中创建一个 “slot”。
在下面的示例中,<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
// 这种模式的工作:
// 可以将服务器组件作为的 child 或 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。你可以使用任何道具来传递 JSX
在 Node.js 的上下文中,运行时是指代码在执行过程中可用的一组库、API 和通用功能。
在服务器上,有两个运行时可以渲染部分应用程序代码:
在选择运行时时需要考虑很多因素。此表显示了主要差异。如果你想对差异进行更深入的分析,请查看下面的部分。
Node | Serverless | Edge | |
---|---|---|---|
Cold Boot | / | Normal | Low |
HTTP Streaming | Yes | Yes | Yes |
IO | All | All | fetch |
Scalability | / | High | Highest |
Security | Normal | High | High |
Latency | Normal | Low | Lowest |
npm Packages | All | All | A smaller subset |
Static Rendering | Yes | Yes | No |
Dynamic Rendering | Yes | Yes | Yes |
Data Revalidation w/ fetch | Yes | Yes | Yes |
在 Next.js 中,轻量级 Edge 运行时是可用 Node.js API 的子集。
如果你需要以小而简单的功能以低延迟提供动态、个性化的内容,Edge 运行时是理想的选择。Edge 运行时的速度来自于它对资源的最小使用,但在许多情况下这可能会受到限制。
例如,在 Edge 运行时中执行的代码在 Vercel 上不能超过 1MB 到 4MB,此限制包括导入的包、字体和文件,并且会根据你的部署基础结构而有所不同。
使用 Node.js 运行时可以所有 Node.js API 以及所有依赖它们的 npm 包。但是,它的启动速度不如使用 Edge 运行时的路由快。
将 Next.js 应用程序部署到 Node.js 服务器将需要管理、扩展和配置基础设置。或者,你可以考虑 Next.js 应用程序部署到 Vercel 这样的无服务器平台,它将为你处理此问题。
如果你需要一个可扩展的解决方案来处理比 Edge 运行时更复杂的计算负载,那么无服务器是理想的选择。例如,使用 Vercel 上的无服务器函数,你的总体代码大小为 50MB,包括导入的包、字体和文件。
与使用 Edge 的路由相比的区别是无服务器函数在开始处理请求之前可能需要数百毫秒才能启动。根据你的网站接收的流量,这种情况可能会频繁发生,因为这些功能并不经常 “warm”。
你可以在 Next.js 应用程序中为各个路由段指定运行时。要执行此操作,请声明一个名为 runtime
的变量并将其导出。该变量必须是字符串,并且必须具有为 'nodejs'
或 'edge'
运行时的值。
以下示例演示了导出值为带有 'edge'
的 runtime
的页面路由段:
// app/page.tsx
export const runtime = 'edge' // 'nodejs' (default) | 'edge'
您还可以在布局级别上定义 runtime
,这将使布局下的所有路由在 edge 运行时上运行:
// app/layout.tsx
export const runtime = 'edge' // 'nodejs' (default) | 'edge'
如果未设置段运行时,则将使用默认的 nodejs
运行时。如果你不打算从 Node.js 运行时进行更改,则不需要使用运行时选项。
有关可用 API 的完整列表,请参阅 Node.js 文档和 Edge 文档。根据你的部署基础架构,这两个运行时还可以支持流式传输。