iT邦幫忙

2024 iThome 鐵人賽

DAY 28
2
JavaScript

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

[Day 28] Static Rendering/Static Site Generation (SSG)

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20241012/20168201IyMUMHOSEe.png
根據前面的介紹,我們知道在 SSR 渲染模式中,如果伺服器遇到高流量請求時,會延後 TTFB 的時間;而 CSR 的缺點則是大型的 JavaScript bundle 可能會延後應用程式的 FCP、LCP 和 TTI 的時間,因為下載和處理肥大的 JavaScript 檔案需要時間,那是否有方法能解決這兩者的缺點呢?Static Rendering 或稱為 Static Site Generation(SSG) 試圖透過向客戶端提供預先渲染好的 HTML 來解決這些問題,接下來就來看看 Static Rendering 的運作吧~

Static Rendering 運作流程

  1. 週期性地執行 build 以讓伺服器依照當下資料預先渲染每個路徑對應的 HTML 檔案
    • 預先產生好的靜態 HTML 檔案可以存在伺服器或 CDN 上
      https://ithelp.ithome.com.tw/upload/images/20241012/20168201a73NaKcvGw.jpg
      圖 1 在 build 階段渲染 HTML(資料來源:自行繪製)
  2. 使用者輸入網址請求網頁,瀏覽器向伺服器請求資源
  3. 伺服器(或 CDN)根據請求的路由,立即回應預先渲染好的對應 HTML 檔案 home.html
  4. 瀏覽器收到檔案後,開始渲染 home.html
  5. 瀏覽器向伺服器請求 JavaScript bundle 檔案 home.js
  6. 瀏覽器執行 home.js 內的 JavaScript 程式碼,以此水合(hydrate)頁面上的元件,讓元件可互動
  7. 後續使用者若要導向其他頁面,就要重新向伺服器請求該頁的 HTML

因為 SSG 已事先準備好 HTML,可減少 SSR 在伺服器處理請求、渲染 HTML 內容和回應請求花的時間,因此可更快回應使用者檔案,帶來更快的 TTFB 和 FCP,且因為 HTML 已事先渲染,能減少 JavaScript 程式碼大小,因此也可以更快的解析 JavaScript 來讓頁面具有互動性,TTI 較好。
SSG 流程示意圖如下。
https://ithelp.ithome.com.tw/upload/images/20241012/20168201xWoM5S0iVj.jpg
圖 2 SSG 流程示意圖(資料來源:自行繪製)

Static Rendering 和 Server Side Rendering 差異

Static Rendering 運作流程大多數與 Server Side Rendering 相似,兩者都會預先把 HTML 產生出來,兩者間最重要的差別是「靜態頁面(HTML)產生的時間點」:

  • Static Rendering:靜態頁面是在 build 階段就已經預先渲染,伺服器收到請求時會重複使用這些已經生成好的靜態頁面
  • Server-side Rendering:伺服器每次收到請求時,才產生對應的靜態頁面

附上 Static Rendering 和 Server-side Rendering 兩者的示意圖以供比較:
https://ithelp.ithome.com.tw/upload/images/20241012/20168201V4rW4QnSya.jpg
圖 3 Static Rendering 運作示意圖(資料來源:參考 https://nextjs.org/learn-pages-router/basics/data-fetching/two-forms 後自行繪製)
https://ithelp.ithome.com.tw/upload/images/20241012/20168201d04R0N75K4.jpg
圖 4 Server-side Rendering 運作示意圖(資料來源:參考 https://nextjs.org/learn-pages-router/basics/data-fetching/two-forms 後自行繪製)

適合情境與支援的框架

Static Rendering 會預先渲染 HTML 檔案,不像 SSR 會在每次使用者請求時,根據使用者身份而產出不同 HTML,因此 Static Rendering 更適合那種「不常更改、無論誰請求都顯示相同資料的頁面」,例如:關於我們、聯繫我們、部落格文章頁或產品說明頁面。

有支援 Static Rendering 的框架如:Next.jsGatsbyAstroDocusaurusVitePressHugo

基本範例

以 Next.js 來說明 SSG 基本架構,以下是一個不包含動態資料的靜態頁面:

// pages/contact.js

export default function Contact() {
  return (
    <div>
      <h1>Contact Us</h1>
      <p>If you have any questions, feel free to reach out!</p>
      {/* other contact information */}
    </div>
  );
}

當我們執行 next build 建構網站時,此頁面將被預渲染為一個 HTML 檔案 contact.html,可透過路由 /contact 存取。

優點

SEO 友善

因為 HTML 檔案都預先渲染、建立好,所以搜尋引擎的爬蟲可從中建立索引,對 SEO 友善。

伺服器負擔較輕

因為 HTML 檔案是預先被建立好,而不是使用者請求時才產生,所以伺服器的負擔較輕,有更快的 TTFB、FCP、TTI。

可被快取

預渲染的 HTML 都是靜態內容,可以被 CDN 或 Vercel Edge Network 快取,能減少傳統 SSR 處理請求的時間。

缺點

大量 HTML 文件

對於整個應用程式的所有路徑,都要產生單獨的 HTML 文件,例如,使用 SSG 建立部落格應用程式時,資料庫中的每一篇文章都需要生成一個 HTML 文件,且如果文章需要修改時,都要重新 build 才能將更新的內容反應在靜態 HTML 中。要管理大量的 HTML 檔案可能會變得困難、有挑戰性。

不適合動態內容

當網頁內容有更改時,SSG 網站需要重新 build 和重新部署,如果網站沒有在內容更改後 build 和部署,顯示的網頁內容可能會變得過時,高度動態內容對 SSG 來說較不好管理,除非使用 SSG 的變體,否則 SSG 不適合有太多動態內容的網站。

快取可能要花更多成本

可被快取是優點也可能是缺點,當網頁內容變多、HTML 檔案變多,CDN 快取就會需要花費更多機器成本。

加入動態資料:SSG 的變體

純粹的靜態渲染(例如上面的 contact 範例)不涉及任何動態、會變動的資料。如果要在 SSG 加入動態資料,可考慮以下方式。
(以下將純粹、沒有動態資料的靜態渲染稱為「一般 SSG」,以便和其他有動態資料的 SSG 變體區別)

使用客戶端取得資料的靜態渲染(Static Rendering with Client-Side fetch)

對於靜態資料與畫面使用靜態渲染預先渲染好,而預計要放動態資料的位置則先使用骨架元件(skeleton component)來渲染,等到客戶端載入頁面後,客戶端再發送 API 去取動態資料以填入頁面中。
示意圖如下,不同於一般 SSG 可擁有幾乎同時的 FCP 和 LCP,Client-Side fetch 的 FCP 和 LCP 會有點距離,因為客戶端需要等 API 資料回來才能渲染畫面。
https://ithelp.ithome.com.tw/upload/images/20241012/20168201olW7IGJhe1.jpg
圖 5 Static Rendering with Client-Side fetch 流程示意圖(資料來源:自行繪製)

適合情境

  • 每次載入頁面都要顯示最新資料
    • 例如:需要一直顯示最新列表資料的列表頁面
  • 有包含穩定 placeholder 或 skeleton 的元件

優點

  • 可以有更好的 TTFB 和 FCP:因為此方式伺服器一開始回傳的 HTML 結構相對簡單(沒有包含所有動態資料),所以伺服器可以更快回傳 HTML、瀏覽器可以更快渲染畫面。

缺點

  • LCP 表現較差:因為「最大內容」只能在從 API 拿到資料後才會顯示出來
  • 可能產生佈局偏移(CLS):如果 skeleton UI 大小和最終渲染的內容大小不合的話,可能會產生佈局偏移(CLS),影響使用者體驗
  • 可能導致更高的伺服器成本:因為每次使用者請求頁面後,都要再發送一次 API 請求取得動態資料,但可能這些動態資料對不同使用者來說都依樣(沒有身份區別性,例如最新商品資料,誰發 API 都會拿到一樣的)

因應上述缺點,Next.js 提供了一些解決方案如下,以改善 SSG 處理動態資料時的效能。

使用資料庫的動態資料,靜態產生列表頁

可透過在 Next.js 頁面元件(page component)中 export getStaticProps() 函式來達成。getStaticProps() 會在建構(build)階段於伺服器端執行,並將回傳的 props 傳遞給頁面元件,讓頁面元件使用這些 props 中的資料來預先渲染頁面。
因為 getStaticProps() 是在 build 階段執行的,所以它不會包含在客戶端的 JavaScript bundle 中。此外,因為 getStaticProps() 是在伺服器端執行的,它也可以用來直接從資料庫拿取資料。

// pages/articles.js

// 1. 這個函式在 build 伺服器的 build 階段執行,會從資料庫取得所有 article 資料並回傳
export async function getStaticProps() {
  return {
    props: {
      articles: await getArticlesFromDatabase(),
    },
  };
}

// 2. 頁面元件在 build 階段從 getStaticProps 收到 articles prop,以此渲染頁面
export default function Articles({ articles }) {
  return (
    <>
      <h1>Articles</h1>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </>
  );
}

取得資料並渲染頁面元件的流程示意圖如下。
https://ithelp.ithome.com.tw/upload/images/20241012/20168201JLSRFE5whM.jpg
圖 6 取得資料並渲染頁面元件流程示意圖(資料來源:自行繪製)

整個請求的過程示意圖如下,當使用者請求頁面時,過程看起來類似一般 SSG,HTML 可被 CDN 緩存,瀏覽器會渲染 HTML 然後抓取需要的 JavaScript bundle 以進行 hydration。從瀏覽器角度來看,網路和 main thread 的運作也都和一般 SSG 相同,因此可擁有與一般 SSG 類似的效能。
https://ithelp.ithome.com.tw/upload/images/20241012/20168201zDvqtiBIRa.jpg
圖 7 使用資料庫動態資料,靜態產生列表頁流程示意圖(資料來源:自行繪製)

適合情境

  • 動態資料不會因使用者而不同(not user-specific)
  • 動態資料在 build 階段就可用、可取得
  • 頁面內容取決於外部資料的情境,這些動態資料會在建構階段從資料庫獲取,並用於預先渲染頁面
    • 如:商品列表或文章列表等頁面,其中列表本身相對靜態,但部分資訊來自外部資料來源
  • 動態資料不經常變更的情況,因為變更一次就要重新 build 一次,多次變更會導致頻繁 build

優點

可解決 Static Rendering with Client-Side fetch 的缺點,因為此方法可避免在客戶端發送 API 來請求資料,且資料載入時不需要骨架元件,因為頁面會直接渲染數據,同時客戶端會收到已渲染好的畫面,避免佈局偏移。

缺點

  • 影響開發者體驗(DX):隨網站規模變大,此方法的開發者體驗可能會變差,如果像部落格網站需要生成數百個靜態頁面,就需要反覆呼叫 getStaticProps 方法,導致 build 時間變長
  • 頻繁請求 API:承上,如果要生成大量頁面就需要頻繁請求 API,如果使用的是外部、第三方 API,可能會達到請求限制,或者產生大量的使用費用

使用動態路由,靜態產生詳細資訊頁

可在 Next.js 用動態路由搭配 getStaticPaths() 函式來達成。
首先先建立一個共用的頁面元件 articles/[id].js[id] 在 Next.js 就是動態路由的意思。建立元件後,在其中 export getStaticPaths() 函式,getStaticPaths() 函式會回傳所有可能的 article id,例如有 110111112 的 article,就會回傳告訴 Next.js 這個應用程式會有 articles/110articles/111articles/112 這些路徑。
在 build 階段時,Next.js 就會根據這些路徑去一一預渲染這些頁面,去渲染 articles/110articles/111articles/112 這些頁面並產生對應的 HTML 檔案。

// pages/articles/[id].js

// 1. 這個函式在 build 階段被執行,用來回傳一個包含所有要預先生成的動態路由(paths)頁面的列表。在這裡 getStaticPaths 會從資料庫中取得所有文章,並生成一個包含所有文章 id 的 paths 陣列
export async function getStaticPaths() {
  const articles = await getArticlesFromDatabase();

  const paths = articles.map((article) => ({
    params: { id: article.id },
  }));

  // fallback: false 代表如果使用者請求的文章 id 不在列出的資料中,代表 id 不符合,則頁面會顯示 404
  return { paths, fallback: false };
}

// 2. getStaticProps():當 Next.js 根據 getStaticPaths() 回傳的 paths 建置每個靜態頁面時,會自動將對應路徑中的參數(例如 id)傳遞給 getStaticProps() 的 params 參數,getStaticProps 再根據這個參數帶的 article id 來取出文章資料,然後傳給頁面元件做渲染
// 舉例來說,當 Next.js 需要預先生成 /articles/110 頁面時,getStaticProps 的 params 將包含 { id: "110" },getStaticProps 可以利用這個 id 來取得特定文章的資料,並將其作為 props 傳遞給該頁面元件
export async function getStaticProps({ params }) {
  return {
    props: {
      article: await getArticleFromDatabase(params.id),
    },
  };
}

export default function Article({ article }) {
  // 渲染文章資訊
}

這種方式適合的情境是詳細資訊的頁面,例如產品或部落格頁面,這類詳細頁面通常會有某些固定模板,然後再將動態資料填入佔位符(placeholder)中,實作時就是將固定模板和動態資料合併,為每個詳細頁面產生個別頁面。

小結

SSG 的渲染方式會預先渲染好所有路由需要的 HTML 檔案,當檔案變多以後就會變得較難維護,且每次要更新內容時就要重新 build,耗費大量 build 成本,因此後來發展出 SSG 的優化方案 Incremental Static Generation(或稱 Incremental Static Regeneration,ISR),將在下篇介紹。

Reference


上一篇
[Day 27] Server Side Rendering(SSR)、Streaming Server-Side Rendering、Selective Hydration
下一篇
[Day 29] Incremental Static Generation/Regeneration(ISR) 與渲染模式總結
系列文
30天的 JavaScript 設計模式之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言