無聊聊Blog

Hydration rendering 基礎篇: SSR + CSR

2023-01-21
Hydration rendering 基礎篇: SSR + CSR

之前一直沒去理解像是 NextJS, NuxtJS 等使用的「混合式 SSR」是什麼樣的技術以及原理,這次藉由簡報的機會釐清 SSR 的概念。

Web rendering

先從網頁的渲染說起,主要參考這篇:

https://developers.google.com/web/updates/2019/02/rendering-on-the-web

以下是一些網頁效能相關的名詞,後續會引用:

  • TTFB: Time to First Byte - seen as the time between clicking a link and the first bit of content coming in.
  • FP: First Paint - the first time any pixel gets becomes visible to the user.
  • FCP: First Contentful Paint - the time when requested content (article body, etc) becomes visible.
  • TTI: Time To Interactive - the time at which a page becomes interactive (events wired up, etc).

SSR

首先就是 SSR: Server-Side Rendering

Server 根據請求來渲染不同的頁面內容 (html), 頁面需要的資料由後端取得 前端通常只有一些簡單的頁面互動,因此 JS bundle size 較小。

SSR 可能需要一段時間來準備資料(較長的 TTFB) 但是有很快的 FCP 以及 TTI

ssr-1

SSG / pre-rendering

SSG: Server-Side Generation 或者有人叫 pre-rendering

在 Build time 的時候產生不同頁面,而非在 request 的時候及時產生頁面。

當頁面資料需要透過 API,DB Query 等操作動態取得要使用 SSR。

當頁面內容為靜態、不常更動、或是更動隨著 Code Change,便可以考慮使用 SSG。

ssr-2

CSR

隨著前端功能和互動越來越複雜,我們需要 Client-Side Rendering 和 SPA 的架構來建構富有互動性的網頁: 像是 Angular, React 和 Vue 等等。

Server 只回傳沒有內容的 html,等到 JS 加載完成再根據不同 url 渲染頁面,後續的頁面變化也都是在前端,依賴 JS 來渲染頁面。

CSR 和 SPA 大幅增進了前端開發的體驗,以及頁面的互動性,但是缺點也隨之而出:

  • 越來越大的 Javascript Bundle Size
  • 一開始的頁面空白,需等 JS 加載執行才有內容,有可能不利 SEO

其中肥大的 Javascript Code,造成加載和執行的速度變慢 FCP, TTI 時間變長,意味著使用者有很長時間看到的是空白或者不完整、還無法互動的頁面。

ssr-3

SSR + CSR

為了解決純 CSR 的問題,出現了 SSR + CSR 的技術(或者 SSG + CSR) 可以稱做混合式 SSR、Universal SSR, Rehydration。

先 SSR 產生 HTML 內容,到瀏覽器(前端)再注入(Hydrate)Javascript Code,由 CSR 掌控後續頁面的渲染。

好處是一開始就有頁面內容,後續也可以交由 CSR 產生豐富的互動性。

關於 SSR + CSR

這段來簡單看一下 SSR + CSR 這種架構會是什麼樣子。

假設,我們有一個 React 的 SPA 專案,並且有使用前端 router,想要實作混合式 SSR 我們還需要一個負責 Server-Side Rendering 的 server:

  • 稱作 rendering server
  • 根據不同的 path,伺服器端讀取 SPA 的架構, 將 React components 渲染成 string 然後產生 html。
  • 因為要讀取前端 Code 轉 string,所以通常也是 Js runtime => 使用 NodeJS。

rendering server minimal sample

一個簡易的 rendering server:

// example with koa
import React from "react";
import Koa from "koa";
import Router from "koa-router";
import path from "path";
import fs from "fs";

// import spa root component
import App from "../client/App";
// import function for SSR
import { StaticRouter } from "react-router-dom";
import { renderToString } from "react-dom/server";

const app = new Koa();

const indexTemplate = fs.readFileSync(
  path.join(__dirname, "../clientBuild/index.html"),
  "utf-8",
);
app.get("(.*)", (ctx) => {
  const result = renderToString(
    <StaticRouter location={ctx.request.url}>
      <App />
    </StaticRouter>,
  );
  // should use index.html template
  const html = indexTemplate.replace(/#{content}/, result);
  ctx.body = html;
});

app.listen(3000);
  • line 11, 12
    • StaticRouter, renderToString 分別是 react-router-domreact-dom 提供的 SSR 方法。
  • line 20 ~ 29
    • 可以看到這個 rendering server 根據不同 path 把對應的 component render 成字串,放進原本 html 的 SPA entry element。

以 HTML 來看,原本 SPA 的 HTML 長這樣:

<html>
  <head>
    /* meta */
    <title>SSR Practice</title>
  </head>
  <body>
    <div id="root">/* Inject in here */</div>
    <script src="/bundle.js"></script>
  </body>
</html>

Server 呼叫 React renderToString 方法產生字串:

<div>
  <h1>Home</h1>
  <ul>
    <li>Page1</li>
    <li>Page2</li>
  </ul>
</div>

塞進 HTML 對應的位置並回傳

<html>
  <head>
    /* meta */
    <title>SSR Practice</title>
  </head>
  <body>
    <div id="root">
      <div>
        <h1>Home</h1>
        <ul>
          <li>Page1</li>
          <li>Page2</li>
        </ul>
      </div>
    </div>
    <script src="/bundle.js"></script>
  </body>
</html>

Client 會接收到完整內容的 HTML,並且當 CSR Code /bundle.js 載入之後再 Hydrate 網頁,使之變成可互動的 React Component。

什麼是 Hydrate?

所謂的 Hydrate, Hydration 指的是什麼? 和普通的 render 有什麼差異?

React-dom 提供了兩種將 React Component 注入 Dom 的方法 分別是 render 以及 hydrate 一個用在純 CSR,一個用於 SSR Rehydration

ReactDOM.render(element, container[, callback]);
ReactDOM.hydrate(element, container[, callback]);

使用 render,任何存在於 container 的 DOM element 都會被替換。

但混合式 SSR 在 contaniner 裡面已經有 pre-render 的 DOM 結構,而且預期和之後 CSR render 的結構一樣,因此可以重複利用 DOM element。

hydrate 會去檢查既有的結構是否相符,並將相對應的事件加上,使之成為 CSR Component,而非像 render 直接替換掉整個 container 的 DOM element,相對起來更有效率。

這個動作就稱為Hydrate

Universal Application

以上的 rendering server 只是一個很簡易的範例,實作上會遇到許多問題,例如:

  • 如果 css 是靠 js 加載,例如使用 webpack style-loader 塞在 style tag,那直到 JS 載入之前,頁面會是沒有 Css 的狀態
  • 整合處理前端 Routing。
  • 整合 Redux 等 state 管理。
  • 處理資料:有些頁面資料並不是靜態,而是要透過 API 或 DB Query 等取得資料之後才能渲染出頁面內容,Hydrate 的時候 Component 也要能拿到資料。
  • ...

如果有興趣自己實作看看,可以看看這篇詳細教學

總而言之,要時做 rendering server + 現有 SPA 還是非常麻煩且有難度的 而且 SPA 和 rendering server 高度耦合卻又分別開發,會需要互相妥協和修正。

因此出現了一種可以同時開發兩者邏輯的框架,稱作Universal Application 也就是我們熟悉的 NextJS (for React), NuxtJS (for Vue) 等。

非常推薦在了解 SSR + CSR 技術後去看看 NextJS 文件,會對這種 Universal 框架有更深的瞭解。

缺點

TTI Delay

雖然頁面一開始就有內容,有了較快的 FCP 但是要等到 JS Bundle 加載完成並 Hydrate 頁面才能互動。

也就是 FCP 和 TTI 的時間拉長,這段時間內,使用者已經看到畫面,卻無法和頁面互動

ssr-4

events fire before hydration

有些 dom events 可能會比 JS Code 更早觸發

例如 image onLoad event,因為圖片已經被 render 在 html 上面 碰到類似情形就要用一些 work around 來抓取 events。 https://github.com/facebook/react/issues/15446

Enhancement

為了有更好的 SSR + CSR 體驗,有以下的改進方式:

Streaming Server

針對 rendering server,可以採用 Streaming server 進行優化。

用 chunks 的方式送 html,讓瀏覽器可以漸進的去接收 不用等整份 html render 完成,會有比較快的 FP, FCP

以 React 為例,提供的 streaming server 方法為的 renderToNodeStream()renderTostring 不同之處在於前者是非同步。

http
  .createServer((request, response) => {
    const html = ReactDOMServer.renderToNodeStream(<App />);
    response.pipe(html);
  })
  .listen(3000);

Web Performance

另一種是基本的前端 web performance 優化項目,旨在縮短 JS 加載的時間:

  • 減少 JS Bundle Size:
    • 移除不必要的 code, dependencies
    • 使用 tree shaking
  • Code Splitting:按需來分次加載 Code
  • 使用 CDN 部署
  • 使用 encoding 來降低傳輸內容的大小,Ex: gzip。
  • 最後還可以考慮 PWA,使用 service worker 來做 cache 的策略。

以上細節就不詳述囉~

Different Hydration Strategies

另外還可使用不同的 Hydration 策略,像是

  • Partial Hydration
  • Progressive Hydration

以下是我有找到的一些介紹:

Partial Hydration

Partial Hydration 就是只 hydrate 需要互動的 components,其他靜態的部分就沒有必要去 hydrate。

這篇有詳細的例子: https://medium.com/@luke_schmuke/how-we-achieved-the-best-web-performance-with-partial-hydration-20fab9c808d5

Progressive Hydration

Progressive Hydration 則是 需要的時候才 hydrate,例如在 onClick, onFocus 或者元件出現在 viewport 之內。

範例 repo: https://github.com/GoogleChromeLabs/progressive-rendering-frameworks-samples

可以看到 source code 使用了 IntersectionObserver,當 component 出現在 viewport 之內才會 hydrate 成 React Component。

普通的 Hydrate 是從整個 App root 下去 Hydrate 整個 Component, 而使用不同的 Hydration 方法:

  • 只 Hydrate 部分 components
  • 漸進式、需要的時候才進行 Hydrate

可以減少 JS Code size、和一開始 JS 加載時 Hydrate 的負擔。

SEO

為何最後要講到 SEO 因為很多人使用 SSR + CSR 都是為了 SEO 考量,因此從 SEO 角度來看我們是否需要真的需要使用 SSR。

CSR 不利於 SEO?

很多人的認知是純 CSR/SPA 因為頁面一開始空白,因此爬蟲爬不到內容。 但事實上現在很多搜尋引擎爬蟲,例如 Google,可以運行 JS,在頁面渲染之後再建立 index。

Meta tags

在不同頁面加上適當的 mata tags, title tag, OG tag 都可以幫助爬蟲索引你的網站,而這些 SPA 也做得到 (例如使用 react-helmet)。

Dynamic rendering

ssr-5

還可以使用 dynamic rendering 假設我們可以用 user-agent 等資訊判斷出請求是使用者還是爬蟲 就可以根據如果是戶用就用 SSR + CSR 如果是爬蟲 直接回傳 SSR 靜態的頁面就可以了。

總結

SSR + CSR 的架構解決了純 CSR 的一些缺點, 但也有自己的議題要解決。

非常建議可以去看這支影片: https://www.youtube.com/watch?reload=9&v=k-A2VfuUROg&ab_channel=GoogleChromeDevelopers 裡面提到 web rendering 像是一個光譜,你必需在 SSR 和 CSR 之間找到一個平衡。

Reference