這是一個統計學習網站,課文裡到處是這種東西:
$$\bar{x} \pm z_{\alpha/2}\frac{\sigma}{\sqrt{n}}$$
沒有數學排版,整個專案沒有意義。今天把 MathJax 接上去——然後花大半篇文章講兩個我親手製造的 bug,它們的共同特徵是:不報錯、不當掉、console 一片乾淨,公式就是靜靜地不渲染。
| MathJax 3 | KaTeX | |
|---|---|---|
| 速度 | 較慢 | 快很多 |
| 分隔符 | 可設定,原生支援 \(...\) |
需要 auto-render 擴充 |
| 中文混排 | 容忍度高 | 需要額外調整 |
| 載入方式 | CDN 單檔 | JS + CSS + 字型檔 |
我選 MathJax 3。理由:這網站是中文長文夾行內公式,\(P(A \mid B)\) 這種東西會出現在句子中間。KaTeX 快是真的,但我的瓶頸不是排版速度,而是「中文段落裡的行內公式基線要對齊」這種細節,MathJax 處理得比較省心。
(如果你的網站是純英文、公式量大、在意首屏速度,KaTeX 是更好的選擇。這不是通用結論,是這個專案的取捨。)
<!-- chapter.html:只有課文頁需要 MathJax,另兩頁不載入 -->
<!-- script src="js/mathjax-config.js" -->
<!-- script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" -->
/* js/mathjax-config.js */
window.MathJax = {
tex: {
inlineMath: [["\\(", "\\)"]],
displayMath: [["\\[", "\\]"]]
},
options: { skipHtmlTags: ["script", "noscript", "style", "textarea"] }
};
三個重點:
window.MathJax,晚了就無效。script。 現在看起來多此一舉,但第 10 天上嚴格 CSP 時,行內 script 會被全面禁止。先付這個成本,之後不用回頭改。$...$ 作為行內分隔符。 中文課文裡談到金額、變數名時很容易誤觸發,只用 \(...\) 最安全。換課之後要手動重新排版(Day 6 的 renderLesson 最後一段):
if (window.MathJax && MathJax.typesetPromise)
MathJax.typesetPromise([body]);
只傳 [body] 而不是整頁——只排版變動的區塊,換課才不會越來越慢。前面的 feature detection 是必要的:CDN 掛掉時網站要照常運作,只是公式變原始碼,不能整頁掛掉。
LaTeX 的 \ 在 JS 字串裡是跳脫字元。所以 template literal 裡要雙寫:
/* ch01-04.js — template literal,反斜線雙寫 */
L["ch08-1"] = `
<div class="formula">\\[ \\bar{x} \\pm z_{\\alpha/2}\\frac{\\sigma}{\\sqrt{n}} \\]</div>
`;
\\[ 在 runtime 是 \[,MathJax 才看得懂。到這裡都很正常。
問題是 Day 6 提到的第二種產生方式——用 JSON.stringify 把純 HTML 包成 JS 字串。那種檔案長這樣:
/* ml.js — JSON.stringify 產生的雙引號字串 */
L["ml04-1"] = "<div class=\"formula\">\\[ \\hat{y} = \\beta_0 + \\beta_1 x \\]</div>";
檔案文字看起來一樣是 \\[,但這是 JSON.stringify 自己算好的正確結果,runtime 也是 \[。兩種檔案表面上長得一樣、來源完全不同。
災難發生在我「順手修一下公式」的時候。我在一個 JSON.stringify 產生的檔案裡,照著另一種檔案的習慣,把 \\frac 又寫成 \\\\frac。結果:
node --check 通過(語法合法)。\\frac{a}{b} 印在畫面上,沒有錯誤、沒有警告。只有真的用眼睛看那一課才會發現。而全站有兩百多課。
node -e '
const fs=require("fs"), vm=require("vm");
const ctx={window:{}}; vm.createContext(ctx);
const files=["js/models/curriculum.js",
...fs.readdirSync("js/lessons").map(f=>"js/lessons/"+f)];
for (const f of files) vm.runInContext(fs.readFileSync(f,"utf8"), ctx, {filename:f});
const L=ctx.window.LESSONS;
const bad=Object.entries(L).filter(([k,h])=>/\\\\\(|\\\\\[/.test(h)).map(([k])=>k);
console.log("runtime 雙反斜線(壞):", bad.length, bad.slice(0,5));
'
關鍵在檢查 runtime 值,不檢查檔案文字。用 node:vm 把課文檔當資料載入(Day 3 那招),拿到的 L[key] 就是瀏覽器實際會看到的字串。裡面出現 \\( 就是壞的,不管檔案裡是怎麼寫的。
正確答案永遠是:runtime 必須是單一反斜線的 \(、\[。 這條寫進專案規範,改課文前先確認自己在改哪一種檔案。
<(這個更陰險)課文是 HTML,這行有什麼問題?
<p>當樣本數少於變數數,也就是 \(m<p\),模型無法識別。</p>
<p\) 被瀏覽器當成一個 <p 標籤的開頭。瀏覽器不會報錯——它會盡力解析,結果是 \(m 之後的內容被吃掉、公式的閉合分隔符消失,MathJax 找不到成對的分隔符,於是整條公式不渲染,也不報錯。
畫面上你看到的是一段少了幾個字的句子。如果你沒把課文對照原稿逐字檢查,根本不會注意到。
我的驗證器(Day 10)檢查的是:id 唯一、課文存在、每課恰一題測驗、答案索引有效。它用正則檢查字串,而 \(m<p\) 在字串層面完全合法。它甚至無法知道「這段 HTML 被瀏覽器解析成什麼」。
這是一整類 bug 的代表:結構驗證只能保證資料形狀對,不能保證瀏覽器渲染對。 這就是我第 10 天要寫第二層驗證(真的開瀏覽器)的直接原因。
<p>當樣本數少於變數數,也就是 \(m<p\),模型無法識別。</p>
規則:數學式裡的 < 一律寫 <,> 一律寫 >。 MathJax 讀到的是瀏覽器解碼後的文字,所以 < 會正確變成 <,公式照樣渲染。
我在全站掃出 5 處這種寫法(分布在 3 課),修完之後其中一課的 mjx-container 數量從 18 變成 20——有兩條公式原本靜靜地消失了兩個月,我完全不知道。
掃描法(在載入 LESSONS 之後):
// 檢查 \(...\) 與 \[...\] 區間內有沒有 "<" 緊接字母
const re = /\\[([]([\s\S]*?)\\[)\]]/g;
for (const [key, html] of Object.entries(L)) {
let m;
while ((m = re.exec(html))) {
if (/<[a-zA-Z/]/.test(m[1])) console.log("疑似裸 <:", key, m[1].slice(0, 60));
}
}
| Bug 1(雙反斜線) | Bug 2(裸 <) |
|
|---|---|---|
| JS 語法檢查 | ✅ 通過 | ✅ 通過 |
| 結構驗證器 | ✅ 通過 | ✅ 通過 |
| console | 乾淨 | 乾淨 |
| 使用者看到 | 裸的 LaTeX 原始碼 | 少了幾個字的句子 |
| 唯一抓法 | 檢查 runtime 字串 | 真的開瀏覽器數 mjx-container |
這給了我一條原則,後來變成專案的紀律:
會靜默失敗的東西,一定要有自動檢查。 因為靜默失敗不會有人回報 bug——它只會慢慢累積,直到你某天隨機點進一課才發現。
# 1. runtime 反斜線檢查(上面那段)
node /tmp/check-backslash.js # 輸出應為 0
# 2. 瀏覽器實測
python3 -m http.server 8901
// DevTools,開一課數學密集的
document.querySelectorAll("mjx-container").length // > 0
document.getElementById("lesson-body").textContent.includes("\\(") // false
document.getElementById("lesson-body").textContent.includes("\\frac") // false
第二段的三個判斷就是第 10 天自動化 smoke test 的核心邏輯:有渲染結果,且課文裡不能殘留可見的 LaTeX 原始碼。
(小提醒:終端裡跑含 ! 的 node one-liner 會被 bash 的 history expansion 吃掉。一律寫成 /tmp/*.js 再執行,別在命令列硬幹。)
今天接上了 MathJax,也交出了本系列最貴的兩個教訓——都不是「不會寫」,而是「壞了看不出來」。
明天做隨堂測驗元件,順便處理另一個「看起來沒問題其實是假的」的東西:我曾經用樣板產生器量產了兩百多課,測驗答案是 index % 3 算出來的。 那段黑歷史值得完整寫一篇。