||-Just-Func-||

靜態資源快取管理 Static Assets Caching

Photo by picsum

快取 (Caching) 可以說是軟體開發中最常見也最麻煩的問題之一。

在這篇文章中,我們將會討論到,
我們要如何處理靜態資源的快取 (Static Assets Caching),
以及我們是如何解決快取導致的相關問題。

什麼是快取?

快取 (Caching) 是指將資料儲存在記憶體中,以便未來能夠更快的存取資料。
我們可以簡單將快取視做一種 key-value 的資料結構,
我們可以透過 key 來取得對應的快取資料,
且這類資料通常具有時效性,
當快取過期時,我們就必須重新取得資料。

a.k.a 快取主要注意兩個點,KEY 跟 時間。

什麼是靜態資源快取 (Static Assets Caching)?

這篇談到的 靜態資源快取 (Static Assets Caching),是指將靜態資源檔案儲存在用戶的機器上。
這樣當用戶再次使用我們的應用程式,或是導覽到不同頁面但使用相同檔案時,
使用者的瀏覽器就不必再從伺服器下載檔案,瀏覽器可以重複使用用戶機器上的檔案副本。
這項技術讓應用程式擁有更快的處理速度,以便帶給用戶更好的體驗,
以及它也同時減少了伺服器的負擔。

技術上來說並沒有所謂靜態資源快取 (Static Assets Caching) 這個名詞,這些都被歸類在 HTTP Cache
但透過這樣區分我們可以比較簡單分辨哪些資源應該要怎麼處理。

雖然實務上有蠻多細節,但大致上瀏覽器會根據 GET request url 來儲存對應的 response,
並會在快取過期時,瀏覽器才會再次發出 request 來取得最新的 response。

靜態資源快取產生的問題

雖然靜態資源快取 (Static Assets Caching) 有很多好處,
但它同時也會給我們開發者帶來一些麻煩。

常見的問題像是:

  1. 我剛發佈新版本,但為什麼用戶的電腦上怎麼還是舊的?
  2. 用戶瀏覽器沒清快取,有什麼辦法讓用戶能夠拿到最新的資源?

當我們新增了新功能或修復了個錯誤,發佈到伺服器上,
會希望用戶能夠立即獲取最新的資源,而不是繼續使用舊的資源,
但同時也希望若資源沒有新的版本時,可以被用戶重複利用越久越好。

這看似是一個矛盾的問題,但我們可以透過一些技巧來解決這個問題,

思路

index.html

當用戶使用我們的應用程式時,瀏覽器第一個會拿 index.html
假設我們的 index.html 長這樣:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    ...
    <script src="/script.js"></script>
  </body>
</html>

因為在拿到第一發 response 之前用戶是無法判定是否有新的版本,
index.html 這發 request 算是必要開銷。

index.html 這發,瀏覽器發出的 request 會附帶 cache-control: max-age=0 來避免使用快取。

瀏覽器拿到 index.html 後,
根據 HTML 瀏覽器會發出 style.cssscript.js 的 request,
而這些就是我們想要處理的靜態資源。

如果瀏覽器預設會快取的話,
我們只需要聚焦在如何讓用戶能夠拿到最新的資源就行了。

根據檔案內容產生對應的檔名

要避免瀏覽器使用快取,最直接的方法就是讓瀏覽器認為這是一個新的檔案,
而要讓瀏覽器認為這是一個新的檔案,最簡單的方法就是改變檔案的 URI (a.k.a 取得檔案的路徑)。

根據這樣的思路,假如我們可以讓每次發佈新版本時,
檔案產生檔名都不一樣,那麼瀏覽器就會認為這是一個新的檔案,
而不會使用舊的快取。

我們延續前面的 index.html
假設用戶已經使用過前一版的 index.html,並且已經有 style.cssscript.js 的快取,
而我們發佈了新版本的 index.html,新版本的 html 長這樣:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <link rel="stylesheet" href="/style-1.css" />
  </head>
  <body>
    Content
    <script src="/script.js"></script>
  </body>
</html>

這時,因為 script.js 的 URI 沒有改變,所以瀏覽器會使用快取,
style.css 的 URI 改變了,所以瀏覽器會重新發出 request 來取得最新的資源。

URI

那具體來說我們要怎麼改變 URI 來達到我們的目的?
主要可以分成兩種方式:

  1. 透過 檔案名 添加版本號
    舉例,style.css 改成 style-1.css
  2. 透過 query string 添加版本號
    舉例,style.css 改成 style.css?v=1

這兩種方式都可以達到我們的目的。

但每次都需要手動改變檔案名稱或是 query string,不僅麻煩,也容易出錯。

Fingerprints

Fingerprints 是一種演算法,
會根據 檔案內容 產生一組比較短的對應字串,所以可以透過這組字串來比對檔案內容是否有變更。

Fingerprints 常被稱為 hash 或 checksum,
但在這個情境,更加精準的用詞應該是 Fingerprints。

Fingerprints 也可以使用 crypto hash 演算法來產生,
像是 MD5、SHA1、SHA256 等等。

透過 原檔案名 加上 Fingerprints 來自動產生對應檔案內容的唯一檔名,
這樣就可以避免每次發佈新版本時,都需要手動改變檔名,
如果檔案內容沒有變更,就會產生與前一次相同的檔名,這樣瀏覽器就可以使用快取。

Bundler

通常前端 bundler 都已經內建相應的功能,我們只需要設定一下就可以了。

Vite

在 Vite 中,我們什麼都不用做,因為 Vite 預設就會自動加上 Fingerprints。

Webpack

在 Webpack 中,我們可以透過 output.filename 來設定 初始載入檔案 的檔名,
output.chunkFilename 則是用來設定 動態加載檔案(lazy-loading) 的檔名。

module.exports = {
  output: {
    filename: "[name].[contenthash].js",
    chunkFilename: "[name].[contenthash].js",
  },
};

這樣就可以讓 Webpack 在產生檔案時,自動加上 Fingerprints 了。

關於 Webpack [hash], [contenthash] 的差異,就不在這篇文章中討論了。

Gulp

在 Gulp 中,我們可以透過 gulp-rev 來產生 Fingerprints。

const gulp = require("gulp");
const rev = require("gulp-rev");
 
gulp.task("default", function () {
  return gulp
    .src("src/*.css")
    .pipe(rev())
    .pipe(gulp.dest("dist"))
    .pipe(rev.manifest())
    .pipe(gulp.dest("dist"));
});

傳慘應對法

如果你很不幸的,沒有辦法使用任何 bundler,
這邊提供一些方案,讓你可以在不使用 bundler 的情況下,也能夠達到相同的效果。

透過 git 來產生 Fingerprints

git 提供了一個指令 git hash-object,可以根據檔案內容產生 Fingerprints。

git hash-object 使用 SHA1 演算法。

git hash-object style.css
660b740fcaef8ebc7904a3267993d42753d2a478

這個指令會給我們 40 個字元的 hash,我們不需要全部都用,只需要用前 8 個字元就可以了。

git hash-object style.css | cut -c 1-8
660b740f

在把這個 hash 加到檔名就可以了。

# 直接改檔名
mv style.css style-660b740f.css
# 或是複製一份
cp style.css style-660b740f.css

透過 sum 來產生 Fingerprints

這邊指的 sum 是指包含 cksum,md5sum,shasum 任意一種。

sum 是一種指令,通常會 OS 預設都會附帶,可以根據檔案內容產生 Fingerprints。

以下以 shasum 為例:

shasum style.css
f259b7033b395c6ca5f24279a33a75af76698713  style.css

shasum 預設使用 SHA1 演算法,如果在意安全性,可以改用 shasum -a 256 改用 SHA256 演算法。
不過只是比對檔名,一般來說 SHA1 應該就夠了。

這個指令會給我們 40 個字元的 hash,我們不需要全部都用,只需要用前 8 個字元就可以了,方法跟上面一樣。