iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

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

Day 16 — 徹底離線:自架 MathJax + PWA

  • 分享至 

  • xImage
  •  

今天要解的問題

這個網站有兩個外部依賴:

<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" -->

三個理由要拿掉它們:

  1. 離線可用:通勤時在地下鐵讀課文,這是我做這網站最真實的使用場景。
  2. CSP 可以更純script-src'self' https://cdn.jsdelivr.net 收成 'self'
  3. 隱私:Google Fonts 會讓使用者的 IP 送到第三方。

代價是體積與維護成本。今天把取捨算清楚。

自架 MathJax:只拿需要的部分

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/asciimatha11y 的額外包 各省數百 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 也刪了——不再有外部網域要連。

CSP 收緊

<!-- 之前 -->
<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-inlinestyle-src 的風險遠低於 script-src(無法執行程式碼,最多是視覺破壞)。這是有意識的取捨,寫進文件備查。

Service Worker

/* 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:// 需求要求這段必須安靜地跳過,不能報錯。

Manifest

{
  "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),完全沒察覺。修法:快取名字帶 VERSIONactivate 時刪掉所有舊名快取。

驗證方式一定要用 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

離線驗證(這是今天的驗收重點):

  1. 開站、瀏覽幾課(讓課文進 runtime 快取)。
  2. DevTools > Network > 勾 Offline
  3. 重整 → 首頁仍出現、地圖節點完整。
  4. 進讀過的課 → 課文、公式、測驗全部正常。
  5. 進沒讀過的課 → 應優雅降級(顯示「本課內容準備中」而不是白畫面)。

外部請求歸零:

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 會很有感)與完全的離線能力。

小結與明天預告

今天的重點:

  1. 自架第三方資源要挑最小可用集tex-chtml 取代 tex-mml-chtml、只留 woff2,30 MB → 4.2 MB。
  2. fontURL 是 MathJax 自架最容易漏的一步,而且會靜默降級
  3. SW 的 HTML 用 network-first、資產用 cache-first,課文按需進快取。
  4. 不要預設 skipWaiting():混版本比晚更新危險得多。
  5. 快取名字一定要帶版本,activate 時清舊的。

明天做學習儀表板:進度匯出匯入(使用者資料主權)、streak 熱力圖、類別完成率雷達圖——全部手寫 SVG,不裝圖表庫。


上一篇
Day 15 — 名詞中英對照頁:讓內容自己長出頁面
下一篇
Day 17 — 學習儀表板:把 localStorage 變成圖
系列文
知識圖譜 : 技能樹式學習歷程18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言