如何在 React 18 中 利用 Suspense 實現 服務端渲染 -SSR-

概述

React 18 將包括對 其服務器端渲染 (SSR) 性能的架構做了改進。這些改進帶來了實質性的效果,是幾年來其團隊工作的結晶。大多數的改進點都是在幕後進行的,但您需要了解一些選擇加入機制,尤其是您在不適用框架的情況下。

主要的新 API 是 pipeToNoWritable, 您可以在 "升級到服務器上的 React18" 這篇文章中閱讀了解。在最終的正式版本中,我們也將計劃寫更多關於它的細節。

如何在服務端使用 React18

https://github.com/reactwg/react-18/discussions/22

現有的 API 是 ,此文主要是對新架構、其設計及背後要解決的問題進行闡述。

服務端渲染(SSR)介紹

服務器端渲染(在本文中縮寫爲 “SSR”)讓您可以從服務器上的 React 組件生成 HTML,並將該 HTML 發送給您的用戶。SSR 允許您的用戶在您的 JavaScript 包加載和運行之前查看頁面的內容。

React 中的 SSR 總是發生在幾個步驟中:

關鍵部分是,在下一步開始之前,整個應用程序的每個步驟都必須立即完成。如果其中一個環節比其他部分慢,將會影響整體的加載時間。

React18 中,您可以使用  將您的應用程序分解成更小的獨立單元,每個模塊都是獨自異步加載,並不會影響其餘部分。即便是應用程序中最慢的模塊也不會拖累較快的模塊。因此,用戶也將更快地看到內容,並更快的開始與之交互。

這些改進點都是框架內部自動完成的,您無需爲它們編寫任何特殊的代碼。

通過案例演示來介紹什麼是 SSR ?

當用戶加載您的應用程序時,您希望更快顯示一個完全交互式的頁面:

此插圖使用綠色表示頁面的這些部分是交互式的。換句話說,它們所有的 JavaScript 事件處理程序都已附加,單擊按鈕可以更新狀態,等等。

但是,頁面在 JavaScript 代碼完全加載之前無法進行交互。這包括 React 本身和您的應用程序代碼。對於非 React 的應用程序,大部分加載時間將用於下載您的應用程序代碼。

如果您不使用 SSR,則用戶在 JavaScript 加載時只會看到一個空白頁面:

出現空白頁面對用戶來說非常的不友好,這也是我們推薦使用 SSR 的原因。SSR 允許您將服務器上的 React 組件渲染爲 HTML 字符串並將其發送給用戶。HTML 的交互性不是很強(除了簡單內置 Web 交互,如鏈接和表單輸入)。然而,它讓用戶在 JavaScript 仍在加載時可以看到一些內容:

以上圖例中,灰色說明屏幕的這些部分尚未完全交互。您應用程序的 JavaScript 代碼尚未加載,因此單機按鈕不會執行任何操作。但特別是對於內容較多的站點,SSR 非常有用,因爲它可以讓連接較差的用戶在 JavaScript 加載時開始閱讀或查看內容。

當 React 和你的應用程序代碼都加載時,你想讓這個 HTML 交互。你告訴 React:“這是 App 在服務器上生成這個 HTML 的組件。將事件處理程序附加到該 HTML!” React 將在內存中渲染你的組件樹,但它不會爲它生成 DOM 節點,而是將所有邏輯附加到現有的 HTML。

渲染組件和附加事件處理程序的過程稱爲 “水化”。這就像用交互性和事件處理程序的“水” 去澆灌 “乾涸” 的 HTML。(或者至少,這就是我對自己解釋這個術語的方式。)

水合之後,它是 “像往常一樣反應”:你的組件可以設置狀態,響應點擊等等:

你可以看到 SSR 是一種 “魔術”。它不會使您的應用程序完全交互更快。相反,它可以讓您更快地顯示應用程序的非交互式版本,以便用戶可以在等待 JS 加載時查看靜態內容。然而,這個技巧對網絡連接不佳的人產生了巨大的影響,並提高了整體感知性能。由於其更容易的索引和更快的速度,它還可以幫助您進行搜索引擎排名。

注意:不要將 SSR 與服務器組件混淆。服務器組件是一個更具實驗性的功能,仍在研究中,可能不會成爲最初的 React 18 版本的一部分。您可以複製以下連接瞭解服務器組件。服務器組件是對 SSR 的補充,並將成爲推薦的數據獲取方法的一部分,但本文與它們無關。

React 服務器組件

https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html

現有 SSR 存在哪些問題?

上述方法有效,但在很多方面表現不佳。

1. 您必須獲取所有內容,然後才能顯示任何內容【整個過程是 同步進行的】

今天 SSR 的一個問題是它不允許組件 “等待數據”。使用當前的 API,當您呈現爲 HTML 時,您必須爲服務器上的組件準備好所有數據。這意味着您必須先收集服務器上的所有數據,然後才能開始向客戶端發送任何 HTML。這是相當低效的。

例如,假設您要呈現帶有評論的帖子。儘早顯示註釋很重要,因此您希望將它們包含在服務器 HTML 輸出中。但是您的數據庫或 API 層很慢,這是您無法控制的。現在你必須做出一些艱難的選擇。如果您將它們從服務器輸出中排除,則在 JS 加載之前用戶將不會看到它們。但是,如果您將它們包含在服務器輸出中,則必須延遲發送其餘的 HTML(例如,導航欄、側邊欄,甚至是帖子內容),直到評論加載完畢並且您可以呈現完整的樹。這不是很友好。

作爲旁註,一些數據獲取解決方案反覆嘗試將樹渲染爲 HTML 並丟棄結果,直到數據得到解析,因爲 React 沒有提供更符合人體工程學的選項。我們希望提供一種不需要如此極端妥協的解決方案。

2. 給任何板塊補水之前,您必須加載所有數據

在您的 JavaScript 代碼加載後,您將告訴 React “水合” HTML 並使其具有交互性。React 將在渲染組件時 “遍歷” 服務器生成的 HTML,並將事件處理程序附加到該 HTML。爲此,瀏覽器中組件生成的樹必須與服務器生成的樹相匹配。否則 React 無法“匹配它們!” 這樣做的一個非常不幸的後果是,您必須先爲客戶端上的所有組件加載 JavaScript,然後才能開始對它們中的任何一個進行補水。

例如,假設評論小部件包含很多複雜的交互邏輯,爲其加載 JavaScript 需要一段時間。現在你必須再次做出艱難的選擇。最好將服務器上的評論呈現爲 HTML,以便儘早將它們顯示給用戶。但是因爲今天只能一次完成補水,所以在您加載評論小部件的代碼之前,您無法開始對導航欄、側邊欄和帖子內容進行補水!當然,您可以使用代碼拆分並單獨加載它,但是您必須從服務器 HTML 中排除註釋。否則 React 將不知道如何處理這塊 HTML(它的代碼在哪裏?)並在水化過程中將其刪除。

3. 在與任何事物交互之前,您必須先補充所有水分

融合作用本身也存在類似的問題。今天,React 一次性完成樹的水化。這意味着一旦它開始 hydrating(本質上是調用你的組件函數),React 不會停止,直到它爲整個樹完成此操作。因此,您必須等待所有組件都 “融合” 後才能與它們中的任何一個進行交互。

例如,假設評論小部件具有昂貴的渲染邏輯。它可能在您的計算機上運行得很快,但在運行所有這些邏輯的低端設備上並不便宜,甚至可能會鎖定屏幕幾秒鐘。當然,理想情況下,我們根本不會在客戶端上有這樣的邏輯(服務器組件可以提供幫助)。但是對於某些邏輯來說,這是不可避免的,因爲它決定了附加的事件處理程序應該做什麼並且對於交互性至關重要。因此,一旦水化開始,用戶就無法與導航欄、側邊欄或帖子內容進行交互,直到整個樹被水化。對於導航,這尤其令人遺憾,因爲用戶可能希望完全離開此頁面——但由於我們正忙於補充水分,我們將它們保留在他們不再關心的當前頁面上。

我們如何解決這些問題呢 ?

這些問題之間有一個共同點。它們迫使你在早點做某事(但因爲它阻止所有其他工作而損害用戶體驗)或晚做某事(但因爲你浪費時間而損害用戶體驗)之間做出選擇。

這是因爲有一個過程:獲取數據(服務器)→ 渲染到 HTML(服務器)→ 加載代碼(客戶端)→ 水合物(客戶端)。在應用程序的前一階段完成之前,這兩個階段都不能開始。這就是它效率低下的原因。我們的解決方案是將工作分開,以便我們可以爲屏幕的一部分而不是整個應用程序執行每個階段。

這不是一個新穎的想法:例如,

Marko[https://tech.ebayinc.com/engineering/async-fragments-rediscovering-progressive-html-rendering-with-marko/] 是實現此模式版本的 JavaScript Web 框架之一。挑戰在於如何使這樣的模式適應 React 編程模型。花了一段時間才解決。我們 在 2018 年爲此目的引入了該組件。我們引入它時僅支持在客戶端延遲加載代碼。但目標是將其與服務器渲染集成並解決這些問題。

讓我們看看如何 在 React 18 中使用來解決這些問題。

React 18 :流式 HTML 和 選擇性的 “水化”

Suspense 解鎖的 React 18 中有兩個主要的 SSR 特性:

要了解這些功能的作用以及它們如何解決上述問題,讓我們返回到我們的示例。

在獲取所有數據之前 流式傳輸 HTML

使用現有的 SSR,渲染 HTML 和水化是 “全有或全無”。首先渲染所有 HTML:

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section>
    <!-- Comments -->
    <p>First comment</p>
    <p>Second comment</p>
  </section>
</main>

客戶端最終將會展示爲:

然後加載所有代碼併爲整個應用程序注入水分:

但是 React 18 給了你一個新的可能。您可以用  包裹頁面的一部分。

例如,讓我們包裝註釋塊並告訴 React,在它準備好之前,React 應該顯示該 組件:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

包裝成 < Suspense>,我們告訴 React 它不需要等待評論開始爲頁面的其餘部分流式傳輸 HTML。相反,React 將發送佔位符(一個微調器)而不是評論:

現在在最初的 HTML 中找不到註釋:

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section>
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

故事到這裏還沒有結束。當評論的數據在服務器上準備好時,React 會將額外的 HTML 發送到同一個流中,以及一個最小的內聯 標籤,以將該 HTML 放在 “正確的位置”:

<div hidden>
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

結果,即使在 React 本身加載到客戶端之前,遲來的 HTML 評論也會 “彈出”:

這就解決了我們的第一個問題。現在,您不必先獲取所有數據,然後才能顯示任何內容。如果屏幕的某些部分延遲了初始 HTML,則您不必在延遲所有 HTML 或將其從 HTML 中排除之間做出選擇。您可以只允許該部分稍後在 HTML 流中 “彈出”。

與傳統的 HTML 流不同,它不必按自上而下的順序發生。例如,如果側邊欄需要一些數據,您可以將其包裝在 Suspense 中,React 會發出一個佔位符並繼續渲染帖子。然後,當側邊欄 HTML 準備好時,React 會將其與將 其插入正確位置的標籤一起流式傳輸——即使帖子的 HTML(在樹中更遠的位置)已經發送!不要求以任何特定順序加載數據。您指定微調器應該出現的位置,React 會找出其餘的。

注意:爲此,您的數據獲取解決方案需要與 Suspense 集成。服務器組件將開箱即用地與 Suspense 集成,但我們還將提供一種方法讓獨立的 React 數據獲取庫與之集成。

在所有代碼加載之前對頁面進行 “水分” 補充

我們可以更早地發送初始 HTML,但我們仍然有問題。在評論小部件的 JavaScript 代碼加載之前,我們無法開始在客戶端上對我們的應用程序進行補水。如果代碼很大,這可能需要一段時間。

爲了避免較大體積的組件包,你通常會使用 “代碼拆分”:你會指定一段代碼不需要同步加載,你的捆綁器會將它拆分成一個單獨的 < script > 標籤。

您可以使用代碼拆分 React.lazy 從主包中拆分註釋代碼:

import { lazy } from 'react';
const Comments = lazy(() => import('./Comments.js'));
// ...
<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

以前,這不適用於服務器渲染。(據我們所知,即使是流行的解決方法也迫使您在選擇退出代碼拆分組件的 SSR 或在所有代碼加載後對其進行補充之間做出選擇,這在某種程度上違背了代碼拆分的目的。)

但是在 React 18 中, 可以讓您在評論小部件加載之前對應用程序 進行補水。

從用戶的角度來看,最初他們會看到以 HTML 形式流入的非交互式內容:

然後您告訴 React 去 “水合”,評論的代碼還沒有,但沒有關係:

這是選擇性水合作用的一個例子。通過包裝 Comments 中 ,你告訴陣營,他們不應該阻止頁面的其餘部分流和,事實證明,水化,太!這意味着第二個問題解決了:您不再需要等待所有代碼加載才能開始補水。React 可以在加載部件時對其進行水合。

React 將在其代碼加載完成後開始爲評論部分補水:

多虧了 Selective Hydration,大量的 JS 不會阻止頁面的其餘部分變得可交互。

在流式傳輸所有代碼之前對頁面進行 “水分” 補充

React 會自動處理所有這些,因此您無需擔心事情會以意外的順序發生。例如,即使 HTML 正在流式傳輸,它也可能需要一段時間才能加載:

如果 JavaScript 代碼早於所有 HTML 加載,React 沒有理由等待!它將滋潤頁面的其餘部分:

當註釋的 HTML 加載時,它將顯示爲非交互式,因爲 JS 還沒有:

最後,當評論小部分的 JavaScript 加載時,頁面將變得完全可交互:

在所有組件都 “水合” 之前與頁面交互

當我們將評論包裹在 . 現在它們的水分不再阻止瀏覽器做其他工作。

例如,假設用戶在添加評論時單擊側邊欄:

在 React 18 中,Suspense 邊界內的水化內容發生在瀏覽器可以處理事件的微小間隙中。多虧了這一點,點擊會立即處理,並且在低端設備上長時間水合期間,瀏覽器不會出現卡住現象。例如,這讓用戶可以離開他們不再感興趣的頁面。

在我們的例子中,只有評論被包裹在 Suspense 中,所以頁面的其餘部分在一次傳遞中發生。但是,我們可以通過在更多地方使用 Suspense 來解決這個問題!例如,讓我們也包裝側邊欄:

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

現在兩個人可以從服務器包含導航欄和後最初的 HTML 之後流。但這也會影響水合作用。假設它們兩個的 HTML 都已加載,但它們的代碼尚未加載:

然後,包含側邊欄和註釋代碼的包加載。React 將嘗試將它們都水化,從它在樹中較早找到的 Suspense 邊界開始(在本例中,它是側邊欄):

但是假設用戶開始與評論小部件交互,爲此還加載了代碼:

React 會記錄發生的點擊,並優先處理評論,因爲它更緊急:

在評論 “水合” 之後,React“重放”記錄的點擊事件(通過再次調度它)並讓您的組件響應交互。然後,既然 React 無事可做,React 將“水化” 側邊欄:

這就解決了我們的第三個問題。多虧了選擇性水合作用,我們不必 “爲了與任何東西互動而將所有東西都水化”。React 會盡早開始爲所有內容補水,並根據用戶交互優先考慮屏幕上最緊急的部分。如果您考慮到在整個應用程序中採用 Suspense 時,邊界將變得更加細化,則選擇性水化的好處將變得更加明顯:

在此示例中,用戶在水合開始時單擊第一條評論。React 將優先處理所有父 Suspense 邊界的內容,但會跳過任何不相關的兄弟姐妹。這會產生一種錯覺,即水合是即時的,因爲交互路徑上的組件首先被水合。React 將立即爲應用程序的其餘部分補水。

在實踐中,您可能會在應用程序的根目錄附近添加 Suspense:

<Layout>
  <NavBar />
  <Suspense fallback={<BigSpinner />}>
    <Suspense fallback={<SidebarGlimmer />}>
      <Sidebar />
    </Suspense>
    <RightPane>
      <Post />
      <Suspense fallback={<CommentsGlimmer />}>
        <Comments />
      </Suspense>
    </RightPane>
  </Suspense>
</Layout>

在此示例中,初始 HTML 可以包含 內容,但其餘部分將在加載相關代碼後立即流入並混合部分,優先考慮用戶與之交互的部分。

注意:您可能想知道您的應用程序如何在這種非完全水合狀態下工作。設計中有一些微妙的細節使其發揮作用。例如,不是單獨對每個單獨的組件進行水合,而是對整個 邊界進行水合。由於 < Suspense > 已用於不會立即出現的內容,因此您的代碼對其子項不立即可用具有彈性。React 總是按照父級優先順序進行 hydration,因此組件總是設置了它們的 props。React 推遲調度事件,直到從事件點開始的整個父節點都被水合。最後,如果父級更新導致尚未水合的 HTML 變得陳舊,React 將隱藏它並將其替換爲 fallback 您指定直到代碼加載完畢。這確保了樹對用戶來說是一致的。你不需要考慮它,但這就是讓它起作用的原因。

演示

我們準備了一個演示,您可以嘗試瞭解新的 Suspense SSR 架構如何工作。它被人爲地減慢,因此您可以調整延遲 server/delays.js:

https://codesandbox.io/s/github/facebook/react/tree/master/fixtures/ssr2?file=/src/App.js

總結

React 18 爲 SSR 提供了兩個主要特性:

這些特性解決了 React 中 SSR 的三個長期存在的問題:

組件可作爲所有這些功能的選擇。改進本身在 React 內部是自動的,我們希望它們能夠與大多數現有的 React 代碼一起使用。這展示了以聲明方式表達加載狀態的能力。從 if (isLoading) 到看起來可能沒有很大的變化 ,但它是解鎖所有這些改進的原因。

以上譯文,避免不了措辭不當之處,還請諒解,如需查看原文請訪問如下鏈接:

https://github.com/reactwg/react-18/discussions/37

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0IFW4h6L0aI9iTiRbzxcLg