動態

詳情 返回 返回

深入探索 Next.js 中的流式渲染和分塊傳輸編碼 - 動態 詳情

hello 大家好,我是 superZidan,這篇文章想跟大家聊聊 Next.js 中的流式渲染和分塊傳輸機制 ,如果大家遇到任何問題,歡迎 聯繫我 或者直接微信添加 superZidan41

🔥🔥🔥 前方高能,乾貨滿滿,建議點贊➕關注➕收藏;

温馨提示:如果你還是個 Next.js 新手,建議先閲讀這篇 Next.js 最佳實踐,照着這篇文章先把代碼敲一遍

簡述

在本篇文章中,我們將深入探索組件流式(stream)渲染和分塊(chunk)傳輸編碼,探索 Next.js 如何運用這些技術來優化頁面內容傳輸以及提升用户體驗。我們還將研究 HTTP 傳輸的細微差別以及 Next.js 如何與 Web 瀏覽器的實現情況保持一致。看完本篇文章,我們將對如何使用Next.js 創建一個高效且優雅的 web 應用有一個更好的認識。讓我們馬上開始吧!🚀

什麼是流

在我們探索「組件流」之前,我們先了解概念「流」這個概念本身的意思。當你的瀏覽器發送一個 HTTP 請求到服務器時,服務器響應的內容大概是這樣的

HTTP/1.1 200 OK␍␊
Date: Sat 18 Nov 2023 12:28:53 GMT␍␊
Content-Length: 12␍␊
Content-Type: text/plain␍␊
␍␊
Hello World!

服務器響應的第一行,HTTP/1.1 200 OK 表示服務器已響應 200 OK,這意味着一切正常。然後在這後面,有三行響應頭信息。在這個例子中,這些頭分別是 Date,Content-Length 以及 Content-Type。我們可以認為他們是一些鍵值對,這些鍵和值是通過冒號來進行分隔的

在這些頭信息後面, 有一個空行來分隔響應頭和響應體。響應體的信息就在這個空行後面。根據響應頭的信息,我們的瀏覽器知道了兩個事情:

  1. 它需要下載 12 字節的內容(Hello World! 僅包含 12 個字符)
  2. 一旦下載完成,它可以顯示這些內容或者把這些內容返回給到一個 fetch 請求的回調函數中

換而言之,我們可以總結到,響應體內容就是在空行後面讀取 12 個字符之後就結束了

但是如果我們的響應頭沒有包含 Content-Length 會發生什麼事情呢?在這種情況下,很多 HTTP 服務器會自動為響應頭添加一個 Transfer-Encoding: chunked 這樣的響應頭信息。這個響應可以理解為:「我是服務器的響應,我並不清楚響應體中有多少內容,所以我會分塊(chunk)發送數據」

HTTP/1.1 200 OK␍␊
Date: Sat, 18 Nov 2023 12:28:53 GMT␍␊
Transfer-Encoding: chunked␍␊
Content-Type: text/plain␍␊
␍␊
5␍␊
Hello␍␊

在這個時候,我們僅僅接收了信息的前 5 個字節。值得注意的是,響應體的格式與相應頭不同。首先,chunk 的體積大小被髮送,然後緊跟着的是 chunk 本身的內容。在每個 chunk 後面,服務器都會添加一個 ␍␊ 序列

現在讓我們接收第二個 chunk

怎麼會出現這樣的情況呢?

HTTP/1.1 200 OK␍␊
Date: Sat, 18 Nov 2023 12:28:53 GMT␍␊
Transfer-Encoding: chunked␍␊
Content-Type: text/plain␍␊
␍␊
5␍␊
Hello␍␊
7␍␊
 World!␍␊

我們收到了額外的 7 個字節的響應。那麼在 Hello␍␊7␍␊ 之間發生了什麼事情呢?在這個間隔期間這個響應會如何處理呢?我們假設一下,如果在 7␍␊ 發送之前服務器需要有 10 秒的處理時間。如果你在處理期間查看瀏覽器開發人員工具的「網絡」選項卡,會看到服務器的響應已開始,並在這 10 秒內保持「進行中」狀態。這個是因為服務器還沒有發送響應已經結束的指示。

那麼當服務器已經發送「完畢」了,瀏覽器將如何檢測呢?答案是有一個約定。服務器需要發送 0␍␊␍␊ 這個序列。簡單來説就是,「我發送一個長度為 0 的 chunk 給你,表明已經沒有其他內容需要發送了」。在「網絡」選項卡,這個序列將會被標記為請求結束的時機。

HTTP/1.1 200 OK␍␊
Date: Sat, 18 Nov 2023 12:28:53 GMT␍␊
Transfer-Encoding: chunked␍␊
Content-Type: text/plain␍␊
␍␊
5␍␊
Hello␍␊
7␍␊
 World!␍␊
0␍␊
␍␊

瞭解 HTTP 傳輸

在 HTTP 頭信息中,瞭解 Content-Length:<number>Transfer-Encoding: chunked 的區別很重要。看到的第一眼,我們可能覺得 Content-Length:<number> 是表明響應體的數據不是流式的,但這不完全準確。雖然此響應頭指示要接收的數據的總長度,但這並不意味着數據作為單個大 chunk 來進行傳輸。在 HTTP 層之下,TCP/IP 等協議規定了實際的傳輸機制,這本質上涉及將數據分解為更小的數據包。

所以,雖然 Content-Length 表明系統一旦積累了指定數量的數據就已準備好進行渲染,但實際的數據傳輸是在較低層級增量執行的。一些現代瀏覽器利用這種內在的分包機制,甚至在接收到整個數據之前就啓動渲染過程。這對於用於漸進式渲染的特定數據格式特別有利。另一方面,Transfer-Encoding: chunked 對 HTTP 層的數據流提供了更明確的控制,在發送時標記每個數據塊(chunk)。這提供了更大的靈活性,特別是對於動態生成的內容或一開始就未知完整內容長度的情況。

<Suspense />

現在我們已經介紹了一個對於 Next.js 中的組件流式渲染至關重要的基本概念,在深入探討 <Suspense /> 之前,讓我們首先定義它要解決的問題。

現在讓我們為例子創建一個幫助函數

export function wait<T>(ms: number, data: T) {
  return new Promise<T>((resolve) => {
    setTimeout(() => resolve(data), ms);
  });
}

這個函數幫助我們創建一個長耗時的模擬請求。

使用 npx create-next-app@least 初始化一個 Next.js 應用

清除掉一些不需要的文件和代碼,複製下面的代碼到 app/page.tsx 這個文件中:

import { wait } from "@/helpers/wait";

const MyComponent = async () => {
  const data = await wait(10000, { name: "zidan" });
  return <p>{data.name}</p>;
};

export const dynamic = "force-dynamic";

export default async function Home() {
  return (
    <>
      <p>網頁靜態信息</p>
      <MyComponent />
    </>
 );

該結構由一個包含 「網頁靜態信息」 的 p 標籤和一個在輸出數據之前需要等待 10 秒的組件。

為了看到效果,執行 npm run build && npm run start ,然後在瀏覽器打開 http://localhost:3000

接下來會發生什麼事情呢?

在收到整個頁面內容(包括「網頁靜態信息」和“zidan”)之前,你會需要等待 10 秒的延遲。這意味着當 <MyComponent /> 獲取其數據時,用户將無法查看「網頁靜態信息」內容。這遠非理想狀態; 頁面會一直顯示正在加載的白屏狀態,然後在 10 秒之後向用户展示內容。

然而如果在組件外面套一個 <Suspense /> 然後再重新嘗試一下,我們可以馬上就看到內容。讓我們來深挖一下這個方法。

我們把組件包裹在 <Suspense /> 裏面並且給 fallback 賦一個值為 “數據正在加載,請稍等...” 這樣的文案。

export default async function Home() {
  return (
    <>
      <p>網頁靜態信息</p>
      <Suspense fallback={"數據正在加載,請稍等..."}>
        <MyComponent />
      </Suspense>
    </>
  );
}

現在我們打開瀏覽器

p1.png

現在,我們觀察到作為 <Suspense />fallback 屬性提供的字符串(數據正在加載,請稍等...)暫時代表 <MyComponent /> 先顯示出來。然後在 10 秒之後,真正組件的內容再顯示出來

讓我們查看一下收到的 HTML 響應。

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <!-- Omitted -->
    </head>
    <body class="__className_20951f">
        <p>網頁靜態信息</p><!--$?-->
        <template id="B:0"></template>
        數據正在加載,請稍等...<!--/$-->
        <script src="/_next/static/chunks/webpack-f0069ae2f14f3de1.js" async=""></script>
        <script>(self.__next_f = self.__next_f || []).push([0])</script>
        <script>self.__next_f.push(/* Omitted */)</script>
        <script>self.__next_f.push(/* Omitted */)</script>
        <script>self.__next_f.push(/* Omitted */)</script>
        <script>self.__next_f.push(/* 還沒有一個關閉的 script 標籤...

雖然我們還沒有收到完整的頁面,但我們已經可以在瀏覽器中查看其內容了。這是怎麼做到的?這種行為是由於現代瀏覽器的 容錯能力 造成的。考慮這樣一個場景:你訪問一個網站,但由於開發人員忘記關閉標籤,該網站無法正確顯示。儘管瀏覽器開發人員可以強制執行嚴格的無錯誤 HTML,但這樣的決定會降低用户體驗。作為用户,我們希望網頁能夠加載並顯示其內容,無論底層代碼中是否存在小錯誤。為了確保這一點,瀏覽器在底層實現了多種機制來彌補此類問題。例如,如果有一個打開的 <body> 標籤尚未關閉,瀏覽器將自動“關閉”它。這樣做是為了提供最佳的用户體驗,即使面對不完美的 HTML 也是如此。

很明顯,Next 在實現組件流式渲染時利用了這種固有的瀏覽器行為。通過推送可用的內容塊,並利用瀏覽器能過解析和渲染部分甚至稍微畸形的內容的能力,Next.js 可確保更快的加載時間並增強用户體驗。這種方法的優點在於它符合網絡瀏覽的實際情況。 用户通常更喜歡即時反饋,即使是增量反饋,也不願等待整個頁面加載。Next.js 會將準備好的內容進行分塊傳輸,所以很好的滿足了用户的這種瀏覽偏好。

現在,觀察這個片段

    <!--$?-->
      <template id="B:0"></template>
      數據正在加載,請稍等...
    <!--/$-->

我們可以發現佔位符文本與帶有 B:0 id 的空 <template> 標籤相鄰。此外,我們可以看出來自 localhost:3000 的響應仍在進行中。後面的 script 標籤保持未關閉狀態。 Next.js 使用佔位符模板為即將填充下一個 chunk 的 HTML 騰出空間。

下一個 chunk 到達之後,我們就有了以下這個標籤內容

$RCcompleteBoundary 函數,可以在 此處 找到帶註釋的版本
    <p>網頁靜態信息</p>

    <!--$?-->
    <template id="B:0"></template>
    數據正在加載,請稍等...
    <!--/$-->

    <!-- <script> tags omitted -->

    <div hidden id="S:0">
      <p>zidan</p>
    </div>

    <script>
      $RC = function (b, c, e) {
        c = document.getElementById(c);
        c.parentNode.removeChild(c);
        var a = document.getElementById(b);
        if (a) {
          b = a.previousSibling;
          if (e)
            b.data = "$!",
              a.setAttribute("data-dgst", e);
          else {
            e = b.parentNode;
            a = b.nextSibling;
            var f = 0;
            do {
              if (a && 8 === a.nodeType) {
                var d = a.data;
                if ("/$" === d)
                  if (0 === f)
                    break;
                  else
                    f--;
                else
                  "$" !== d && "$?" !== d && "$!" !== d || f++
              }
              d = a.nextSibling;
              e.removeChild(a);
              a = d
            } while (a);
            for (; c.firstChild;)
              e.insertBefore(c.firstChild, a);
            b.data = "$"
          }
          b._reactRetry && b._reactRetry()
        }
      }
      ;
      $RC("B:0", "S:0")
    </script>

我們收到一個隱藏的 <div>,其 id="S:0"。 這包含 <MyComponent /> 的 HTML 內容。 除此之外,我們還看到了一個有趣的腳本,它定義了一個全局變量 $RC。 此變量指向一個使用 getElementByIdinsertBefore 執行某些操作的函數。

腳本中的最後語句 $RC("B:0", "S:0") 調用上述函數並使用 “B:0”“S:0”作為參數。 正如我們所推斷的,B:0 對應於之前保留我們後備的模板的 ID。 同時,S:0是新獲取的<div>的 ID。 為了提取此信息,$RC 函數本質上指出:“從 S:0 div 中獲取標籤並將其放置在 B:0 模板所在的位置。”

以下是該段落的簡單總結,為了更加清晰的表達,我對內容進行分段:

  1. 啓動分段(chunked)傳輸:Next.js 設置了 Transfer-Encoding:chunked 響應頭信息,告訴瀏覽器響應的內容長度在這個階段暫時是不確定的。
  2. 頁面執行:當頁面執行時,不會遇到任何等待操作。 這意味着沒有數據獲取會阻止立即發送響應
  3. 處理 Suspense:處理到 <Suspense /> 標籤後,Next.js 使用 fallback 的值立即渲染,同時插入佔位符 <template /> 標籤。 稍後一旦準備好,將使用它來插入實際的 HTML。
  4. 對瀏覽器的初始響應:需要渲染的內容將發送到瀏覽器。 然而只要 0␍␊␍␊ 這個終止序列尚未發送,就表明瀏覽器應該需要準備接收更多數據的到來。
  5. 組件數據請求<MyComponent /> 與服務器進行通信,請求需要的數據,相當於在説:“我們需要你的內容,當你準備好時請告訴我們。”
  6. 組件渲染<MyComponent /> 獲取數據後,會渲染並生成相應的 HTML
  7. 發送組件的 HTML:然後該 HTML 作為新 chunk 發送到瀏覽器
  8. JavaScript 執行:然後瀏覽器的 JavaScript 會將這個新的 HTML 塊添加到之前在步驟3中生成的 <template /> 標籤的位置。
  9. 終止序列:最後服務器發送終止序列 0␍␊␍␊ ,表示響應結束。

深入探索多個 Suspense

處理單個 <Suspense /> 標籤很簡單,但如果頁面有多個這樣的標籤怎麼辦呢? Next.js 如何應對這種情況呢? 有趣的是,核心方法並沒有太大偏差。 以下是管理多個 <Suspense /> 標籤時會發生的一些事情:

  1. Fallback 相關:每個 <Suspense /> 標籤都設置了自己的 fallback 值。 在渲染階段,同時利用所有這些 fallback 值,確保每個 <Suspense /> 組件為用户提供臨時的內容。 這是我們之前列出的第三點的延伸。
  2. 統一內容請求:就像單個 <Suspense /> 一樣,Next.js 向 <Suspense /> 標籤中包含的所有組件發出統一的調用。 它本質上是廣播,一旦組件準備好就響應對應的內容。
  3. 等待所有的組件:終止序列至關重要,它表示響應的結束。 在具有多個 <Suspense /> 標籤的情況下,直到每個組件都發送其內容後終止序列才會被髮送。 這確保瀏覽器可以呈現所有組件的內容,從而為用户提供完整的頁面視圖。

總結

本篇文章的所有內容就是這樣了! 希望你能夠喜歡這次的內容。通過利用瀏覽器的原生行為並優化內容傳輸,Next.js 可確保用户等待時間最短並儘快看到內容。 作為開發人員,瞭解這些細微差別不僅使我們的技術更加出色,而且使我們能夠為用户更加絲滑的用户體驗。與往常一樣,如果你有任何疑問,請隨時與我聯繫或發表評論。 祝你編程愉快!

user avatar linlinma 頭像 zourongle 頭像 soroqer 頭像 xiaolei_599661330c0cb 頭像 nqbefgvs 頭像 ecomools 頭像 code500g 頭像 zhoumo_62382eba4b454 頭像 sy_records 頭像 happy2332333 頭像 morimanong 頭像 zohocrm 頭像
點贊 43 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.