iT邦幫忙

2024 iThome 鐵人賽

DAY 27
2
JavaScript

30天的 JavaScript 設計模式之旅系列 第 27

[Day 27] Server Side Rendering(SSR)、Streaming Server-Side Rendering、Selective Hydration

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20241011/20168201qdFqAPoP0G.png

Server Side Rendering 是最古老的渲染網頁內容的方式之一,今天要介紹它的運作流程與優缺點,另外也會介紹 SSR with hydration 以及它的後續優化 Streaming Server-Side Rendering 與 Selective Hydration。

Server Side Rendering 運作流程

  1. 使用者輸入網址請求網頁,瀏覽器向伺服器請求資源
  2. 伺服器解析路由,根據請求的路由去連接、取得資料庫或 API 資料,收到資料後會處理資料並產生 home.html
    • 此 HTML 內的資料可能包含外部 API 的資料,是一個具有完整內容的 HTML,以下為伺服器渲染的示意圖
      https://ithelp.ithome.com.tw/upload/images/20241011/20168201Giz8TG9Nvr.jpg
      圖 1 伺服器渲染過程示意圖(資料來源:自行繪製)
  3. 瀏覽器收到檔案後,開始渲染 home.html
  4. 瀏覽器向伺服器請求 JavaScript 檔案 home.js
  5. 瀏覽器執行 home.js 內的 JavaScript 程式碼,以此讓點擊按鈕、表單提交等功能變得可互動(補充:傳統 SSR 中的 JavaScript 主要是處理特定的互動功能,需要的 JavaScript 程式碼較少,有時候也不一定會獨立一個檔案,可能會直接附加於 HTML 的 script 標籤內)
  6. 後續使用者若要導向其他頁面,就要重新向伺服器請求該頁的 HTML,頁面會整個重新載入

https://ithelp.ithome.com.tw/upload/images/20241011/20168201770nc9sSO7.jpg
圖 2 SSR 流程示意圖(資料來源:自行繪製)
在 Server Side Rendering 中,每當使用者請求,伺服器就會重新渲染一份具有完整內容的 HTML 回傳給瀏覽器,因此瀏覽器一開始就會拿到有內容的 HTML,瀏覽器就可以直接渲染內容給使用者看到,使用者不用等待 JavaScript 執行完才能看到內容,會有更快的初始載入速度,不過如果需要和頁面互動,還是要等 JavaScript 執行完、綁定事件處理後才能和頁面互動。

適合情境

這類的渲染方式很適合有高度個人化資料的頁面,例如基於使用者 cookie 而產生資料的個人化儀表板(dashboard),通常 dashboard 會包含特定於使用者的敏感資料,因此不同使用者請求都要重新渲染一份屬於該使用者的 HTML,另外,此方式也可用來阻擋頁面渲染,如果伺服器收到請求後發現該使用者身分驗證失敗(不符合存取該頁面的資格),就不會渲染該頁面給使用者。

優點

可避免為了獲取資料而有額外網路往返

SSR 在伺服器端已經取好資料並渲染完整內容,在瀏覽器端就不需要為了得到資料,再去發送 API 請求,可減少網路往返次數。

較好的 SEO

因為可拿到有完整內容的 HTML,搜尋引擎的索引效果較好,能有較好的 SEO 表現。

更小的 JavaScript 檔案

因為客戶端不需渲染 DOM 元素,渲染 DOM 元素的 JavsScript 程式碼也不需傳給客戶端,因此 SSR 所需的 JavaScript 明顯會比 CSR 少,解決了 CSR JavaScript bundle 檔案過大的問題,也因為 JavaScript 檔案較小,載入和處理所需的時間因此較短,FCP 和 TTI 相對更快。

為客戶端提供更多 JavaScript 的空間

因為 SSR 減少頁面渲染需要的 JavaScript,如果客戶端需要載入第三方 JavaScript,就會有更多額外空間可使用。

缺點

切換路由時會整個頁面重新載入,影響使用者體驗

使用者需要等待頁面重新載入,可能要花更長的等待時間,也因此 SSR 無法實現 SPA。

伺服器負擔會過重

在 SSR,使用者的每個請求都是獨立的,都會當作新請求處理,即使連續兩個請求差不多,伺服器也會從頭開始處理和產生 HTML,而伺服器對多位使用者是公用的,給定時間內的處理能力由所有活動使用者共享,因此若過多使用者都像伺服器請求,伺服器就會負擔過重。

更慢的 TTFB

因為伺服器要處理的任務較多,以下因素可能導致伺服器較慢回傳檔案:

  • 同時多個請求造成伺服器流量過高、負擔較重
  • 網路速度緩慢
  • 伺服器端的程式碼未被優化,執行緩慢

再水合成本較高

SSR 渲染模式會傳送 HTML,並附上必要的 JavaScript 以在客戶端上再水合(rehydration)它,更新 UI 元件狀態,以及讓元件可和使用者互動,因此雖然 SSR 可以讓使用者看到有資料的 HTML,但在 JavaScript 還沒執行 hydration 前,還是會有「畫面出現了,但使用者無法互動」的情況。因再水合成本較高,後續的 SSR 變體都試圖優化此過程。

補充:hydrate/hydration
hydration 中文有人翻作水合、水化,渲染模式中的 hydration 意思是用互動性邏輯與事件處理器(event handlers)對靜態 HTML 澆水,去綁定 HTML 中 DOM 元素的事件處理,讓它們成為可互動的元件。

改善方式

針對上述缺點,有幾個可改善的方向:

升級伺服器硬體

升級伺服器硬體可以幫助更快處理各個請求,並獲得更快的回應。

向回應增加 Cache-Control header

在回應時增加 Cache-Control header,讓瀏覽器能快取資源。

將資料庫部署在與無伺服器函式(Serverless Function)相同的區域

在 SSR 渲染方式中,每次當使用者向伺服器請求資源時,都會呼叫 serveless function,由 serveless function 去執行運作、從資料庫獲取資料以渲染 HTML 內容,上面 SSR 運作流程我省略這塊,其實可對應於運作流程的步驟 2,伺服器產資料並渲染網頁,也可想成是由 serveless function 去執行。
也因此如果頁面資料來自資料庫,必須減少查詢資料庫所需的時間,並考量資料庫部署的位置。例如,如果 serveless function 部署在舊金山,但資料庫位於東京,建立連接並獲取資料庫數據可能需要一些時間。因此應考慮將資料庫移動到與 serveless function 相同的區域,以確保資料庫查詢能更快回傳數據。

補充:Serverless Function
Serverless Function 是一種雲端計算服務,讓應用程式程式碼在無需直接管理伺服器的情況下執行。雖然表面上看似無伺服器(Serverless),但實際上是它將傳統伺服器的功能拆分成多個專門的微服務,並在需要時自動建立臨時的伺服器容器,運行設計好的程式碼。這種架構讓開發人員能專注於編寫和部署程式碼,而不必擔心伺服器的配置、維護或擴展。

Serverless Function 的運作流程:

  • 當特定事件觸發時(例如使用者發送 HTTP 請求),Serverless Function 被喚醒執行
  • 函式運行程式碼,完成所需的任務,例如查詢資料庫、處理請求、進行計算等
  • 執行完畢後,函式會自動關閉,並等待下一次被觸發

SSR with hydration

以上的 SSR 運作流程與介紹屬於傳統 SSR 的渲染方式,傳統 SSR 有個缺點就是每次使用者要換頁時,都要重新請求 HTML,整個頁面要重新載入,使用者體驗不佳。
那有沒有什麼方法可以有 SSR 預先渲染好 HTML 的優點,又可以像 CSR 一樣有類似 SPA 的體驗、換頁時不需重新載入頁面呢? SSR with hydration 就是為了改善 SSR 使用者體驗不佳的問題,而產生了「初次渲染交給伺服器,後續交由客戶端處理互動」的渲染模式,實作這類方式的框架如 Next.js。

SSR with hydration 的流程如下:

  1. 使用者輸入網址請求網頁,瀏覽器向伺服器請求資源
  2. 伺服器端根據請求去取得資料庫資料,渲染好靜態 HTML 後回傳
  3. 瀏覽器收到有完整內容的 HTML,開始渲染頁面
  4. 瀏覽器向伺服器請求 JavaScript 檔案,用來在瀏覽器上做事件綁定(即 hydration),讓頁面已有的 DOM 可處理使用者的互動
  5. hydration 完成後,後續流程和 CSR 一樣,後續邏輯如換頁、使用者互動、API 請求都由瀏覽器(客戶端)接管,不需像傳統 SSR 那樣整個頁面重新載入

簡單來說,SSR with hydration 和 CSR 的差異就只有在第一次的畫面是由誰 render 而已。

優點

  • 相比傳統 SSR 有更快的 FCP,因為換頁時是由客戶端處理渲染邏輯,不像傳統 SSR 換頁還要等伺服器端渲染好才回傳 HTML
  • 解決傳統 SSR 使用者體驗不佳的問題,也解決 CSR 初次載入頁面有一大段空白的問題

缺點

  • TTI 較差:會有 HTML 完成、可看到畫面,但 JavaScript 還沒完成 hydration,無法互動的情況
  • 學習門檻較高:SSR 中 Server 扮演重要角色,開發者需要稍微了解 NodeJS 的運作
  • 運作的行為都是串聯的,如果前一步驟執行太久,後續都會被卡住(下方「SSR with hydration 後續優化」會說明)

SSR with hydration 後續優化:Streaming Server-Side Rendering、Selective Hydration

傳統 SSR 每次換頁都要重新載入的問題,由 SSR with hydration 用「初次渲染交給伺服器,後續交由客戶端處理」的方式解決了,但 SSR with hydration 仍有些缺點,除了上述提到的,SSR with hydration 的運作流程中,所有行為都是串聯的,每個步驟都須完全完成,才能進行下一步:

  • 必須先取得所有 API 資料後才能開始組裝 HTML
    • 要先搜集好所有資料才能渲染 HTML.然後再向 client 發送 HTML
    • 必須等待慢的資料,而延遲發送其他 HTML 內容(如:導覽列、側邊欄等)
  • 必須載入全部 JavaScript bundle 內容才能進行 hydration
    • client 收到 HTML 後,React 需遍歷 HTML 並加上 event handlers (hydrate),這代表需要先載入 client 端所有元件的 JavaScript,才能開始 hydrate
    • 如果有一個包含複雜互動邏輯的區塊,載入其 JavaScript 需要時間,在載入該 JavaScript 之前不能先 hydrate 其他地方的元件(如:導覽列、側邊欄)
  • 必須全部完成 hydration 後,才可以開始互動
    • React 會一次完成 hydration,代表當它開始 hydrating,直到完成整個 tree 的 hydration 前,不會停止,因此使用者需等所有元件 hydration 後才能和他們互動
    • 如果 main thread 忙於 hydrating 某複雜渲染邏輯的元件,使用者就無法和其他元件(如:導覽列、側邊欄)互動,對於想離開頁面的使用者來說,我們會強制將他留在頁面直到 hydration 完畢,造成不好的使用者體驗

https://ithelp.ithome.com.tw/upload/images/20241011/20168201Il1nhMM4pJ.jpg
圖 3 SSR with hydration 瀑布流行為示意圖(資料來源:自行繪製)

可看出上述存在瀑布流問題:取資料 -> 渲染 HTML(server) -> 載入程式碼(client) -> hydrate(client),這個流程將導致效能瓶頸。而要如何解決問題? 就是將工作分開,針對每一個區塊執行每個階段任務,各區塊自己進行自己的任務,而這個解法也在 React 18 時提出:

Streaming Server-Side Rendering

Streaming Server-Side Rendering 透過「分區塊串流式傳送 HTML」的方式,在某些區塊資料就緒時立即開始傳送給客戶端,而不是等待所有數據回傳、組裝好完整 HTML 後才傳送給客戶端,解決「必須先取得所有 API 資料後才能開始組裝 HTML」的問題。

原 SSR with hydration 的渲染方式,在渲染 HTML 和 hydration 時是全有或全無的,無法只得到部分。
而解決方式是用 Suspense 包住取得資料需要比較久的區塊,React 可先將 Suspense 外的其他區塊 HTML 傳送給 client。

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

Suspense 內的 Comments 如果還沒完成,就先顯示 fallback 傳入的 spinner。
https://ithelp.ithome.com.tw/upload/images/20241011/201682013E25N989wh.jpg
圖 4 先回傳部分元件的 HTML,Comments 先顯示 spinner (資料來源:自行繪製)

Suspense 內的資料取得後,React 再傳送額外 HTML 和插入元素的 inline script 到同個 stream,將內容插入,取代原有的 spinner。

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // 這裡的實作已被簡化,僅用來示意大致邏輯用意
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

如此 Comments 區塊就可取代 spinner 的區塊。
https://ithelp.ithome.com.tw/upload/images/20241011/20168201iqOOdHBeHE.jpg
圖 5 Comments 渲染完成後取代 spinner (資料來源:自行繪製)

不過此方法需確保 data fetching 的方法可整合 Suspense 一起使用,例如原生 fetch 方法就不支援。

Selective Hydration

Selective Hydration 提供一種依需求載入和 hydration 的機制。透過「分元件進行 hydration」,React 可以先對已載入的元件進行 hydration,而不需等待整個 JavaScript bundle 完成,解決「必須載入全部 JavaScript bundle 內容才能進行 hydration」的問題。

為了避免一次載入大包 JavaScript bundle 後才能進行 hydration,我們通常會用 React.lazy 的方式進行 code splitting,但以前這方法在 SSR 不適用。
解決方式是 Selective Hydration,React 18 可將 Suspense 外的元件先進行 hydration,不用等全部元件的 JavaScript bundle 載入後才能 hydrate。
舉例來說,我們用 Suspense 包住 Comments,告訴 React Comments 區塊不會阻塞其他區塊的 streaming 和 hydration,因此即使 Comments 的 JavaScript bundle 還沒載入,也可以先對其他元件進行 hydration。

https://ithelp.ithome.com.tw/upload/images/20241011/20168201Cop1ECWWZe.jpg
圖 6 可先進行部分元件的 hydration(資料來源:自行繪製)

Selective Hydration 與 Concurrent Feature

Selective Hydration 搭配 Concurrent Features 後,能根據使用者的互動行為切換 hydration 的優先級。如果使用者先和某元件互動,就優先 hydrate 該部分,解決「必須全部完成 hydration 後,才可以開始互動」的問題。

React 在 hydrating 時也可立即反應使用者的互動,用 Suspense 包住的元件的 hydration 任務可被切分,讓使用者互動可在任務與任務間被執行,不會有主線程被阻塞的問題。舉例來說,如果 React 正在 hydrating A 元件,但使用者點擊了另一個還沒 hydrate 的 B 元件,React 會在捕捉到點擊事件時切換改先 hydrate B 元件,先反應使用者的互動,因此不用等全部完成 hydration 才能互動,使用者先互動的就會先 hydrate,改為選擇性、有優先級的 hydration。

https://ithelp.ithome.com.tw/upload/images/20241011/201682016VndSzCBGN.jpg
圖 7 原先正在 hydrating Sidebar 元件(資料來源:自行繪製)

https://ithelp.ithome.com.tw/upload/images/20241011/20168201DNxmlU0CFB.jpg
圖 8 當使用者點擊另一個還沒 hydrate 的 Comments 元件 (資料來源:自行繪製)

https://ithelp.ithome.com.tw/upload/images/20241011/20168201TS9RWQBZHo.jpg
圖 9 React 會切換任務改先 hydrate Comments 元件 (資料來源:自行繪製)

另外,React 可以在任務間切換而不需要一次完成 hydration 也和 React 18 的 Concurrent Feature 有關。在沒有 Concurrent Feature 以前,React 的渲染是以單一、不中斷的同步方式渲染,在同步渲染中,一旦開始進行渲染,在使用者看到結果前,任何事情都無法中斷這過程(任何事情如:使用者對畫面互動)。

而在 Concurrent Feature 中,React 在開始渲染後,中間仍可暫停渲染任務,去處理其他事情,之後再回來繼續渲染,並且即使渲染被中斷,UI 仍會保持一致性。React 會等到整個樹狀結構被評估完成後才開始進行真實 DOM 的操作與變更,Concurrent Feature 能讓 React 在不阻塞主執行緒的情況下,在背景中準備新的畫面,同時也代表即使 React 正進行複雜渲染任務,UI 也能立刻回應使用者的輸入。React 18 的 Suspense、transitions 和 streaming server rendering 的功能都是基於 Concurrent Feature 而建構的,由此可知 Concurrent Feature 的重要性。

而 Concurrent Feature 之所以能被實現,則和 React 16 將內部架構重構為 Fiber Architecture 有關,Fiber 架構可以將頁面渲染的任務切分成 chunks、不同任務可以區分優先級、任務可以暫停,之後再繼續執行,Fiber 架構的這些特性因而讓 Concurrent Feature 能夠實踐,詳細說明推薦閱讀 React 開發者一定要知道的底層機制 — React Fiber Reconciler

簡單總結的話,React Fiber 架構促成 Concurrent Feature 的實踐,而 Concurrent Feature 又再促成 Suspense、transitions 和 streaming server rendering 等功能的實現。


以 Streaming Server-Side Rendering 和 Selective Hydration 改善 SSR with hydration 問題後,我們可以以 component-level 來思考,每個區塊都有自己的任務,自己區塊載入完 JavaScript 就可先做 hydration。示意圖如下,每個元件有自己的任務要執行,不會管其他元件如何,但要注意的是,這些任務仍然是在主線程同步執行,不是平行執行的。

https://ithelp.ithome.com.tw/upload/images/20241011/20168201iJKtueywcs.jpg
圖 10 各元件有自己的任務要執行(資料來源:自行繪製)

另外再附上加上 Streaming Server-Side Rendering 和 Selective Hydration 的流程圖如下,可看出 Streaming 和 Selective Hydration 的方式可讓網頁有更快的 FCP 與 TTI。優點就是使用者可更快看到網頁內容、達到更好的使用者體驗;而缺點則是更複雜的開發成本,開發者要理解此策略的原理也需要花費更多心力。

https://ithelp.ithome.com.tw/upload/images/20241011/20168201FLsAQm7HLz.jpg
圖 11 加入 Streaming 和 Selective Hydration 的流程圖(資料來源:自行繪製,部分圖來自https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming)

小結

總結來說,SSR 的渲染方式可在使用者存取網頁時就立刻拿到有完整內容的 HTML,也有利於 SEO,但仍然有一些問題,例如伺服器需要在每次請求重新產生 HTML,造成伺服器負擔,因此有了 Static Rendering/Static Site Generation (SSG) 的出現,將在下篇介紹它。

最後小補充,不知為何上傳的圖片解析度都變很低QQ,之後會在 Medium 放上這次鐵人賽的文章,希望 Medium 上的圖片比較清晰!對清晰版圖片有興趣的可以先追蹤我的 Medium,之後更新文章就可以看到囉:D

Reference


上一篇
[Day 26] Client Side Rendering(CSR)
下一篇
[Day 28] Static Rendering/Static Site Generation (SSG)
系列文
30天的 JavaScript 設計模式之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言