iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 24

Day 24 — 快取破壞:把手寫 `?v=` 換成內容哈希

  • 分享至 

  • xImage
  •  

今天要解的問題

從 Day 2 開始,我的 HTML 長這樣:

<link rel="stylesheet" href="css/style.css?v=20260723d">
<!-- script src="js/models/curriculum.js?v=20260723d" -->
<!-- script src="js/views/lesson-view.js?v=20260723d" -->
<!-- …再 20 幾行相同的 ?v=20260723d… -->

那個 20260723d 是我手打的。 每次改 JS/CSS,我要記得:把三個 HTML 檔裡的三十幾處版本號全部改掉。

實際發生的事:我改了 lesson-view.js 但忘記改版本號。上線後我自己的瀏覽器是好的(我一直在 hard reload),但回訪使用者拿到快取的舊 js 配新 HTML → 隨機的 undefined is not a function

這是典型的技術債:能用、但每次都要付利息,而且遲早會忘。

為什麼不裝 Vite

最直覺的答案是「用打包工具,它會自動做」。但那要付的代價是:

  • node_modules(幾百 MB)
  • 每次改課文要 rebuild(Day 1 明確拒絕的事)
  • 建置後的產物與原始碼分離,file:// 直開失效

而我需要的功能只有一個:把檔案內容的哈希注入 HTML 的引用路徑。這是 60 行 Node 腳本的工作。

兩種 cache busting 策略

方式 檔名 需要的 header
Query string style.css?v=a1b2c3 Cache-Control: max-age=31536000
檔名哈希 style.a1b2c3.css 同上,且可以加 immutable

看起來差不多,但有一個實質差異:部分 CDN 與 proxy 會忽略 query string 做快取(把 ?v=1?v=2 當成同一個資源)。這在 2026 年已經很少見,但仍存在於某些企業 proxy。

不過 query string 有一個對我很重要的優勢:原始檔名不變,file:// 直開仍然可用(Day 1 的硬需求)。檔名哈希的話,style.a1b2c3.css 這個檔案在開發時不存在,file:// 直開就會 404。

所以我用 query string + 內容哈希,並且用 Cache-Control 補足:

<link rel="stylesheet" href="css/style.css?v=8f3a1c2d">

stamp-assets.js

#!/usr/bin/env node
/* 用檔案內容哈希取代 HTML 裡的 ?v= 版本戳。
   可重跑、幂等;--check 只檢查不寫入(給 CI 用)。 */
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const root = path.resolve(__dirname, "..");
const HTML = ["index.html", "course.html", "chapter.html", "tables.html",
              "glossary.html", "dashboard.html", "404.html"];
const check = process.argv.includes("--check");

const hash = file => crypto.createHash("sha256")
  .update(fs.readFileSync(file)).digest("hex").slice(0, 8);

/* 記憶化:同一個檔案在多個 HTML 裡出現,只算一次 */
const cache = new Map();
const hashOf = rel => {
  if (!cache.has(rel)) {
    const abs = path.join(root, rel);
    cache.set(rel, fs.existsSync(abs) ? hash(abs) : null);
  }
  return cache.get(rel);
};

let changed = 0, missing = [];

for (const name of HTML) {
  const file = path.join(root, name);
  if (!fs.existsSync(file)) continue;
  const before = fs.readFileSync(file, "utf8");

  /* 抓 src="…" / href="…" 指向本地 js/css 的引用 */
  const after = before.replace(
    /((?:src|href)=")([^"?]+\.(?:js|css))(\?v=[^"]*)?(")/g,
    (m, pre, rel, _old, post) => {
      if (/^https?:\/\//.test(rel)) return m;         // 外部資源不動
      const h = hashOf(rel);
      if (!h) { missing.push(`${name} → ${rel}`); return m; }
      return `${pre}${rel}?v=${h}${post}`;
    });

  if (after !== before) {
    changed++;
    if (!check) fs.writeFileSync(file, after);
  }
}

if (missing.length) {
  console.error("引用的檔案不存在:");
  missing.forEach(m => console.error(`  ✗ ${m}`));
  process.exit(1);
}

if (check) {
  if (changed) {
    console.error(`::error::${changed} 個 HTML 的資產版本戳過期,請跑 node scripts/stamp-assets.js`);
    process.exit(1);
  }
  console.log("資產版本戳與內容一致。");
} else {
  console.log(`更新 ${changed} 個 HTML,共 ${cache.size} 個資產`);
  for (const [rel, h] of cache) console.log(`  ${h}  ${rel}`);
}

三個設計點:

1. 幂等。 跑兩次結果一樣(哈希只依賴檔案內容)。這讓它能安全地放進任何流程,也讓 --check 模式有意義。

2. --check 模式給 CI 用。 這是 Day 15 那招「重跑產生器 + git diff --exit-code」的變體,但更直接——不需要動用 git。

3. 順手驗證引用的檔案存在。 Day 22 的 guard job 有一個「路徑大小寫與存在性」檢查,這裡自然地涵蓋了 js/css 的部分(而且更精確,因為它真的去讀檔算哈希)。

哈希只取 8 個 hex 字元(32 bits)。碰撞機率對幾十個檔案來說完全可忽略,而 URL 短很多。

接進流程

本機:改完 JS/CSS 之後跑一次。

node scripts/stamp-assets.js

CI(Day 22 的 static job):加一個檢查,確保沒忘記跑。

      - name: 資產版本戳必須與內容一致
        run: node scripts/stamp-assets.js --check

要不要 commit 進版控? 我選 commit

理由:如果不 commit,那 repo 裡的 HTML 就是「沒有版本戳的樣板」,而 file:// 直開時所有 ?v= 都是空的(無害),但開發時與線上的 HTML 不同——這會讓「本機過但線上壞」的可能性增加(Day 23 已經被咬過一次)。commit 進去的代價是每次改 JS 都會有 HTML 的 diff,但那個 diff 是有意義的(它記錄了哪些資產變了)。

Cache-Control:這才是真正生效的部分

檔名/query 的哈希只解決一半問題。 它保證「新內容有新網址」,但不保證「瀏覽器會積極快取」。

Day 21 我拒絕 GitHub Pages 的主因就在這裡:它的 Cache-Control 固定是 max-age=600,我的哈希再精確,使用者每 10 分鐘還是要發一輪條件請求。

Cloudflare Pages 的 _headers

# ── 帶哈希的資產:一年 immutable ──────────────
/js/*
  Cache-Control: public, max-age=31536000, immutable
/css/*
  Cache-Control: public, max-age=31536000, immutable
/vendor/*
  Cache-Control: public, max-age=31536000, immutable
/icons/*
  Cache-Control: public, max-age=31536000, immutable

# ── HTML:永不快取(它引用了帶哈希的資產) ──────
/
  Cache-Control: public, max-age=0, must-revalidate
/*.html
  Cache-Control: public, max-age=0, must-revalidate

# ── Service Worker:絕對不能快取 ──────────────
/sw.js
  Cache-Control: no-cache, no-store, must-revalidate

# ── manifest:短快取 ──────────────
/manifest.webmanifest
  Cache-Control: public, max-age=3600

四條規則的邏輯:

資源 策略 為什麼
js/css/字型 max-age=31536000, immutable 網址含內容哈希,內容永不改變
HTML max-age=0, must-revalidate 它是「哪些哈希是當前的」的唯一來源
sw.js no-store ← 最重要的一條,見下
manifest 1 小時 很少變,但不是 immutable

sw.js 為什麼不能快取

Service Worker 是更新鏈的起點。如果 sw.js 本身被快取一年:

  1. 我改了 sw.js(例如 Day 16 的 VERSION 從 v1 升到 v2)。
  2. 使用者的瀏覽器拿到快取的舊 sw.js
  3. 舊 SW 的 SHELL_FILES 快取著舊的資產清單。
  4. 使用者永遠卡在舊版,而且我沒有任何辦法從伺服器端推動更新。

這是 PWA 最惡名昭彰的坑:一個不能快取的檔案被快取了,整個網站就變成磚塊。

瀏覽器對 sw.js 有一個保護(會忽略超過 24 小時的快取),但明確設 no-store 才是正解。

immutable 指令的意義是「連條件請求都不要發」——正常的 max-age 過期後瀏覽器會發 If-None-Match 拿 304,immutable 讓它在有效期內完全不問。對帶哈希的資產這是安全的(內容變了網址就變了)。

驗證快取真的生效

#!/bin/bash
# scripts/check-cache.sh <base>
BASE="${1:?}"
fail=0

expect() {   # expect <path> <regex> <說明>
  got=$(curl -sI "$BASE$1" | grep -i '^cache-control:' | tr -d '\r')
  if echo "$got" | grep -iqE "$2"; then echo "  ✓ $3"
  else echo "  ✗ $3(實際:${got:-無})"; fail=1; fi
}

# 先從 HTML 裡撈出一個帶哈希的資產路徑
asset=$(curl -s "$BASE/index.html" | grep -oE 'js/[a-z/-]+\.js\?v=[0-9a-f]{8}' | head -1)
echo "== 快取策略 =="
expect "/$asset"              'max-age=31536000'      "帶哈希資產:一年"
expect "/$asset"              'immutable'             "帶哈希資產:immutable"
expect "/index.html"          'max-age=0|no-cache'    "HTML:不快取"
expect "/sw.js"               'no-store|no-cache'     "sw.js:不快取"

# 哈希必須真的隨內容變(同一份內容應得到相同哈希)
h1=$(curl -s "$BASE/index.html" | grep -oE 'style\.css\?v=[0-9a-f]{8}')
h2=$(curl -s "$BASE/index.html" | grep -oE 'style\.css\?v=[0-9a-f]{8}')
[ "$h1" = "$h2" ] && echo "  ✓ 哈希穩定" || { echo "  ✗ 哈希不穩定"; fail=1; }

exit $fail

接進 Day 23 的部署後驗證:

      - run: bash scripts/check-cache.sh https://learnpath.example.com

實測效果

用 DevTools 的 Network 面板,模擬回訪(不 hard reload):

之前(?v=20260723d + Pages 預設 600s) 之後
回訪請求數 28(全部 304) 2(HTML + 一個過期的 manifest)
回訪傳輸量 12 KB(都是 304 的 header) 3 KB
回訪 LCP 0.9 s 0.4 s
忘記改版本號的風險 無(CI 擋住)

304 的成本不是頻寬,是每個請求一趟 RTT。28 個 304 在 4G 上(RTT 約 60 ms,HTTP/2 多工後仍有排隊)就是 0.3–0.5 秒的純等待。

踩到的雷

Service Worker 的快取清單也需要更新。 Day 16 的 SHELL_FILES 寫的是不帶版本的路徑:

const SHELL_FILES = ["./css/style.css", "./js/views/lesson-view.js", /* … */];

而 HTML 現在請求的是 css/style.css?v=8f3a1c2d這是兩個不同的 cache key——SW 預快取的 ./css/style.css 永遠不會被命中,而 ?v= 版本走的是 runtime 快取。

功能上沒壞(runtime 快取會接手),但預快取白做了,第一次離線可能缺檔。修法有兩條:

/* 方案 A:SW 的 fetch handler 忽略 query 做比對 */
e.respondWith(
  caches.match(req, { ignoreSearch: true })      // ← 關鍵
    .then(hit => hit || fetch(req).then(res => { /* … */ }))
);
/* 方案 B:讓 stamp-assets.js 也更新 sw.js 的清單(更精確但更複雜) */

我選 AignoreSearch: true 一個參數解決,而且語意正確(帶哈希的資產,忽略 query 去比對快取是安全的——因為新哈希的請求在快取裡找不到就會去網路拿)。

但要注意:ignoreSearch 用在 HTML 上是錯的chapter.html?ch=ch09chapter.html?ch=ml01 是同一個檔案(query 只給 JS 讀),所以剛好沒問題。但如果有「query 決定內容」的頁面,就必須分開處理。

immutable 在開發時很煩。 本機 http.server 不送 Cache-Control,所以沒問題。但如果你用 nginx 之類的本機伺服器並複製了線上設定,改一行 CSS 要清快取才看得到——因為 immutable 讓瀏覽器連問都不問。開發環境不要套用 immutable

哈希長度與 CDN 快取的交互。 Cloudflare 預設會快取靜態資產,而它的 cache key 包含 query string。所以新哈希 = 新的 CDN cache key = 自動失效,不需要手動 purge。這是 query string 策略的一個附帶好處(Day 26 的 CloudFront 就沒這麼簡單,要處理 invalidation)。

順手做的一件事:ASSET_VER 全域變數

Day 19 的動態載入(LessonLoader)需要知道當前的版本戳:

s.src = `js/lessons/${name}.js?v=${window.ASSET_VER || ""}`;

window.ASSET_VER 從哪來?讓 stamp-assets.js 順手產生一個小檔:

/* stamp-assets.js 尾端 */
const verFile = path.join(root, "js/asset-ver.js");
/* 用所有資產哈希的組合哈希當全站版本 */
const combined = crypto.createHash("sha256")
  .update([...cache.entries()].sort().map(([k, v]) => `${k}:${v}`).join("\n"))
  .digest("hex").slice(0, 8);
if (!check) fs.writeFileSync(verFile, `window.ASSET_VER = "${combined}";\n`);

課文檔(20 個檔、1.4 MB)就跟著全站版本走。它們變動時全站版本也會變,所以正確性沒問題;代價是「改一課的課文,全部課文檔的快取都失效」。對我這種批次寫課文的節奏完全可接受——真要精確就得讓 LessonLoader.FILES 帶各自的哈希,複雜度不值得。

小結與明天預告

今天的重點:

  1. 手寫版本號是會付利息的技術債,而且遲早會忘(我已經忘過一次)。60 行腳本取代之,不需要 Vite。
  2. 哈希只解決一半Cache-Control: immutable 才是讓回訪從 28 個請求變成 2 個的那一半。這也是 Day 21 排除 GitHub Pages 的具體原因。
  3. sw.js 絕對不能快取——它是更新鏈的起點,被快取就是把網站變成磚塊。
  4. SW 的預快取要用 ignoreSearch: true 才會與帶哈希的請求對上。
  5. 幂等 + --check 模式讓腳本能安全接進 CI。

明天把安全 header 做完整:CSP 從 <meta> 升級成真正的 HTTP header(終於能用 frame-ancestors)、整理成單一來源(避免 Day 23 那種雙份疊加事故)、加上 HSTS 與其他四個 header,並設定 CSP violation 回報。


上一篇
Day 23 — 第一次上線:Cloudflare Pages + 自訂網域
系列文
知識圖譜 : 技能樹式學習歷程24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言