Server Side Rendering 是最古老的渲染網頁內容的方式之一,今天要介紹它的運作流程與優缺點,另外也會介紹 SSR with hydration 以及它的後續優化 Streaming Server-Side Rendering 與 Selective Hydration。
home.html
home.html
home.js
home.js
內的 JavaScript 程式碼,以此讓點擊按鈕、表單提交等功能變得可互動(補充:傳統 SSR 中的 JavaScript 主要是處理特定的互動功能,需要的 JavaScript 程式碼較少,有時候也不一定會獨立一個檔案,可能會直接附加於 HTML 的 script 標籤內)
圖 2 SSR 流程示意圖(資料來源:自行繪製)
在 Server Side Rendering 中,每當使用者請求,伺服器就會重新渲染一份具有完整內容的 HTML 回傳給瀏覽器,因此瀏覽器一開始就會拿到有內容的 HTML,瀏覽器就可以直接渲染內容給使用者看到,使用者不用等待 JavaScript 執行完才能看到內容,會有更快的初始載入速度,不過如果需要和頁面互動,還是要等 JavaScript 執行完、綁定事件處理後才能和頁面互動。
這類的渲染方式很適合有高度個人化資料的頁面,例如基於使用者 cookie 而產生資料的個人化儀表板(dashboard),通常 dashboard 會包含特定於使用者的敏感資料,因此不同使用者請求都要重新渲染一份屬於該使用者的 HTML,另外,此方式也可用來阻擋頁面渲染,如果伺服器收到請求後發現該使用者身分驗證失敗(不符合存取該頁面的資格),就不會渲染該頁面給使用者。
SSR 在伺服器端已經取好資料並渲染完整內容,在瀏覽器端就不需要為了得到資料,再去發送 API 請求,可減少網路往返次數。
因為可拿到有完整內容的 HTML,搜尋引擎的索引效果較好,能有較好的 SEO 表現。
因為客戶端不需渲染 DOM 元素,渲染 DOM 元素的 JavsScript 程式碼也不需傳給客戶端,因此 SSR 所需的 JavaScript 明顯會比 CSR 少,解決了 CSR JavaScript bundle 檔案過大的問題,也因為 JavaScript 檔案較小,載入和處理所需的時間因此較短,FCP 和 TTI 相對更快。
因為 SSR 減少頁面渲染需要的 JavaScript,如果客戶端需要載入第三方 JavaScript,就會有更多額外空間可使用。
使用者需要等待頁面重新載入,可能要花更長的等待時間,也因此 SSR 無法實現 SPA。
在 SSR,使用者的每個請求都是獨立的,都會當作新請求處理,即使連續兩個請求差不多,伺服器也會從頭開始處理和產生 HTML,而伺服器對多位使用者是公用的,給定時間內的處理能力由所有活動使用者共享,因此若過多使用者都像伺服器請求,伺服器就會負擔過重。
因為伺服器要處理的任務較多,以下因素可能導致伺服器較慢回傳檔案:
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,讓瀏覽器能快取資源。
在 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 運作流程與介紹屬於傳統 SSR 的渲染方式,傳統 SSR 有個缺點就是每次使用者要換頁時,都要重新請求 HTML,整個頁面要重新載入,使用者體驗不佳。
那有沒有什麼方法可以有 SSR 預先渲染好 HTML 的優點,又可以像 CSR 一樣有類似 SPA 的體驗、換頁時不需重新載入頁面呢? SSR with hydration 就是為了改善 SSR 使用者體驗不佳的問題,而產生了「初次渲染交給伺服器,後續交由客戶端處理互動」的渲染模式,實作這類方式的框架如 Next.js。
SSR with hydration 的流程如下:
簡單來說,SSR with hydration 和 CSR 的差異就只有在第一次的畫面是由誰 render 而已。
傳統 SSR 每次換頁都要重新載入的問題,由 SSR with hydration 用「初次渲染交給伺服器,後續交由客戶端處理」的方式解決了,但 SSR with hydration 仍有些缺點,除了上述提到的,SSR with hydration 的運作流程中,所有行為都是串聯的,每個步驟都須完全完成,才能進行下一步:
圖 3 SSR with hydration 瀑布流行為示意圖(資料來源:自行繪製)
可看出上述存在瀑布流問題:取資料 -> 渲染 HTML(server) -> 載入程式碼(client) -> hydrate(client),這個流程將導致效能瓶頸。而要如何解決問題? 就是將工作分開,針對每一個區塊執行每個階段任務,各區塊自己進行自己的任務,而這個解法也在 React 18 時提出:
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。
圖 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 的區塊。
圖 5 Comments
渲染完成後取代 spinner (資料來源:自行繪製)
不過此方法需確保 data fetching 的方法可整合 Suspense
一起使用,例如原生 fetch
方法就不支援。
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。
圖 6 可先進行部分元件的 hydration(資料來源:自行繪製)
Selective Hydration 搭配 Concurrent Features 後,能根據使用者的互動行為切換 hydration 的優先級。如果使用者先和某元件互動,就優先 hydrate 該部分,解決「必須全部完成 hydration 後,才可以開始互動」的問題。
React 在 hydrating 時也可立即反應使用者的互動,用 Suspense
包住的元件的 hydration 任務可被切分,讓使用者互動可在任務與任務間被執行,不會有主線程被阻塞的問題。舉例來說,如果 React 正在 hydrating A 元件,但使用者點擊了另一個還沒 hydrate 的 B 元件,React 會在捕捉到點擊事件時切換改先 hydrate B 元件,先反應使用者的互動,因此不用等全部完成 hydration 才能互動,使用者先互動的就會先 hydrate,改為選擇性、有優先級的 hydration。
圖 7 原先正在 hydrating Sidebar
元件(資料來源:自行繪製)
圖 8 當使用者點擊另一個還沒 hydrate 的 Comments
元件 (資料來源:自行繪製)
圖 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。示意圖如下,每個元件有自己的任務要執行,不會管其他元件如何,但要注意的是,這些任務仍然是在主線程同步執行,不是平行執行的。
圖 10 各元件有自己的任務要執行(資料來源:自行繪製)
另外再附上加上 Streaming Server-Side Rendering 和 Selective Hydration 的流程圖如下,可看出 Streaming 和 Selective Hydration 的方式可讓網頁有更快的 FCP 與 TTI。優點就是使用者可更快看到網頁內容、達到更好的使用者體驗;而缺點則是更複雜的開發成本,開發者要理解此策略的原理也需要花費更多心力。
圖 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