应用的渲染可以有两种环境:客户端和服务端。两者都有自己的特性和限制,不能说孰优孰劣。
在 Web 开发中,可以用 网络边界 的概念来划分这两种环境。可以用 React 的 "use client"
来定义网络边界。或者用 "use server"
来告诉 React 在服务器上做一些计算性的工作。
服务端组件
React 服务端组件可以让你在服务器上进行 UI 渲染及缓存。在 Next.js 上,渲染工作则会根据路由片段的划分来启用流式渲染或部分渲染。
有三种服务端渲染的策略:静态渲染、动态渲染、流式渲染。
Next.js 默认使用服务端组件,无需额外配置。
服务端渲染的优势
- 数据获取 - 服务器通常离数据资源更近,缩短了请求时间。也可以减少客户端请求的数量。
- 安全 - 服务端组件会将敏感数据和逻辑留存在服务端上,比如 token 或 API Key ,避免暴露给客户端。
- 缓存 - 服务端会对渲染结果进行缓存,一定程度上减少了渲染的工作量和请求次数。
- 打包体积 - 一些大的依赖可以留存在服务端,来减少客户端包体积。
- 首屏加载时间 - 服务端可以生成 HTML ,让用户立即看到页面。
- SEO - 渲染的 HTML 可以让搜索引擎或社交网站的爬虫索引你的页面。
- 流式渲染 - 服务端组件允许你将渲染工作进行拆分,并逐步渲染传输给客户端,确保用户能立即看到页面的一部分。
渲染步骤
Next.js 使用 React 的 API 进行服务端渲染。渲染工作会根据路由片段和 Suspense
进行拆分。
每一拆分的块的渲染步骤如下:
- 在服务端上:
- React 将服务端组件渲染为一种特殊的数据格式,称为 RSC Payload(React Server Component Payload)。
- Next.js 拿着 RSC Payload 和客户端组件的 JS 代码来渲染 HTML 。
- 接着,在客户端上:
- 会立即加载一个无法交互的 HTML ,即首屏页面。
- RSC Payload 会比对客户端和服务端的组件树,来更新 DOM 。
- 根据 JS 代码来 hydrate 客户端组件,使应用可交互。
RSC Payload 通常会包含这些信息:
- 服务端组件的渲染结果;
- 客户端组件的占位符及其 JS 文件的引用地址;
- 所有服务端组件传给客户端组件的参数。
服务端渲染策略
静态渲染
Next.js 默认采取静态渲染。路由会在构建时(或当数据重新校验后)被渲染好。渲染结果会被缓存并且可以推送到 CDN 上。
静态渲染通常用于与用户数据不相关的页面,比如静态博客页面。
动态渲染
当页面与用户数据相关,或当请求中带有信息时(比如 URL 参数、cookie 等),通常使用动态渲染。在动态渲染中,路由会对每次请求都进行渲染。
路由并非只能是纯静态或纯动态。在 Next.js 中,RSC Payload 和请求数据是分别缓存的。
当面对一个动态函数或者未缓存数据请求时,Next.js 会对整个路由进行动态渲染。这些都是自动的,开发者不需要手动设置静态或动态渲染。
动态函数 会依赖一些在请求时才可获知的信息,比如用户 cookie、请求头、查询参数。在 Next.js 中具体有:
cookies()
和headers()
- 会直接将路由切为动态渲染;useSearchParams()
- 若在客户端组件中使用了这一函数,会跳过静态渲染,并回退到最近的Suspense
上。因此建议将这类组件包裹在Suspense
中,来保证上层组件可以正常静态渲染;searchParams
- 使用该页面属性会将路由切为动态渲染。
流式渲染
Next.js 默认开启了流式渲染,可以用 loading.js
及 Suspense
启用这一特性。
客户端组件
在 Next.js 中需在文件最顶部加上 "use client"
来将组件声明为客户端组件。如果在服务端组件中使用了客户端才可用的 API(比如 useState
、onClick
等),会报错。
使用客户端组件的好处有:
- 交互 - 可以使用 state、effect、事件监听等提供用户反馈的 API ;
- 浏览器 API - 比如
localStorage
等。
渲染方式
不同的页面请求方式,客户端组件的渲染方式不同。请求方式有 整页加载 和 导航切换 。
整页加载
整页加载,即首次打开页面或者采用了浏览器刷新。为了优化首屏加载,Next.js(借助 React)均会将客户端和服务端组件渲染为 HTML ,无需等待客户端组件的 JS 包。
导航切换
通过导航切换打开的页面,客户端组件会完全由客户端渲染。客户端拿到客户端组件的 JS 包后,React 会根据 RSC Payload 来比对客户端和服务端组件树,然后更新 DOM 。
两端搭配
服务端组件注意点
-
传递数据 - 在服务端不可以利用 React Context 来传递参数,但可以利用
fetch
或 React 中的缓存机制来获取数据。更多见 React 中的 记忆化 。 -
防止服务端代码进入客户端 - 为了防止服务端代码(比如接口请求)意外地在客户端代码中使用,可以引入
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() }
-
第三方库引入 - 许多第三方库可能没有在组件上声明
"use client"
,需要我们自行封装一下:'use client' import { Carousel } from 'acme-carousel' export default Carousel
-
Context - 在服务端代码中使用 Context 时,该 Context 应当在一个单独的文件中声明,并声明为客户端代码:
'use client' import { createContext } from 'react' export const ThemeContext = createContext({}) export default function ThemeProvider({ children }) { return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider> }
客户端组件注意点
- 下沉客户端组件代码 - 为了减少 JS 代码包,应当将组件树中的存在交互逻辑的客户端组件单独拆分出来,使得其余部分尽可能成为服务端组件。
- 从服务端传递给客户端的参数应当可序列化 。
两端代码嵌套
在客户端代码中,依然可以嵌套服务端代码,但有一些注意点:
- 当你的服务端代码换为客户端代码时,客户端如果要获取数据或资源是需要单独向服务器请求的。
- 当服务端接收到请求时,所有服务端组件(包括内嵌在客户端组件的服务端组件)均会被最先渲染。渲染结果(RSC Payload)会包含客户端组件的位置信息。在客户端上,React 会根据 RSC Payload 来将服务端和客户端组件合为一棵树。
- 因为客户端组件会在服务端组件之后渲染,客户端组件代码中不能包含对服务端代码的 import 导入。服务端组件应当作为参数传递给客户端:
import ClientComponent from './client-component' import ServerComponent from './server-component' export default function Page() { return ( <ClientComponent> {/* 该组件依然会在服务端中预先渲染 */} <ServerComponent /> </ClientComponent> ) }
Edge 和 Node.js 运行时
TODO