CSR、SSR、SSG、ISR 与 NextJS

1,286 阅读9分钟

引言

在现代前端开发中, 页面渲染技术的选择对于用户体验和性能优化至关重要。随着互联网应用的复杂度不断增加, 开发者需要在客户端渲染(CSR)、服务端渲染(SSR)、静态站点生成(SSG)以及增量静态再生(ISR)等多种渲染策略中做出明智的选择。每种技术都有其独特的优势和适用场景, 而了解它们的原理和实现方式, 能够帮助我们更好地构建高效、快速且用户友好的网站。本文将深入探讨这四种渲染技术, 结合 NextJS 的实际应用案例, 为你揭开它们的神秘面纱。

一、CSR: 客户端渲染(Client-side Rendering)

所谓客户端渲染, 指的是客户端(浏览器)访问页面, 获取到的 HTML 是一个很小的文件, 只包含一个 根节点 和一些所需引用的资源信息(JSCSS...), 而整个页面的渲染则是在浏览器也就是客户端完成。

image

因为 HTML 体积较小, 所以请求页面响应较快, 即 TTFB (Time To First Byte) 数据较低, 但是呢在加载完 HTML 浏览器还需要去加载相关 JS 资源, 然后在 JS 中会执行发送请求、获取数据、更新 DOM 和渲染页面等操作, 最终才能动态的将页面完整渲染出来。

我们传统使用 React 的方式, 其实就是所谓的客户端渲染。 我们在打包时, 会提供一个模版文件(HTML), 模版文件内只包含一个有效的根节点, 还有一些基本的 head 信息。在打包时 Webpack 则会将相关资源(JSCSS)处理完成后, 插入到模版文件内。

image

对于客户端来说, 访问页面并不会获取到完整的 HTML, 我们获取到的只是基础的框架和信息, 客户端需要去加载这些资源(JSCSS), 加载完成后通过 JS 去动态的生成 DOM 并且完成相关交互事件的绑定, 最终呈现给用户一个完整的界面

1.2 NextJS 客户端渲染

App Router 下, 我们只需要在页面组件顶部通过 use client 即可将页面标记为客户端渲染

如下代码(src/app/csr/page.tsx)所示, 我们在 页面组件 的顶部使用 use client

'use client';

import { useEffect, useState } from 'react';

export default function Page() {
  const [listData, setListData] = useState<string[]>();

  useEffect(() => {
    fetch('/api/mock')
      .then((res) => res.json())
      .then((data) => setListData(data));
  }, []);

  return (
    <div>
      {listData?.map((item) => (
        <div
          className="my-2 bg-red-100 rounded-md"
          key={item}>
          {item}
        </div>
      ))}
    </div>
  );
}

这时我们访问该页面, 会发现访问到的 HTML 是一个空壳子

image

而所有页面的渲染则是在客户端(浏览器)进行, 在获取到基本的 HTML 后, 客户端会去加载所需的资源(JSCSS..)然后执行 JS 获取数据、渲染页面...

image

二、SSR: 服务端渲染(Server-side Rendering)

顾名思义服务端渲染, 就是用户在请求页面时, 完整的 HTML 内容在服务端就渲染完成, 渲染完成之后直接返回给客户端(浏览器)。

比如我们访问一篇博客文章页面, 我们完全可以直接在服务器端直接获取到文章数据, 然后直接根据内容渲染成完整的 HTML 后将其返回给用户。

image

虽然同样是发送请求, 但通常服务端的环境(网络环境、设备性能)要好于客户端, 甚至它们本身就是部署在同一个机器上, 所以最终的渲染速度(首屏加载时间)必然会比客户端渲染来得快速。

2.1 NextJS 服务端渲染

NextJS 中默认渲染模式就是 服务端渲染。 如上文所诉如需要采用 客户端渲染 只需要在组件文件顶部设置 use client 即可, 反正如果不设置则采用 服务端渲染

如下代码(src/app/ssr/page.tsx)所示, page 页面我们直接返回一个异步组件, 组件内直接使用 fetch 请求数据, 最终使用请求到的数据来进行渲染

export default async function Page() {
  const listData = await fetch('http://localhost:3000/api/mock')
    .then((res) => res.json())
    .then((data) => data);

  return (
    <div>
      {listData?.map((item: string) => (
        <div
          className="my-2 bg-red-100 rounded-md"
          key={item}>
          {item}
        </div>
      ))}
    </div>
  );
}

而上诉这一切都在服务端发生, 最终客户访问页面拿到的 HTML 是完整的内容

image

三、SSG: 静态站点生成(Static Site Generation)

静态站点生 成即 SSG 是直接在 Build 阶段就直接根据数据返回所有完整的静态的 HTML 文件。客户端(浏览器)在访问页面时就可以直接将对应的 HTML 文件返回给客户端即可。

image

假设我们有个 GitHub 项目, 该项目下存放着都是一个个 Mrkdown 文章。我们完全可以使用 静态站点生(SSG) 的方式将一个个 Markdown 文件构建出对应的一个个 HTML 文件。而这里我们就可以借用 GitHub Actions 当项目下 Markdown 发生变更, 执行对应 Action 使用 静态站点生(SSG) 方式构建出所以 HTML 文件, 并发布为 GitHub Page

image

3.1 NexJS 静态站点生成

开始前我们先创建个页面(src/app/blog/[slug]/page.tsx), 如下所示:

image

这里有两个点:

  • 页面组件导出 generateStaticParams 函数或者导出 dynamic = 'force-static'; 则 NextJS 会将当前页面视作 静态站点生成SSG
  • 页面组件如果导出 dynamicParams = false 则会关闭当前页面动态路由特性, 也就是说客户端如果访问不存在的页面(生成的静态页面)则会展示 404 页面
  1. 页面组件导出 generateStaticParams 函数, 该函数返回一个数组(即便为空也要返回一个空数组)。返回的数组是要静态生成的, 所有页面对应的动态路由的参数列表。通过该列表, 服务端就可以批量渲染出一堆的静态页面。
// SSG 路由参数 生成函数, 返回一个数组, 参数中「slug」可填充路由中「slug」字段, 剩余参数随意(后续可用于渲染页面)
export async function generateStaticParams() {
  const listData = await fetch('http://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store',
  }).then((response) => response.json());

  // 返回数组, 数组项就是对应的所有要生成页面对应的路由参数
  return listData.map((item: { id: number }) => ({ slug: item.id.toString() }));
}

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );

  return <div className="bg-red-100">{title}</div>;
}

下面是 Next Build 的一个结果:

image

  1. 这里 generateStaticParams 我们也可以直接返回一个空数组, 那么当我们执行 Next Build 时, 不会去构建任何页面。然后当有用户首次访问 /blog/xxx 页面时。服务端会先判断是否已构建该页面, 如果没有则会进行页面的构建, 对应页面构建完成后将其返回给用户的同时会将该页面缓存起来, 供后续请求使用。
// SSG 路由参数 生成函数: 返回一个空数组
export async function generateStaticParams() {
  return [];
}

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );

  return <div className="bg-red-100">{title}</div>;
}

如下所示, 每访问一个新的页面, 才会构建对应的 HTML

image

这里不要求我们返回一个空数组, 我们可以在 Next Build 时先构建出一部分页面, 其他的则按需构建并缓存。如下代码所示, generateStaticParams 函数我只返回了前 10 条数据, 那么在 Next Build 也只会构建这 10 个对应的页面。后续如果用户访问未构建的页面也是一样的, 直接在服务端进行渲染后缓存并返回, 后续的访问则直接使用缓存的页面。

// SSG 路由参数 生成函数, 返回一个数组, 参数中「slug」可填充路由中「slug」字段, 剩余参数随意(后续可用于渲染页面)
export async function generateStaticParams() {
  const listData = await fetch('http://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store',
  }).then((response) => response.json());

  return listData.slice(0, 10).map((item: { id: number }) => ({ slug: item.id.toString() }));
}

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );

  return <div className="bg-red-100">{title}</div>;
}
  1. 这里我们是定义了一个动态路由页面, 所以理论上访问 /blog/xxx, 这里 xxx 可以是任意值, 它都能访问到该页面, 然后对应页面如果不存在就会尝试构建新的页面。这里我们可以在模块内导出 dynamicParams = false 将动态路由该特性给关闭。那么我们也就只能通过 /blog/xxx 访问到在 Next Build 阶段构建出来的页面, 访问其他路由则会展示 404 页面
+ export const dynamicParams = false;

// SSG 路由参数 生成函数, 返回一个数组, 参数中「slug」可填充路由中「slug」字段, 剩余参数随意(后续可用于渲染页面)
export async function generateStaticParams() {
  const listData = await fetch('http://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store',
  }).then((response) => response.json());

  return listData.slice(0, 10).map((item: { id: number }) => ({ slug: item.id.toString() }));
}

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );

  return <div className="bg-red-100">{title}</div>;
}
  1. 这里我们也可以通过导出 dynamic = 'force-static'; 将当前页面设置为强制静态缓存, 即 静态站点生成(SSG) 这样我们就可以不用额外导出 generateStaticParams 了。这里最终效果和 generateStaticParams 直接返回一个空数组的效果是一样的。
+ export const dynamic = 'force-static';

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );

  return <div className="bg-red-100">{title}</div>;
}
  1. 当然如果我们即没有导出generateStaticParams 函数, 也没导出 dynamic = 'force-static'; 那该页面就是所谓的 服务端渲染(SSR) 了, 用户每次访问页面(即便是同一个页面)都会重新获取数据、渲染页面, 最后返回一个完整的 HTML 内容给客户端(浏览器)
// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );
  console.log('%c [ title ]-18', 'font-size:13px; background:pink; color:#bf2c9f;', title);
  return <div className="bg-red-100">{title}</div>;
}

下面我们看两张图

第一张是 Next Build 的一个逻辑

image

另一个是当用户访问该页面时的一个逻辑

image

更多细节参考: NextJS - generateStaticParams

四、ISR: 增量静态再生(Incremental Static Regeneration)

我们拿一个博客文章举例, 博客的主体内容也许是不变的, 但像比如点赞、收藏、评论这些数据总是在变化的。如果我们只是简单使用静态站点生成(SSG)成 HTML 文件后, 这些数据就无法准确获取了。

那么这里就可以使用增量静态再生(ISR), 它实际上是在静态站点生成(SSG)之上进行一个升级, 有点 按需生成 + 懒加载 的意思。它的完整逻辑如下:

  1. Build 阶段, 其实就是静态站点生成(SSG)的流程
  2. 后续用户只要访问页面, 服务端只要存在缓存(旧 HTML)则直接返回给用户, 没有则服务端进行渲染生成(或者 404, 这里根据实际情况决定)
  3. 用户访问页面的同时, 后台会去检测本地缓存时效是否过期, 如果过期的话会重新去渲染生成新的 HTML, 供后来者访问使用
  4. 也就是说, 用户的访问是触发检测缓存是否有效的一个时机, 但是用户本地拿到的都是旧的 HTML, 只是如果缓存失效的话会触发更新, 给后续的访问使用

image

4.1 NextJS 增量静态再生

NextJS 要实现 增量静态再生 就很简单了, 只需要在 静态站点生成SSG 的基础上新导出配置 revalidate 配置即可, 该配置表示页面缓存有效时长(单位秒)

// Next.js will invalidate the cache when a
// request comes in, at most once every 60 seconds.
+ export const revalidate = 60;

// We'll prerender only the params from `generateStaticParams` at build time.
// If a request comes in for a path that hasn't been generated,
// Next.js will server-render the page on-demand.
export const dynamicParams = true; // or false, to 404 on unknown paths
// SSG 路由参数 生成函数, 返回一个数组, 参数中「slug」可填充路由中「slug」字段, 剩余参数随意(后续可用于渲染页面)
export async function generateStaticParams() {
  const listData = await fetch('http://jsonplaceholder.typicode.com/posts', {
    cache: 'no-store',
  }).then((response) => response.json());

  return listData.map((item: { id: number }) => ({ slug: item.id.toString() }));
}

// 渲染页面
export default async function Page({ params }: { params: Promise<{ title: string; slug: string }> }) {
  const { slug } = await params;
  const { title } = await fetch(`http://jsonplaceholder.typicode.com/posts/${slug}`).then((response) =>
    response.json(),
  );
  return <div className="bg-red-100">{title}</div>;
}

五、最后扯两句

  1. 本文简单介绍了下几个场景的渲染方式, 并介绍了 NextJS 下各个方式实现方式。然而 NextJS 最新版本也就是 App Router 下, 引入了 服务端组件客户端组件 的概念, 服务端渲染客户端渲染 的概念就有所弱化了。当然实际上 服务端组件 也算是 服务端渲染, 只是现在 NextJS 整体更像是 混合模式, 一个页面可以即包括 服务端渲染服务端组件 也可以包含 客户端渲染客户端组件
  2. NextJS 中多种渲染方式是可以同时共存的, 只需要你在编写页面代码时, 遵循相关约定或者调用相关 API 即可切换到对应的渲染方式

六、参考