這個網站有兩個外部依賴:
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC…" rel="stylesheet">
<!-- script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" -->
三個理由要拿掉它們:
script-src 從 'self' https://cdn.jsdelivr.net 收成 'self'。代價是體積與維護成本。今天把取捨算清楚。
tex-mml-chtml.js 是 MathJax 3 的「全部功能」打包版——TeX 輸入、MathML 輸入、CHTML 輸出,加上全部字型,總共約 30 MB(含所有字型格式與語言包)。
我只需要:TeX 輸入 + CHTML 輸出 + 一套字型。
npm pack mathjax@3 # 只下載 tarball,不裝進專案
tar xzf mathjax-3.*.tgz
mkdir -p vendor/mathjax
cp package/es5/tex-chtml.js vendor/mathjax/ # 注意:tex-chtml 不是 tex-mml-chtml
cp -r package/es5/output/chtml/fonts/woff-v2 vendor/mathjax/output/chtml/fonts/
du -sh vendor/mathjax # 約 4.2 MB
從 30 MB 降到 4.2 MB。關鍵是三件事:
| 動作 | 省下 |
|---|---|
tex-chtml 取代 tex-mml-chtml |
拿掉 MathML 輸入解析器(我的課文只寫 TeX) |
只留 woff-v2 字型 |
拿掉 woff-v1 與 otf(現代瀏覽器都支援 woff2) |
不拿 input/asciimath、a11y 的額外包 |
各省數百 KB |
實際傳輸量更小:tex-chtml.js 本身約 1.1 MB(gzip 後約 300 KB),字型是按需載入的——只有課文真的用到某個字集才會下載那個 woff2 檔(通常一課只碰 2–3 個檔,各 20–60 KB)。
npm pack 而不是 npm install:我不要 node_modules,也不要 package.json 進專案(Day 1 的無建置原則)。vendor/ 直接進 git——4 MB 對 git 完全不是問題,換來的是部署時不需要任何安裝步驟。
改設定指向本地字型:
/* js/mathjax-config.js */
window.MathJax = {
tex: { inlineMath: [["\\(", "\\)"]], displayMath: [["\\[", "\\]"]] },
options: { skipHtmlTags: ["script", "noscript", "style", "textarea"] },
chtml: { fontURL: "vendor/mathjax/output/chtml/fonts/woff-v2" }, // ← 關鍵
};
<!-- script src="js/mathjax-config.js" -->
<!-- script defer src="vendor/mathjax/tex-chtml.js" -->
忘了 fontURL 的症狀:公式排版正確、但字型從 jsdelivr 下載(Network 面板會看到 CDN 請求),CSP 一收緊就變成公式顯示成 fallback 字型(歪歪的襯線體)。這個雷很隱蔽,因為「看起來有渲染」。
mkdir -p vendor/fonts
# 從 Google Fonts 下載 Noto Sans TC 的 woff2(400/500/700)
# 只取需要的字重——每個字重約 1.5 MB(中文字型很大)
中文字型是真的大。三個字重 = 約 4.5 MB。兩個選擇:
| 做法 | 說明 |
|---|---|
| 全部自架 | 4.5 MB,但完全離線、零第三方 |
| 字型子集化 | 用 pyftsubset 只留課文用到的字,降到約 800 KB |
子集化很誘人,但風險是使用者輸入(搜尋框、未來的筆記功能)會出現子集裡沒有的字,變成豆腐方塊。折衷:
@font-face {
font-family: "Noto Sans TC";
src: url("../vendor/fonts/NotoSansTC-Regular.woff2") format("woff2");
font-weight: 400;
font-display: swap; /* 字型載入前先用系統字型,不留白 */
unicode-range: U+4E00-9FFF, U+3000-303F, U+FF00-FFEF, U+0020-007F;
}
我選不子集化、但用 font-display: swap + 只留兩個字重(400 正文、700 標題),約 3 MB。理由:中文網站的字型是一次性成本(快取後永久有效),而豆腐方塊是永久性的難看。
CSS 裡把 Google Fonts 的 <link> 拿掉,改成本地 @font-face。順手把 preconnect 也刪了——不再有外部網域要連。
<!-- 之前 -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net;
img-src 'self' data:; object-src 'none'; base-uri 'self'">
<!-- 現在 -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self';
img-src 'self' data:;
object-src 'none'; base-uri 'self'">
沒有任何外部網域了。 這是我今天最想達成的事——CSP 的攻擊面從「三個第三方網域可以載入內容」降到零。
style-src 還留著 'unsafe-inline',因為 View 大量使用 style="width:${pct}%" 這種行內樣式(進度條、類別色)。要拿掉就得改用 CSS 自訂屬性:
// 現在
`<i style="width:${pct}%"></i>`
// 可以改成(配合 CSS 的 width: var(--pct))
`<i style="--pct:${pct}%"></i>` // 仍然是行內 style,沒省到
真正的解法是 element.style.setProperty()(DOM API 不受 style-src 限制),但那要把所有 View 從「回傳 HTML 字串」改成「操作 DOM」,是整個架構的改動。我判斷不值得:unsafe-inline 在 style-src 的風險遠低於 script-src(無法執行程式碼,最多是視覺破壞)。這是有意識的取捨,寫進文件備查。
/* sw.js(必須放在網站根目錄,才能控制整站範圍) */
const VERSION = "v1";
const SHELL = `shell-${VERSION}`;
const RUNTIME = `runtime-${VERSION}`;
/* 殼:立即快取,離線也要能開站 */
const SHELL_FILES = [
"./", "./index.html", "./course.html", "./chapter.html",
"./css/style.css",
"./js/theme.js",
"./js/models/curriculum.js", "./js/models/progress.js",
"./js/views/nav-view.js", "./js/views/map-view.js", "./js/views/course-view.js",
"./js/views/lesson-view.js", "./js/views/gamify.js", "./js/views/interactive.js",
"./js/controllers/home.js", "./js/controllers/course.js", "./js/controllers/chapter.js",
"./js/mathjax-config.js",
"./vendor/mathjax/tex-chtml.js",
"./vendor/fonts/NotoSansTC-Regular.woff2",
"./vendor/fonts/NotoSansTC-Bold.woff2",
];
self.addEventListener("install", e => {
e.waitUntil(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)));
/* 不呼叫 skipWaiting()——理由見下面「踩到的雷」 */
});
self.addEventListener("activate", e => {
e.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener("fetch", e => {
const req = e.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== location.origin) return; // 只管同源
/* HTML:network-first(要拿到最新版),失敗才用快取 */
if (req.mode === "navigate" || req.destination === "document") {
e.respondWith(
fetch(req).then(res => {
caches.open(RUNTIME).then(c => c.put(req, res.clone()));
return res;
}).catch(() => caches.match(req).then(r => r || caches.match("./index.html")))
);
return;
}
/* 其他資產(js/css/字型/課文):cache-first(它們有版本化檔名) */
e.respondWith(
caches.match(req).then(hit => hit || fetch(req).then(res => {
if (res.ok) caches.open(RUNTIME).then(c => c.put(req, res.clone()));
return res;
}))
);
});
兩種策略分開用:
| 資源 | 策略 | 理由 |
|---|---|---|
| HTML | network-first | HTML 引用了帶版本的資產路徑,必須拿到最新的 |
| JS/CSS/字型/課文 | cache-first | 檔名帶版本(Day 24 會改成內容哈希),內容不變 |
課文檔(js/lessons/*.js,20 個檔約 1.5 MB)不放進 shell,而是訪問過就進 runtime 快取。第一次讀某門課要連網,之後那門課就離線可用。這比一次預載 1.5 MB 合理——大部分使用者只讀其中幾門。
註冊:
/* js/sw-register.js */
if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("./sw.js").then(reg => {
/* 有新版本時提示使用者,不強制重載 */
reg.addEventListener("updatefound", () => {
const nw = reg.installing;
nw && nw.addEventListener("statechange", () => {
if (nw.state === "installed" && navigator.serviceWorker.controller) {
showUpdateToast(() => { nw.postMessage("SKIP_WAITING"); });
}
});
});
}).catch(() => {});
});
}
location.protocol.startsWith("http") 的守衛很重要:service worker 在 file:// 下不可用(會 throw)。Day 1 的 file:// 需求要求這段必須安靜地跳過,不能報錯。
{
"name": "學徑 LearnPath",
"short_name": "學徑",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#4f46e5",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
purpose: "maskable" 那個 icon 是給 Android 自適應圖示用的——沒有它,桌面圖示會被裁成圓形時切掉邊緣內容。要求主要圖案在中央 80% 的安全區內。
theme_color 要跟深色模式協調。可以動態改:
/* Theme.apply() 裡順手更新 */
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = effective === "dark" ? "#0b1120" : "#4f46e5";
skipWaiting() 是陷阱。 教學都寫「加 self.skipWaiting() 讓新 SW 立刻生效」,但那會導致已開啟的頁面被換掉底下的 SW:舊頁面載入的 curriculum.js 是 v1,新 SW 提供的 lesson-view.js 是 v2,兩者混用 → 隨機的 runtime 錯誤,而且極難重現。
正解:預設不 skipWaiting,讓使用者決定何時更新。顯示一個「有新版本,點擊更新」的 toast,點了才 postMessage("SKIP_WAITING"):
self.addEventListener("message", e => {
if (e.data === "SKIP_WAITING") self.skipWaiting();
});
快取過期比沒快取更糟。 第一版我把 SHELL_FILES 用 cache-first 且沒有版本號,改了 CSS 之後所有回訪使用者永遠看到舊樣式——而且我在自己的瀏覽器上看到的是新版(因為我一直在 hard reload),完全沒察覺。修法:快取名字帶 VERSION,activate 時刪掉所有舊名快取。
驗證方式一定要用 DevTools > Application > Service Workers > Update on reload 關閉的狀態測,才是真實使用者的行為。
MathJax 字型 404 的靜默失敗。 fontURL 路徑寫錯時,MathJax 不報錯,只是用 fallback 字型排版——公式會顯示,但符號長得不對(∑ 變成細瘦的襯線版)。加進 smoke test:
# 課文頁不該有任何 404
logs = d.get_log("performance") # 或用 CDP 攔 Network.responseReceived
# 簡易做法:檢查 mjx-container 內的字型
font = d.execute_script(
"const e=document.querySelector('mjx-container mjx-mi');"
"return e ? getComputedStyle(e).fontFamily : '';")
if "MJXZERO" not in font and "MathJax" not in font:
fails.append(f"MathJax 字型未載入:{font}")
CSP 與 service worker。 sw.js 本身受 script-src 'self' 管,同源沒問題。但如果你用 importScripts() 載入第三方(例如 Workbox CDN),就會被擋——這也是我選擇手寫 SW 而不用 Workbox 的原因之一(另一個原因是 Workbox 需要建置步驟)。
node scripts/verify.js
python3 -m http.server 8901
python3 scripts/smoke-test.py # 既有測試仍要 0 failures
離線驗證(這是今天的驗收重點):
外部請求歸零:
DevTools > Network > 篩選 3rd-party
→ 應為空。fonts.googleapis.com、fonts.gstatic.com、cdn.jsdelivr.net 全部不該出現。
Lighthouse:Installable 應該打勾(manifest + SW + HTTPS)。本機 localhost 算安全來源,可以測。
file:// 回歸測試:直接雙擊 index.html,18 個節點還在、SW 註冊被安靜跳過、console 無錯誤。
| 之前 | 現在 | |
|---|---|---|
| 外部網域 | 3 個 | 0 |
script-src |
'self' https://cdn.jsdelivr.net |
'self' |
| repo 大小 | 約 3 MB | 約 10 MB(+MathJax 4.2、+字型 3) |
| 首次載入(課文頁) | 約 1.2 MB(跨 3 個網域) | 約 1.5 MB(單一網域,可被 SW 快取) |
| 回訪載入 | 靠 CDN 快取(不可控) | 0 網路請求 |
| 離線 | 完全不能用 | 讀過的內容全部可用 |
repo 從 3 MB 變 10 MB 是最明顯的代價。我接受,因為它換來的是部署零安裝步驟(Day 23 會很有感)與完全的離線能力。
今天的重點:
tex-chtml 取代 tex-mml-chtml、只留 woff2,30 MB → 4.2 MB。fontURL 是 MathJax 自架最容易漏的一步,而且會靜默降級。skipWaiting():混版本比晚更新危險得多。activate 時清舊的。明天做學習儀表板:進度匯出匯入(使用者資料主權)、streak 熱力圖、類別完成率雷達圖——全部手寫 SVG,不裝圖表庫。