我的技術文章裡有大量流程圖,原本全是手工排的 ASCII art——對齊很累、手機上容易跑版、改一個節點要重畫整張。想換成 Mermaid,看了一眼:裝個外掛、把 <pre class="mermaid"> 區塊寫好,收工。
結果花了將近一小時,踩了三個坑,其中最後一個的根因完全違反直覺。這篇把過程完整記下來——包含每個「以為修好了但其實沒有」的中間狀態,因為那些歪路本身就是除錯經驗的一部分。
坑一:外掛只做了一半
第一步很順利:
1 | npm install hexo-filter-mermaid-diagrams</pre> |
寫一個測試圖表,hexo generate,檢查產出——<pre class="mermaid"> 確實出現了。但畫面上還是純文字。
翻外掛原始碼,總共就這幾行:
1 | const reg = /(\s*)(`{3}) *(mermaid) *\n?([\s\S]+?)\s*(\2)(\n+|$)/g; |
它只做語法轉換,完全不負責載入 mermaid.js。 這不是 bug——多數 Hexo 主題(NexT、Butterfly)本來就內建 mermaid 的執行期載入,外掛只補上 Markdown 層的轉換。但我的主題 FlatPaper 沒有,於是產出了一堆等著被渲染、卻沒有人來渲染的 <pre>。
教訓:裝外掛前先看它的原始碼有多長。三十行的外掛不可能包辦所有事情,README 沒說的部分往往預設「主題會處理」。
解法:自己寫執行期載入
我沒有直接把 CDN 的 <script> 塞進去,而是寫了一支載入器 source/js/mermaid-init.js,理由是 mermaid 壓縮後約 1 MB——沒有圖表的頁面不該付這個成本:
1 | var nodes = document.querySelectorAll('pre.mermaid'); |
用動態 import() 而非 <script> 標籤,就能做到「頁面有圖才下載」。掛載方式用主題的 inject 設定,不必改主題模板:
1 | # _config.<theme>.yml |
坑二:<br> 完全沒有作用
圖出來了,但每個多行標籤都被壓成一行——而且更糟,文字還黏在一起:
1 | 預期: 混合檢索 |
注意那個「檢索dense」——<br> 不是被當成文字印出來,而是憑空消失了。這個細節後來成為破案關鍵,但當下我沒意識到。
第一個假設是 mermaid 的安全等級。我原本設的是:
1 | mermaid.initialize({ securityLevel: 'strict' }); |
查文件,strict 的定義是「文字中的 HTML 標籤會被編碼」——聽起來就是元兇。改成 antiscript(允許 HTML 但移除 <script>):
1 | securityLevel: 'antiscript', |
重新整理——沒有改善。
坑三(假的):ES module 的快取
排除法:既然設定沒生效,會不會是瀏覽器拿到舊的 JS?<script type="module"> 的快取相當積極。
加上版本號驗證:
1 | - <script type="module" src="/js/mermaid-init.js?v=2"></script> |
重新整理,還是一樣。用開發者工具一看,載入的居然還是沒有版本號的舊網址——原來是 hexo server 在啟動時就把主題設定讀進記憶體了,改 _config.<theme>.yml 它不會重讀。重啟伺服器後,?v=2 終於生效。
結果 <br> 還是沒有換行。
這一段雖然沒解決主要問題,但版本號本身值得留著:正式站上如果之後改了這支 JS,回訪讀者會拿到瀏覽器快取的舊版。這是意外的收穫。
真正的坑:textContent 把 <br> 吃掉了
繞了一圈,我決定不要再猜,直接驗證「mermaid 本身到底支不支援」。在瀏覽器 console 裡跑一個最小測試:
1 | const m = (await import('https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.esm.min.mjs')).default; |
mermaid 沒問題。同樣的設定、同樣的語法,手動呼叫 render() 就會換行。所以問題在我把定義餵給 mermaid 之前。
回頭看我的載入器:
1 | sources[i] = el.textContent; // ← 元兇 |
我為了支援深淺色切換時重新渲染,會先把原始定義存起來(因為 mermaid 渲染後會用 SVG 覆蓋掉 innerHTML)。而我用的是 textContent。
問題在這裡:<pre class="mermaid"> 的內容是外掛直接輸出的原始 HTML,所以裡面那個 <br> 會被瀏覽器解析成真正的 DOM 元素,而不是文字。而 textContent 的語意是「回傳所有文字節點的內容」——元素節點直接被忽略。於是 <br> 連同它代表的換行意圖一起蒸發,前後兩段文字被無縫接在一起。
這完美解釋了那個「檢索dense」的黏字現象:不是被跳脫、不是被過濾,是被當成不存在。
修法
改用 innerHTML(會保留 <br> 的字面形式),再自行把 HTML 實體解回來:
1 | function decode(html) { |
& 一定要放最後——如果先解它,原文的 &lt; 會先變成 <、再被下一條規則解成 <,形成非預期的二次解碼。這是所有手寫實體解碼都會遇到的順序陷阱。
換上去,所有多行標籤一次全部正確。
完整的載入器
把三個坑的修正合起來:
1 | (function () { |
幾個附帶的設計:
useMaxWidth: true:SVG 會自動縮放到容器寬度,手機上不會爆版。- 每次重繪用新的 element id(
mmd-1-0、mmd-2-0…):mermaid 會拒絕重複的 id,深淺色切換時如果沿用舊 id 會直接失敗。 catch什麼都不做:CDN 掛掉時保留純文字的圖表定義。讀者看到的是一段看得懂的原始碼,而不是空白區塊。
順帶一提:subgraph 的 direction 會被忽略
改圖的過程還遇到一個 mermaid 本身的行為:我原本想把「建索引」和「查詢」兩條管線放進同一張圖的兩個 subgraph,各自水平排列:
1 | flowchart TB |
結果兩個 subgraph 內部都變成垂直排列,整張圖高達 800 px。原因是當 subgraph 有跨越邊界的連線時,mermaid 會忽略內部的 direction 宣告。
解法不是跟它奮戰,而是拆成兩張獨立的圖,中間用一句文字說明銜接。結果反而更好讀——本來就是「離線」與「即時」兩件事,硬塞進同一張圖是我一開始想太多。
小結:這一小時買到什麼
| 坑 | 現象 | 根因 |
|---|---|---|
| 一 | 有 <pre class="mermaid"> 但畫面是純文字 |
外掛只轉語法,不載入 runtime |
| 二 | <br> 沒有換行 |
securityLevel: strict 會跳脫所有標籤 |
| 三(假) | 改了設定沒生效 | hexo server 不重讀主題設定;ES module 快取 |
| 真正的坑 | <br> 連同換行意圖一起消失、文字黏在一起 |
textContent 會忽略元素節點,<br> 被整個丟掉 |
回頭看,最花時間的不是最後那個難題,而是中間那些「假的修好」——每次改完設定就重整、沒有真正驗證假設,於是在錯誤的方向上疊了兩層修正。轉折點是那個 console 最小測試:與其猜「是不是 mermaid 不支援」,不如花三十秒直接問 mermaid 本人。確認上游正常之後,問題空間瞬間縮小到我自己那二十行程式碼裡。
「先隔離出最小可重現案例,再往回推」——這句話每個人都聽過,但實際除錯時總是想先試那個看起來很像的設定。這次又被教了一遍。
留言