從 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。
這是典型的技術債:能用、但每次都要付利息,而且遲早會忘。
最直覺的答案是「用打包工具,它會自動做」。但那要付的代價是:
node_modules(幾百 MB)file:// 直開失效而我需要的功能只有一個:把檔案內容的哈希注入 HTML 的引用路徑。這是 60 行 Node 腳本的工作。
| 方式 | 檔名 | 需要的 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 是有意義的(它記錄了哪些資產變了)。
檔名/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 本身被快取一年:
sw.js(例如 Day 16 的 VERSION 從 v1 升到 v2)。sw.js。SHELL_FILES 快取著舊的資產清單。這是 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 的清單(更精確但更複雜) */
我選 A:ignoreSearch: true 一個參數解決,而且語意正確(帶哈希的資產,忽略 query 去比對快取是安全的——因為新哈希的請求在快取裡找不到就會去網路拿)。
但要注意:ignoreSearch 用在 HTML 上是錯的。chapter.html?ch=ch09 與 chapter.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 帶各自的哈希,複雜度不值得。
今天的重點:
Cache-Control: immutable 才是讓回訪從 28 個請求變成 2 個的那一半。這也是 Day 21 排除 GitHub Pages 的具體原因。sw.js 絕對不能快取——它是更新鏈的起點,被快取就是把網站變成磚塊。ignoreSearch: true 才會與帶哈希的請求對上。--check 模式讓腳本能安全接進 CI。明天把安全 header 做完整:CSP 從 <meta> 升級成真正的 HTTP header(終於能用 frame-ancestors)、整理成單一來源(避免 Day 23 那種雙份疊加事故)、加上 HSTS 與其他四個 header,並設定 CSP violation 回報。