iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
AI Engineering

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

Day 7 — MathJax 整合與兩個「靜靜壞掉」的 bug

  • 分享至 

  • xImage
  •  

今天要解的問題

這是一個統計學習網站,課文裡到處是這種東西:

$$\bar{x} \pm z_{\alpha/2}\frac{\sigma}{\sqrt{n}}$$

沒有數學排版,整個專案沒有意義。今天把 MathJax 接上去——然後花大半篇文章講兩個我親手製造的 bug,它們的共同特徵是:不報錯、不當掉、console 一片乾淨,公式就是靜靜地不渲染。

MathJax vs KaTeX

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"] }
};

三個重點:

  1. 設定必須在 MathJax 本體之前執行。 MathJax 3 在啟動時讀 window.MathJax,晚了就無效。
  2. 設定放外部檔案,不用行內 script 現在看起來多此一舉,但第 10 天上嚴格 CSP 時,行內 script 會被全面禁止。先付這個成本,之後不用回頭改。
  3. 不啟用 $...$ 作為行內分隔符。 中文課文裡談到金額、變數名時很容易誤觸發,只用 \(...\) 最安全。

換課之後要手動重新排版(Day 6 的 renderLesson 最後一段):

if (window.MathJax && MathJax.typesetPromise)
  MathJax.typesetPromise([body]);

只傳 [body] 而不是整頁——只排版變動的區塊,換課才不會越來越慢。前面的 feature detection 是必要的:CDN 掛掉時網站要照常運作,只是公式變原始碼,不能整頁掛掉。


Bug 1:兩種反斜線慣例並存

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 必須是單一反斜線的 \(\[ 這條寫進專案規範,改課文前先確認自己在改哪一種檔案。


Bug 2:數學式裡的裸 <(這個更陰險)

課文是 HTML,這行有什麼問題?

<p>當樣本數少於變數數,也就是 \(m<p\),模型無法識別。</p>

<p\) 被瀏覽器當成一個 <p 標籤的開頭。瀏覽器不會報錯——它會盡力解析,結果是 \(m 之後的內容被吃掉、公式的閉合分隔符消失,MathJax 找不到成對的分隔符,於是整條公式不渲染,也不報錯

畫面上你看到的是一段少了幾個字的句子。如果你沒把課文對照原稿逐字檢查,根本不會注意到。

為什麼結構驗證器抓不到

我的驗證器(Day 10)檢查的是:id 唯一、課文存在、每課恰一題測驗、答案索引有效。它用正則檢查字串,而 \(m<p\) 在字串層面完全合法。它甚至無法知道「這段 HTML 被瀏覽器解析成什麼」。

這是一整類 bug 的代表:結構驗證只能保證資料形狀對,不能保證瀏覽器渲染對。 這就是我第 10 天要寫第二層驗證(真的開瀏覽器)的直接原因。

解法

<p>當樣本數少於變數數,也就是 \(m&lt;p\),模型無法識別。</p>

規則:數學式裡的 < 一律寫 &lt;> 一律寫 &gt; MathJax 讀到的是瀏覽器解碼後的文字,所以 &lt; 會正確變成 <,公式照樣渲染。

我在全站掃出 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 的共同教訓

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 算出來的。 那段黑歷史值得完整寫一篇。


上一篇
Day 6 — 課文頁:`window.LESSONS`、課內導覽與領域驅動擴章
下一篇
Day 8 — 隨堂測驗元件 + 課文寫作規格
系列文
知識圖譜 : 技能樹式學習歷程9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言