JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第09章—渲染篇Suspense与Streaming

首次发表于 2024-03-22, 更新于 2024-03-22

前言

Suspense 是 Next.js 项目中常用的一个组件,了解其原理和背景有助于我们正确使用 Suspense 组件。

传统 SSR

在最近的两篇文章里,我们已经介绍了 SSR 的原理和缺陷。简单来说,使用 SSR,需要经过一系列的步骤,用户才能查看页面、与之交互。具体这些步骤是:

  1. 服务端获取所有数据
  2. 服务端渲染 HTML
  3. 将页面的 HTML、CSS、JavaScript 发送到客户端
  4. 使用 HTML 和 CSS 生成不可交互的用户界面(non-interactive UI)
  5. React 对用户界面进行水合(hydrate),使其可交互(interactive UI)

image.png

这些步骤是连续的、阻塞的。这意味着服务端只能在获取所有数据后渲染 HTML,React 只能在下载了所有组件代码后才能进行水合:

image.png

还记得上篇总结的 SSR 的几个缺点吗?

  1. SSR 的数据获取必须在组件渲染之前
  2. 组件的 JavaScript 必须先加载到客户端,才能开始水合
  3. 所有组件必须先水合,然后才能跟其中任意一个组件交互

Suspense

为了解决这些问题,React 18 引入了 <Suspense> 组件。我们来介绍下这个组件:

<Suspense> 允许你推迟渲染某些内容,直到满足某些条件(例如数据加载完毕)。

你可以将动态组件包装在 Suspense 中,然后向其传递一个 fallback UI,以便在动态组件加载时显示。如果数据请求缓慢,使用 Suspense 流式渲染该组件,不会影响页面其他部分的渲染,更不会阻塞整个页面。

让我们来写一个例子,新建 app/dashboard/page.js,代码如下:

import { Suspense } from 'react'

const sleep = ms => new Promise(r => setTimeout(r, ms));

async function PostFeed() {
  await sleep(2000)
  return <h1>Hello PostFeed</h1>
}

async function Weather() {
  await sleep(8000)
  return <h1>Hello Weather</h1>
}

async function Recommend() {
  await sleep(5000)
  return <h1>Hello Recommend</h1>
}

export default function Dashboard() {
  return (
    <section style={{padding: '20px'}}>
      <Suspense fallback={<p>Loading PostFeed Component</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading Weather Component</p>}>
        <Weather />
      </Suspense>
      <Suspense fallback={<p>Loading Recommend Component</p>}>
        <Recommend />
      </Suspense>
    </section>
  )
}

在这个例子中,我们用 Suspense 包装了三个组件,并通过 sleep 函数模拟了数据请求耗费的时长。加载效果如下:

suspense.gif

可是 Next.js 是怎么实现的呢?

让我们观察下 dashboard 这个 HTML 文件的加载情况,你会发现它一开始是 2.03s,然后变成了 5.03s,最后变成了 8.04s,这不就正是我们设置的 sleep 时间吗?

查看 dashboard 请求的响应头:

截屏2024-03-04 22.47.51.png

Transfer-Encoding 标头的值为 chunked,表示数据将以一系列分块的形式进行发送。

分块传输编码(Chunked transfer encoding)是超文本传输协议(HTTP)中的一种数据传输机制,允许 HTTP由网页服务器发送给客户端应用( 通常是网页浏览器)的数据可以分成多个部分。分块传输编码只在 HTTP 协议1.1版本(HTTP/1.1)中提供。

再查看 dashboard 返回的数据(这里我们做了简化):

<!DOCTYPE html>
<html lang="en">
    <head>
        // ...
    </head>
    <body class="__className_aaf875">
        <section style="padding:20px">
            <!--$?-->
            <template id="B:0"></template>
            <p>Loading PostFeed Component</p>
            <!--/$-->
            <!--$?-->
            <template id="B:1"></template>
            <p>Loading Weather Component</p>
            <!--/$-->
            <!--$?-->
            <template id="B:2"></template>
            <p>Loading Recommend Component</p>
            <!--/$-->
        </section>
        // ...
        <div hidden id="S:0">
            <h1>Hello PostFeed</h1>
        </div>
        <script>
            // 交换位置
            $RC = function(b, c, e) {
                // ...
            };
            $RC("B:0", "S:0")
        </script>
        <div hidden id="S:2">
            <h1>Hello Recommend</h1>
        </div>
        <script>
            $RC("B:2", "S:2")
        </script>
        <div hidden id="S:1">
            <h1>Hello Weather</h1>
        </div>
        <script>
            $RC("B:1", "S:1")
        </script>
    </body>
</html>

可以看到使用 Suspense 组件的 fallback UI 和渲染后的内容都会出现在该 HTML 文件中,说明该请求持续与服务端保持连接,服务端在组件渲染完后会将渲染后的内容追加传给客户端,客户端收到新的内容后进行解析,执行类似于 $RC("B:2", "S:2")这样的函数交换 DOM 内容,使 fallback UI 替换为渲染后的内容。

这个过程被称之为 Streaming Server Rendering(流式渲染),它解决了上节说的传统 SSR 的第一个问题,那就是数据获取必须在组件渲染之前。使用 Suspense,先渲染 Fallback UI,等数据返回再渲染具体的组件内容。

使用 Suspense 还有一个好处就是 Selective Hydration(选择性水合)。简单的来说,当多个组件等待水合的时候,React 可以根据用户交互决定组件水合的优先级。比如 Sidebar 和 MainContent 组件都在等待水合,快要到 Sidebar 了,但此时用户点击了 MainContent 组件,React 会在单击事件的捕获阶段同步水合 MainContent 组件以保证立即响应,Sidebar 稍后水合。

总结一下,使用 Suspense,可以解锁两个主要的好处,使得 SSR 的功能更加强大:

  1. Streaming Server Rendering(流式渲染):从服务器到客户端渐进式渲染 HTML
  2. Selective Hydration(选择性水合):React 根据用户交互决定水合的优先级

Suspense 会影响 SEO 吗?

首先,Next.js 会等待 generateMetadata 内的数据请求完毕后,再将 UI 流式传输到客户端,这保证了响应的第一部分就会包含 <head> 标签。

其次,因为 Streaming 是流式渲染,HTML 中会包含最终渲染的内容,所以它不会影响 SEO。

Suspense 如何控制渲染顺序?

在刚才的例子中,我们是将三个组件同时进行渲染,哪个组件的数据先返回,就先渲染哪个组件。

但有的时候,希望按照某种顺序展示组件,比如先展示 PostFeed,再展示Weather,最后展示Recommend,此时你可以将 Suspense 组件进行嵌套:

import { Suspense } from 'react'

const sleep = ms => new Promise(r => setTimeout(r, ms));

async function PostFeed() {
  await sleep(2000)
  return <h1>Hello PostFeed</h1>
}

async function Weather() {
  await sleep(8000)
  return <h1>Hello Weather</h1>
}

async function Recommend() {
  await sleep(5000)
  return <h1>Hello Recommend</h1>
}

export default function Dashboard() {
  return (
    <section style={{padding: '20px'}}>
      <Suspense fallback={<p>Loading PostFeed Component</p>}>
        <PostFeed />
        <Suspense fallback={<p>Loading Weather Component</p>}>
          <Weather />
          <Suspense fallback={<p>Loading Recommend Component</p>}>
            <Recommend />
          </Suspense>
        </Suspense>
      </Suspense>
    </section>
  )
}

那么问题来了,此时页面的最终加载时间是多少秒?是请求花费时间最长的 8s 还是 2 + 8 + 5 = 15s 呢?让我们看下效果:

suspense1.gif

答案是 8s,这些数据请求是同时发送的,所以当 Weather 组件返回的时候,Recommend 组件立刻就展示了出来。

注意:这也是因为这里的数据请求并没有前后依赖关系,如果有那就另讲了。

Streaming

介绍

Suspense 背后的这种技术称之为 Streaming。将页面的 HTML 拆分成多个 chunks,然后逐步将这些块从服务端发送到客户端。

image.png

这样就可以更快的展现出页面的某些内容,而无需在渲染 UI 之前等待加载所有数据。提前发送的组件可以提前开始水合,这样当其他部分还在加载的时候,用户可以和已完成水合的组件进行交互,有效改善用户体验。

image.png

Streaming 可以有效的阻止耗时长的数据请求阻塞整个页面加载的情况。它还可以减少加载第一个字节所需时间(TTFB)首次内容绘制(FCP),有助于缩短可交互时间(TTI),尤其在速度慢的设备上。

传统 SSR:

image.png

使用 Streaming 后:

image.png

使用

在 Next.js 中有两种实现 Streaming 的方法:

  1. 页面级别,使用 loading.jsx
  2. 特定组件,使用 <Suspense>

<Suspense> 上节已经介绍过,loading.jsx《路由篇 | App Router》也介绍过。这里分享一个使用 loading.jsx 的小技巧,那就是当多个页面复用一个 loading.jsx 效果的时候可以借助路由组来实现。

目录结构如下:

app                  
├─ (dashboard)       
│  ├─ about          
│  │  └─ page.js     
│  ├─ settings       
│  │  └─ page.js     
│  ├─ team           
│  │  └─ page.js     
│  ├─ layout.js      
│  └─ loading.js         

其中 app/(dashboard)/layout.js代码如下:

import Link from 'next/link'

export default function DashboardLayout({
  children,
}) {
  return (
    <section>
        <nav className="flex items-center justify-center gap-10 text-blue-600 mb-6">
          <Link href="/about">About</Link>
          <Link href="/settings">Settings</Link>
          <Link href="/team">Team</Link>
        </nav>
      {children}
    </section>
  )
}

app/(dashboard)/loading.js代码如下:

export default function DashboardLoading() {
  return  <div className="h-60 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">Loading</div>
}

app/(dashboard)/about/page.js代码如下:

const sleep = ms => new Promise(r => setTimeout(r, ms));

export default async function About() {
  await sleep(2000)
  return (
    <div className="h-60 flex-1 rounded-xl bg-teal-400 text-white flex items-center justify-center">Hello, About!</div>
  )
}

剩余两个组件代码与 About 组件类似。最终的效果如下:

suspense2.gif

在线查看效果和代码:CodeSandbox Loading

缺点

Suspense 和 Streaming 确实很好,将原本只能先获取数据、再渲染水合的传统 SSR 改为渐进式渲染水合,但还有一些问题没有解决。就比如用户下载的 JavaScript 代码,该下载的代码还是没有少,可是用户真的需要下载那么多的 Javascript 代码吗?又比如所有的组件都必须在客户端进行水合,对于不需要交互性的组件其实没有必要进行水合。

为了解决这些问题,目前的最终方案就是上一篇介绍的 RSC:

image.png

当然这并不是说 RSC 可以替代 Suspense,实际上两者可以组合使用,带来更好的性能体验。我们会在实战篇的项目中慢慢体会。

参考链接

  1. https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
  2. https://vercel.com/blog/how-streaming-helps-build-faster-web-applications
  3. https://www.builder.io/blog/why-react-server-components#suspense-for-server-side-rendering
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru