React 官方在前幾天(6/8)發表了新的文章 The Plan for React 18,新增了一些功能,像是 Automatic batchingstartTransition 以及今天的主題 New Suspense SSR Architecture in React 18

React 18 這次帶來了全新的 SSR 架構,本文節錄重點自該文章,並在文末附上我對這個架構的看法。

過去的 SSR 架構有什麼缺陷?

過去 SSR 在伺服器的步驟如下:

而這樣連續而無法中斷的流程,衍生了許多的問題。

Render 任何 HTML 之前,必須獲取所有的資料

現在的 SSR 不允許 component 等待資料。在產生 HTML 前必須獲取所有的資料,這樣在部分緩慢的資料庫或 API 時效能會不彰。

Hydrate 任何 element 之前,必須載入所有的檔案

在載入所有 JavaScript 檔案後,React 必須進行 hydrate 讓所有的 HTML 可以被操作。

React 在 render 時會走過所有的 HTML tree,並把 event handler 綁定到 HTML 上。因此在 client 上所產生的 tree 要跟 HTML tree 完全吻合,所以在 hydrate 之前必須載入所有元件的 JavaScript。

操作任何 UI 之前,必須 hydrate 所有 element

hydrate 本身也有一樣的問題,他的過程是連續且不中斷的,意思就是在 React 幫整個 HTML tree hydrate 結束前,所有的 HTML 都無法被操作。

React 18 帶來的新 SSR 架構:Streaming HTML 及 Selective Hydration

因為獲取資料(server)→ render 成 HTML(server)→ 載入 code(client)→ hydrate(client)的流程本身就是一個 waterfall,所以為了解決此問題,React 官方提出的新架構就是將整個 app 的 waterfall,拆分成多個元件分別執行此流程。

而解問題的作法就是採用了之前提出的 <Suspense> API。

在取得資料前 Streaming HTML

現今的 SSR 在 render HTML 及 hydrate 是個 0 或 1 的過程,首先你會 render 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>

你的 client 會收到一個靜態的 HTML(灰色區塊代表無法操作):

接著會載入所有的 code 並進行 hydrate(綠色區塊代表可操作):

但在 React 18,你可以使用 <Suspense> 將需要延遲載入的 component 包起來。

例如我們將 <Comments> 包起來,告訴 React 這個區塊準備好之前,先顯示 <Spinner />

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

因為 <Comments><Suspense> 包起來了,所以 React 不會等待這個區塊 render 完成,就會開始向 client 發送 streaming HTML,而該區塊會顯示為 fallback 的 placeholder。

現在得到的 SSR 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 id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>

接著當 <Comments> 元件在 server 準備好時,React 會將額外的 HTML 送到同一個 stream,並包含一個最小的 inline script,將該區塊放入正確的位置:

<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// 簡化的實作
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>

結果如下,即便 React 還沒被載入,之後的 <Comments> 也會被放入正確的位置:

這個架構解決了現行 SSR 的第一個問題。現在 render HTML 前就不需獲取所有的資料。

而且這個做法與傳統的 HTML streaming 不同,它並不在乎順序。像是你也可以將 <Sidebar> 使用 <Suspense> 包起來,因為 React 會連將該元件插入正確位置的 script 一起發送,所以不按照順序也會插入正確的位置。

在所有 code 載入前開始 hydrate

我們現在已經可以儘早的發送 HTML,但是在 <Comments> 的 code 載入之前,我們無法為整個 client 的 app 進行 hydrate,如果 bundle size 很大的話需要一段時間。

為了避免較大的 bundle size,你可以使用 code splitting 的技巧指定部分的 code 不要同步載入。

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

但是這在過去是不支援 SSR 的,不過在 React 18 允許你在 <Comments> 元件載入前就開始 hydrate。

從使用者的角度看,他們會先收到無法進行操作的 HTML:

接著 React 會進行 hydrate,即便 <Comments> 元件的 code 還沒載入:

這就是 Selective Hydration 的例子。透過將 <Comments> 包在 <Suspense> 內,告訴 React 這個區塊不應該 block stream,同時他也不會 block hydrate。這樣也解決的第二個問題:現在不需等待所有的 code 被載入後才開始 hydrate。

React 會在 <Comments> 的 code 載入完成後繼續剩下的 hydrate 流程:

在所有 HTML stream 完畢前進行 hydrate

React 會自動地處理這些 hydrate 流程,例如 HTML 還需要一點時間才會 stream 完成:

如果 JavaScript 的 code 在 HTML stream 完成前就提前載入,React 不會等待 HTML 而是直接開始 hydrate:

<Comments> 的 HTML 載入完成後,該區塊並不能馬上進行操作,因為他的 JavaScript 還沒被載入:

最後當 <Comments> 的 JavaScript 載入後整個頁面都會變得可以操作:

在所有 components hydrate 完成前進行操作

當我們把 <Comments> 包在 <Suspense> 內時還有額外的加強,現在 hydrate 不會 block 瀏覽器的其他行為。

舉個例子,當 <Comments> 正在 hydrate 時點擊側邊欄:

在 React 18 中,<Suspense> 內的 hydrate 行為會穿插在瀏覽器處理事件的間隙之間,意指點擊的事件會立即被處理而不會造成瀏覽器的卡頓,即便在效能較差的裝置也是如此。

在我們的例子中只有 <Comments><Suspense> 包起來,所以只要一次額外的 hydrate 就可以完成整個頁面的 hydrate。我們可以再使用更多的 <Suspense> 來調整這個問題:

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

現在除了 <NavBar><Post> 會從 server 拿到 HTML,其他的兩個部分都會透過 stream 取得,這樣調整也會影響到 hydrate 的行為。假設 <Suspense> 區塊的 JavaScript 還沒載入:

接著兩者的 JavaScript 被載入,React 會對這兩個 <Suspense> 區塊進行 hydrate。因為 <Sidebar> 是 tree 中較早被找到的,所以會先進行:

若此時 user 點擊了 <Comments>(該 JavaScript 已載入):

React 會紀錄這個點擊事件,並轉而優先對 <Comments> 進行 hydrate:

<Comments> hydrate 完成後,React 會重播被記錄的點擊事件(再執行一次)。最後 React 再幫 <Sidebar> 進行 hydrate:

如此一來就解決了第三個問題,我們不必在互動時就將所有元件都 hydrate。React 會盡量提早進行 hydrate,並根據使用者操作的部分優先處理。如果考慮在整個 app 中使用 <Suspense> 時,Selective Hydration 所帶來的好處會更加明顯:

在這個例子中,使用者在 hydrate 開始時就點擊第一個 Comment,React 會優先處理所有 parent <Suspense> 的內容,但跳過所有不相關的 sibling 元件。這就會產生一種 hydrate 是即時的錯覺,因為被操作的元件至 root 路徑上的所有元件都會優先被 hydrate。

實際運用時你可能會在 root 附近加上 <Suspense>

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

上述範例的初始 HTML 內容只會包含 NavBar,其餘部分會採用 streaming HTML 及部分 hydrate 的方式載入,並優先處理使用者操作的區塊。

結語

這次 React 18 在 SSR 帶來架構性的革新,也取消了當初 Concurrent mode 只能選擇全用或者不用的情境。改成 Concurrent rendering 並讓開發者可以自由的嘗試新功能。採用這種逐步採用的策略更有助於 React 推廣新版本。

而過去最常聽到需要 SSR 的情境通常都是用在 SEO 比較多,但其實這次 React 發表的新架構反倒是為了使用者體驗的推出的。以官方的例子來說,被 Suspense 的區塊並不會在第一次的 render 中出現,所以在搜尋引擎爬到的時候可能會影響 SEO。

不過 Dan 自己也有在該文底下回覆關於 SEO 的問題,其實只要在遇到搜尋引擎時使用 onCompleteAll 取代 onReadyToStream 就會跟過去 SSR 的行為一樣了。但這麼做可能造成 response 的速度變慢,也會影響排名。Sebastian 也有在後續留言補充更多關於此架構在 SEO 上能做的調整及取捨。

Google 將在 2021 的 6 月中旬將 web vitals 納入搜尋引擎排名的一部分,該如何在速度及內容之間作出權衡可能是未來開發者所要面臨的課題,可以窺見未來 SEO 及 SSR 的玩法會擦出更多火花。

← Back to Home