NextJs 渲染篇 - 什么是CSR、SSR、SSG、ISR 和服务端/客户端组件
- 前言
- 一. 什么是CSR、SSR、SSG、ISR
- 1.1 CSR 客户端渲染
- 1.2 SSR 服务端渲染
- 1.3 SSG 静态站点生成
- ① 没有数据请求的页面
- ② 页面内容需要请求数据
- ③ 页面路径需要获取数据
- 1.4 ISR 增量静态再生
- 1.5 四种渲染方式的对比和总结
- 二. 服务端组件和客户端组件
- 2.1 水合(Hydration)
- 2.2 Suspense 和 Streaming
- 2.3 React Server Components 和 SSR
- 2.4 服务端组件 VS 客户端组件
前言
在 NextJs 初级篇 中讲了关于NextJs
的安装、路由、中间件等内容,本篇文章来一起学习一下关于 NextJs 的渲染知识。
一. 什么是CSR、SSR、SSG、ISR
我们先来说下这几个名词的专业解释:
CSR(Client-side Rendering)
:客户端渲染。SSR(Server-side Rendering)
:服务端渲染。SSG(Static Site Generation)
:静态站点生成。ISR(Incremental Static Regeneration)
:增量静态再生。
接下来我们对每种渲染进行详细的解释以及NextJs
的实现案例。后续都用简称来说明。
1.1 CSR 客户端渲染
CSR
常规的实现就是我们常规的React
开发,就是一种客户端渲染:
- 一般浏览器会下载一个非常小的
HTML
文件以及必要的JS
文件。 - 我们在
JS
中发送请求,更新DOM
和渲染页面。比如useEffect
钩子函数中初始化页面数据。
NextJs
中,在AppRouter
模式下,使用CSR
,在组件中使用 'use client'
标明,用useEffect
请求初始化数据渲染即可,例如以下伪代码:
'use client'
import React, { useEffect, useState } from 'react'
const Home = () => {
const [data, setData] = useState<any>(null);
useEffect(() => {
setTimeout(() => {
setData({ id: 1 })
}, 5000);
})
return <>
{data ? <span id='test'>{data.id}</span> : 'Loading'}
</>
}
export default Home
刚开始的时候页面长这个样子:
渲染完毕后:
1.2 SSR 服务端渲染
SSR 服务渲染有啥好处,我们举个例子:假如客户端网速非常差,那么在CSR
的情况下,由客户端发起请求加载数据就会非常慢,倘若我们把加载数据的工作丢给服务端,而服务器的网络情况非常良好,那么最终的首屏加载时长FCP
也就更短。
但是同样的,由于SSR
情况下,它的响应时长还算上了数据的请求,因此响应时间更长,最终的TTFB
指标也就更长。
例如NextJs
中要想实现SSR
,我们可以在pages
目录下创建个ssr.tsx
文件:
内容如下,我们需要借助getServerSideProps
函数来获取数据并通过props
返回给前端组件,
// pages/ssr.js
export async function getServerSideProps() {
const data = [{ 'id': 1, 'name': 'ljj' }]
return { props: {data} }
}
// getServerSideProps 传入的是什么,这里就接收什么名称的参数
const SSR = ({ data }: any) =>{
return <span id='test'>{JSON.stringify(data)}</span>
}
export default SSR;
1.3 SSG 静态站点生成
SSR
,会在构建阶段,就将页面编译成一个静态的HTML
文件。
例如,当我们的站点,上面的Layout
总是一样的时候,或者是面对所有的用户,展示的都是一个内容,那么这块部分就没必要在用户请求页面的时候来渲染。干脆提前编译为HTML
文件,在用户访问的时候,直接返回一个HTML
则会更快。
NextJs
中实现SSG
,分为这么几种情况:
① 没有数据请求的页面
例如:
const SonA = () => {
return <>
我是SonA!!!
</>
}
export default SonA
这种页面,NextJs
在构建的时候就会生成一个单独的HTML
文件,
② 页面内容需要请求数据
如果我们的HTML
文件的某些内容,需要通过接口获取,那怎么办?这种方式就需要结合 getStaticProps
函数来使用。例如我们在pages目录下创建ssg.tsx
文件:
export default function SSG({ data }: any) {
return (
<ul>
{data.map((item: any) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)
}
export async function getStaticProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await res.json()
return {
props: {
data,
},
}
}
getStaticProps
这个函数,会在构建的时候被调用,然后通过props
属性传递给组件。
③ 页面路径需要获取数据
我们知道NextJs
中有一个动态路由,只需要将动态部分用[]
括起来即可,例如:
那如果我们希望这类路由的页面都通过SSG
来实现:
- blog/1
- blog/2
- …
如何实现?我们在 getStaticProps
的基础上,追加一个函数的实现 getStaticPaths
:
export default function Blog({ post }: any) {
return (
<>
<header>{post.title}</header>
<main>{post.body}</main>
</>
)
}
export async function getStaticPaths() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
const paths = posts.map((post: any) => ({
params: { id: String(post.id) },
}))
return { paths, fallback: false }
}
export async function getStaticProps({ params }: any) {
// 如果路由地址为 /posts/1, params.id 为 1
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`)
const post = await res.json()
return { props: { post } }
}
getStaticProps
用来定义获取的数据传递给HTML
getStaticPaths
则用来定义哪些路径将会实现SSG
。fallback
返回false
代表当访问这些静态路径以外的,则返回404.
当我们执行npm run build
的时候,可以看到构建产物如下,这些都是SSG
的产物。
1.4 ISR 增量静态再生
我们的一些页面例如博客,主题内容可能永远是不变的,但是部分内容是改变的,例如这篇博客的阅读量。在我们使用SSG
的情况下,这个HTML
文件就被固定生成了,那么如何让这个阅读量能够实时的改变呢?那么在SSG
的基础上,就有了ISR
。
- 在访问某个
SSG
页面的时候,可能依旧是老的HTML
内容。 - 但是与此同时,
NextJs
会静态编译一个新的HTML
文件。 - 那么在第二次访问的时候,就会变成新的
HTML
文件内容了。
我们在``案例的基础上,稍微改造一下:
export default function Blog({ post }: any) {
return (
<>
<header>{post.title}</header>
<main>{post.body}</main>
</>
)
}
export async function getStaticPaths() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
const paths = posts.map((post: any) => ({
params: { id: String(post.id) },
}))
return { paths, fallback: 'blocking' }
}
function getRandomInt(max: number) {
return Math.floor(Math.random() * max);
}
export async function getStaticProps({ params }: any) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${getRandomInt(100)}`)
const post = await res.json()
return {
props: { post },
revalidate: 3,
}
}
可以看到,我们在 getStaticProps
函数中,多暴露了一个属性:revalidate
。代表发生请求的时候,需要间隔多少秒才会更新页面,我这里填的是3,也就是3秒会刷新一次,构建新的HTML
。
注意:ISR
需要在生产环境下生效。因此我们npm run build
之后再npm run start
,
结果如下:
可以看到我们第一次访问以及接下来的3秒内,博客的内容都是一样的。但是3秒过后,博客的内容就发生了改变,实际上是HTML
刷新了。每3秒就会重新构建一个新的HTML
,缓存3秒的时长。
1.5 四种渲染方式的对比和总结
CSR | SSR | SSG | ISR | |
---|---|---|---|---|
名词解释 | 客户端渲染 | 服务端渲染 | 静态站点生成(即生成HTML 文件返回给客户端) | 增量静态再生 |
实现方式 | 例如React 的useEffect | 借助getServerSideProps 函数,在服务端请求数据并通过props 属性传递给组件 | ①没有数据请求的页面自动生成HTML ②文件内容则借助 getStaticProps 函数获取数据,再生成静态文件 ③ 动态路由则借助getStaticPaths 来指定生成HTML 的路径 | 在SSG的基础上getStaticProps函数追加暴露revalidate 属性,代表刷新HTML 的时长 |
优缺点 | 只有少量的静态文件先加载,由客户端发起请求触发渲染, TTFB 短。但是在网络特别差的情况下,会大大增加FCP (首屏加载时长) | 可以让初始化请求交给服务端完成,由服务端完成渲染,解决客户端网络不一的情况,FCP 缩短,但是会增加响应时长,TTFB 时长高。每次请求都会触发SSG 渲染。 | 可以让页面生成静态HTML ,在编译时机就可以完成构建,只会触发一次。 | 可以控制HTML 的刷新时长,在指定的时间范围内使用同一个HTML ,时间过后自动重新构建 |
二. 服务端组件和客户端组件
在第一节当中,我们讲到了SSR
,在 NextJs v12
之前,都是通过 getServerSideProps
这个函数来实现服务端渲染,即SSR
。
2.1 水合(Hydration)
SSR
服务端渲染,会将整个组件渲染为HTML
,但是HTML
是没有交互性的。而客户端在渲染HTML
之后,还需要等待JS
下载完毕并且执行,由JS
来赋予HTML
交互性,那么这个阶段就叫做水合。水合过后,内容就会变为可交互性。
那么SSR
有这么几个缺陷:
SSR
渲染,数据的获取必须在组件渲染之前。- 组件的
JS
必须先加载到客户端,才能开始水合。 - 所有组件都必须水合完毕,组件之间才能够进行交互。
因此,一旦有部分组件渲染慢了,就会导致整体的渲染效率降低。不仅如此,SSR
只能适用于页面的初始化加载,对于后续的页面交互、数据修改等操作,SSR
就无作用了。
2.2 Suspense 和 Streaming
上面提到了,服务端只能在获取所有数据后渲染 HTML
,React
只能在下载了所有组件代码后才能进行水合。
为了解决这个问题,就有了 Suspense
组件,它允许你推迟渲染某些内容,直到满足某些条件(例如数据加载完毕)
给个案例如下:
import { Suspense } from 'react'
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function Component1() {
await sleep(2000)
return <h1>Hello Component1</h1>
}
async function Component2() {
await sleep(3000)
return <h1>Hello Component2</h1>
}
async function Component3() {
await sleep(4000)
return <h1>Hello Component3</h1>
}
export default function MySuspense() {
return (
<section style={{ padding: '20px' }}>
<Suspense fallback={<p>Loading Component1</p>}>
<Component1 />
</Suspense>
<Suspense fallback={<p>Loading Component2</p>}>
<Component2 />
</Suspense>
<Suspense fallback={<p>Loading Component3</p>}>
<Component3 />
</Suspense>
</section>
)
}
效果如下:
这种方式我们可以看下请求头:
Transfer-Encoding
的值为 chunked
,表示允许 HTTP
由网页服务器发送给客户端应用( 通常是网页浏览器)的数据可以分成多个部分
倘若我们这三个组件都不使用Suspense
封装,效果如下:
整体的效果一目了然。不使用Suspense
封装的情况下,需要等待所有组件都渲染完毕才能完整的展示页面。
而 Suspense
背后的实现技术就叫做Streaming
。即将页面的HTML
拆分多个chunks
,逐步从服务端发送给客户端。有这么几个好处:
- 提前发送到客户端的组件,就可以提前进行水合,那么用户就可以和提前水合完毕的组件进行交互。
- 从页面性能角度来考虑就是:减少
TTFB
和FCP
以及TTI
的时长。有兴趣的可以看下我这篇文章 性能优化 - 前端性能监控和性能指标计算方式
传统的SSR
:
使用Streaming
之后:
那么在NextJs
中有两种实现Streaming
的方式:
- 针对组件级别:使用
Suspense
组件(就上面的案例)。 - 针对页面级别:使用
loading.tsx
。
例如这样的目录结构:
组件1:
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export default async function Component1() {
await sleep(2000)
return <h1>Hello Component1</h1>
}
组件2:
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export default async function Component2() {
await sleep(3000)
return <h1>Hello Component2</h1>
}
page.tsx
:
import Link from 'next/link'
export default function MySuspense({ children }) {
return (
<section>
<nav className="flex items-center justify-center gap-10 text-blue-600 mb-6">
<Link href="/suspense/component1">component1</Link>
<Link href="/suspense/component2">component2</Link>
</nav>
{children}
</section>
)
}
loading.tsx
:
export default async function loading() {
return <h1>loading....</h1>
}
效果如下:
2.3 React Server Components 和 SSR
RSC
(React Server Components
)和 SSR
的区别
RSC
:重点在Components
,即组件。提供了更细粒度的组件渲染方式,可**以在组件中直接获取数据。组件依赖的代码并不会打包到bundle
中。并且只有在客户端请求相关组件的时候才会返回。 **SSR
:重点在Rendering
,即渲染。在服务端将组件渲染成HTML
发送给客户端,因此SSR
需要将组件的所有依赖都打包到bundle
中。
Suspense
以及Streaming
的实现确实能优化我们的页面渲染,将原本只能先获取数据、再渲染水合的传统 SSR
改为渐进式渲染水合 。
但是对于用户需要下载的JS
代码量依旧是没有减少。因此使用RSC
,服务端组件,就能将不必要的代码隐藏到服务器当中。
2.4 服务端组件 VS 客户端组件
在 NextJs
中,组件默认就是服务端组件。这类组件,请求会在服务端执行,最后会将组件渲染成HTML
返回给客户端,例如以下就是一个服务端组件的例子:
const Address = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = (await res.json()).slice(0, 10)
console.log(data)
return <ul>
{data.map(({ title, id }: any) => {
return <li key={id}>{title}</li>
})}
</ul>
}
export default Address
相关的console
打印会在服务端执行:
数据的渲染也会直接在HTML
当中。
那么再来看下对应的客户端组件版本:
- 使用
'use client'
声明。 - 配合
useEffect
钩子函数
'use client'
import { useEffect, useState } from 'react';
const Address = () => {
const [list, setList] = useState([]);
const fetchData = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json())
setList(data)
}
useEffect(() => {
fetchData()
}, [])
return <ul>
{list.map(({ title, id }: any) => {
return <li key={id}>{title}</li>
})}
</ul>
}
export default Address
两者对比的优势如下:
服务端组件 | 客户端组件 | |
---|---|---|
优势 | ① 数据获取更快。 ② 安全(服务端逻辑不会暴露给前端) ③ 缓存(服务端渲染的结果可缓存) ④ 服务端组件的代码不会打包到bundle 中 ⑤ FCP 时长更短 ⑥ 可以使用Streaming ,将渲染工作拆分为chunks ,通过流式传输到客户端,用户可以更早的看到部分页面,而无需等待整个页面渲染完毕。 | ① 交互性更好,可以使用useEffect、useState 等钩子函数。 ② 可以使用浏览器的API |
劣势 | 不可使用useEffect、useState 等钩子函数,也就无法管理状态 | 网络很差的情况下,由客户端完成渲染会导致FCP 特别长 |
运行时机 | 服务端组件运行在构建时和服务端 | 运行在构建时、服务端(生成初始HTML )和客户端(管理DOM ) |
除此之外,还有几个非常重要的点:
- 服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件。
- 服务端组件当导入到客户端组件中,就会被认为是客户端组件。