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。我們可以認為他們是一些鍵值對,這些鍵和值是通過冒號來進行分隔的
在這些頭信息後面, 有一個空行來分隔響應頭和響應體。響應體的信息就在這個空行後面。根據響應頭的信息,我們的瀏覽器知道了兩個事情:
- 它需要下載 12 字節的內容(
Hello World!僅包含 12 個字符) - 一旦下載完成,它可以顯示這些內容或者把這些內容返回給到一個
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>
</>
);
}
現在我們打開瀏覽器
現在,我們觀察到作為 <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 到達之後,我們就有了以下這個標籤內容
$RC是completeBoundary函數,可以在 此處 找到帶註釋的版本
<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。 此變量指向一個使用 getElementById 和 insertBefore 執行某些操作的函數。
腳本中的最後語句 $RC("B:0", "S:0") 調用上述函數並使用 “B:0”和“S:0”作為參數。 正如我們所推斷的,B:0 對應於之前保留我們後備的模板的 ID。 同時,S:0是新獲取的<div>的 ID。 為了提取此信息,$RC 函數本質上指出:“從 S:0 div 中獲取標籤並將其放置在 B:0 模板所在的位置。”
以下是該段落的簡單總結,為了更加清晰的表達,我對內容進行分段:
- 啓動分段(chunked)傳輸:Next.js 設置了
Transfer-Encoding:chunked響應頭信息,告訴瀏覽器響應的內容長度在這個階段暫時是不確定的。 - 頁面執行:當頁面執行時,不會遇到任何等待操作。 這意味着沒有數據獲取會阻止立即發送響應
- 處理 Suspense:處理到
<Suspense />標籤後,Next.js 使用 fallback 的值立即渲染,同時插入佔位符<template />標籤。 稍後一旦準備好,將使用它來插入實際的 HTML。 - 對瀏覽器的初始響應:需要渲染的內容將發送到瀏覽器。 然而只要
0␍␊␍␊這個終止序列尚未發送,就表明瀏覽器應該需要準備接收更多數據的到來。 - 組件數據請求 :
<MyComponent />與服務器進行通信,請求需要的數據,相當於在説:“我們需要你的內容,當你準備好時請告訴我們。” - 組件渲染:
<MyComponent />獲取數據後,會渲染並生成相應的 HTML - 發送組件的 HTML:然後該 HTML 作為新 chunk 發送到瀏覽器
- JavaScript 執行:然後瀏覽器的 JavaScript 會將這個新的 HTML 塊添加到之前在步驟3中生成的
<template />標籤的位置。 - 終止序列:最後服務器發送終止序列
0␍␊␍␊,表示響應結束。
深入探索多個 Suspense
處理單個 <Suspense /> 標籤很簡單,但如果頁面有多個這樣的標籤怎麼辦呢? Next.js 如何應對這種情況呢? 有趣的是,核心方法並沒有太大偏差。 以下是管理多個 <Suspense /> 標籤時會發生的一些事情:
- Fallback 相關:每個
<Suspense />標籤都設置了自己的fallback值。 在渲染階段,同時利用所有這些fallback值,確保每個<Suspense />組件為用户提供臨時的內容。 這是我們之前列出的第三點的延伸。 - 統一內容請求:就像單個
<Suspense />一樣,Next.js 向<Suspense />標籤中包含的所有組件發出統一的調用。 它本質上是廣播,一旦組件準備好就響應對應的內容。 - 等待所有的組件:終止序列至關重要,它表示響應的結束。 在具有多個
<Suspense />標籤的情況下,直到每個組件都發送其內容後終止序列才會被髮送。 這確保瀏覽器可以呈現所有組件的內容,從而為用户提供完整的頁面視圖。
總結
本篇文章的所有內容就是這樣了! 希望你能夠喜歡這次的內容。通過利用瀏覽器的原生行為並優化內容傳輸,Next.js 可確保用户等待時間最短並儘快看到內容。 作為開發人員,瞭解這些細微差別不僅使我們的技術更加出色,而且使我們能夠為用户更加絲滑的用户體驗。與往常一樣,如果你有任何疑問,請隨時與我聯繫或發表評論。 祝你編程愉快!