架站筆記

Hexo 裝 Mermaid 的三個坑:一個「五分鐘任務」的除錯全紀錄

2026-08-09 #Hexo#教學#Mermaid#除錯#JavaScript

我的技術文章裡有大量流程圖,原本全是手工排的 ASCII art——對齊很累、手機上容易跑版、改一個節點要重畫整張。想換成 Mermaid,看了一眼:裝個外掛、把 <pre class="mermaid"> 區塊寫好,收工。

結果花了將近一小時,踩了三個坑,其中最後一個的根因完全違反直覺。這篇把過程完整記下來——包含每個「以為修好了但其實沒有」的中間狀態,因為那些歪路本身就是除錯經驗的一部分。

坑一:外掛只做了一半

第一步很順利:

1
2
3
4
5
6
npm install hexo-filter-mermaid-diagrams</pre>

```yaml
# _config.yml
mermaid:
enable: true

寫一個測試圖表,hexo generate,檢查產出——<pre class="mermaid"> 確實出現了。但畫面上還是純文字

翻外掛原始碼,總共就這幾行:

1
2
3
4
5
const reg = /(\s*)(`{3}) *(mermaid) *\n?([\s\S]+?)\s*(\2)(\n+|$)/g;

data.content = data.content.replace(reg, function (raw, start, q, lang, content, ...) {
return `${start}<pre class="mermaid">${content}</pre>${end}`;
});

它只做語法轉換,完全不負責載入 mermaid.js。 這不是 bug——多數 Hexo 主題(NexT、Butterfly)本來就內建 mermaid 的執行期載入,外掛只補上 Markdown 層的轉換。但我的主題 FlatPaper 沒有,於是產出了一堆等著被渲染、卻沒有人來渲染的 <pre>

教訓:裝外掛前先看它的原始碼有多長。三十行的外掛不可能包辦所有事情,README 沒說的部分往往預設「主題會處理」。

解法:自己寫執行期載入

我沒有直接把 CDN 的 <script> 塞進去,而是寫了一支載入器 source/js/mermaid-init.js,理由是 mermaid 壓縮後約 1 MB——沒有圖表的頁面不該付這個成本

1
2
3
4
5
var nodes = document.querySelectorAll('pre.mermaid');
if (!nodes.length) return; // 沒圖就直接結束,不載入任何東西

import('https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.esm.min.mjs')
.then(function (mod) { /* 渲染 */ });

用動態 import() 而非 <script> 標籤,就能做到「頁面有圖才下載」。掛載方式用主題的 inject 設定,不必改主題模板:

1
2
3
4
# _config.<theme>.yml
inject:
bottom:
- <script type="module" src="/js/mermaid-init.js"></script>

坑二:<br> 完全沒有作用

圖出來了,但每個多行標籤都被壓成一行——而且更糟,文字還黏在一起

1
2
3
4
5
預期:  混合檢索
dense 20 + sparse 20
RRF 融合 → 撈 30

實際: 混合檢索dense 20 + sparse 20RRF 融合 → 撈 30

注意那個「檢索dense」——<br> 不是被當成文字印出來,而是憑空消失了。這個細節後來成為破案關鍵,但當下我沒意識到。

第一個假設是 mermaid 的安全等級。我原本設的是:

1
mermaid.initialize({ securityLevel: 'strict' });

查文件,strict 的定義是「文字中的 HTML 標籤會被編碼」——聽起來就是元兇。改成 antiscript(允許 HTML 但移除 <script>):

1
2
securityLevel: 'antiscript',
flowchart: { htmlLabels: true }

重新整理——沒有改善

坑三(假的):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
2
3
4
const m = (await import('https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.esm.min.mjs')).default;
m.initialize({ startOnLoad: false, securityLevel: 'antiscript', flowchart: { htmlLabels: true } });
const r = await m.render('t', 'flowchart LR\n A[第一行<br/>第二行]');
console.log(/<br\s*\/?>/i.test(r.svg)); // → true

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
2
3
4
5
6
7
8
9
10
function decode(html) {
return html
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') // mermaid 的 --&gt; 要還原成 -->
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&amp;/g, '&'); // 必須放最後,否則會二次解碼
}

var sources = nodes.map(function (el) { return decode(el.innerHTML); });

&amp; 一定要放最後——如果先解它,原文的 &amp;lt; 會先變成 &lt;、再被下一條規則解成 <,形成非預期的二次解碼。這是所有手寫實體解碼都會遇到的順序陷阱。

換上去,所有多行標籤一次全部正確

完整的載入器

把三個坑的修正合起來:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
(function () {
var CDN = 'https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.esm.min.mjs';
var nodes = Array.prototype.slice.call(document.querySelectorAll('pre.mermaid'));
if (!nodes.length) return; // 坑一:沒圖不載入

function decode(html) { /* 如上 */ }
var sources = nodes.map(function (el) {
return decode(el.innerHTML); // 坑三:不能用 textContent
});

function currentTheme() {
return document.documentElement.classList.contains('dark-mode') ? 'dark' : 'default';
}

import(CDN).then(function (mod) {
var mermaid = mod.default;
var pass = 0;

function render() {
mermaid.initialize({
startOnLoad: false,
theme: currentTheme(),
securityLevel: 'antiscript', // 坑二:strict 會跳脫掉 <br>
flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' }
});
pass += 1; // 重繪要給新 id,否則 mermaid 會拒絕
nodes.forEach(function (el, i) {
mermaid.render('mmd-' + pass + '-' + i, sources[i])
.then(function (res) { el.innerHTML = res.svg; })
.catch(function () {});
});
}

render();

// 網站切換深淺色時重繪(用存好的 sources,不是被 SVG 覆蓋後的 innerHTML)
var pending = null;
new MutationObserver(function () {
clearTimeout(pending);
pending = setTimeout(render, 60);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}).catch(function () {
// 載入失敗就讓純文字定義留在畫面上,總比空白好
});
})();

幾個附帶的設計:

  • useMaxWidth: true:SVG 會自動縮放到容器寬度,手機上不會爆版。
  • 每次重繪用新的 element idmmd-1-0mmd-2-0…):mermaid 會拒絕重複的 id,深淺色切換時如果沿用舊 id 會直接失敗。
  • catch 什麼都不做:CDN 掛掉時保留純文字的圖表定義。讀者看到的是一段看得懂的原始碼,而不是空白區塊。

順帶一提:subgraph 的 direction 會被忽略

改圖的過程還遇到一個 mermaid 本身的行為:我原本想把「建索引」和「查詢」兩條管線放進同一張圖的兩個 subgraph,各自水平排列:

1
2
3
4
5
6
7
8
9
10
flowchart TB
subgraph ING["建索引"]
direction LR
...
end
subgraph QRY["查詢"]
direction LR
...
end
V -. 提供檢索目標 .-> HS ← 跨 subgraph 的連線

結果兩個 subgraph 內部都變成垂直排列,整張圖高達 800 px。原因是當 subgraph 有跨越邊界的連線時,mermaid 會忽略內部的 direction 宣告

解法不是跟它奮戰,而是拆成兩張獨立的圖,中間用一句文字說明銜接。結果反而更好讀——本來就是「離線」與「即時」兩件事,硬塞進同一張圖是我一開始想太多。

小結:這一小時買到什麼

現象 根因
<pre class="mermaid"> 但畫面是純文字 外掛只轉語法,不載入 runtime
<br> 沒有換行 securityLevel: strict 會跳脫所有標籤
三(假) 改了設定沒生效 hexo server 不重讀主題設定;ES module 快取
真正的坑 <br> 連同換行意圖一起消失、文字黏在一起 textContent 會忽略元素節點,<br> 被整個丟掉

回頭看,最花時間的不是最後那個難題,而是中間那些「假的修好」——每次改完設定就重整、沒有真正驗證假設,於是在錯誤的方向上疊了兩層修正。轉折點是那個 console 最小測試:與其猜「是不是 mermaid 不支援」,不如花三十秒直接問 mermaid 本人。確認上游正常之後,問題空間瞬間縮小到我自己那二十行程式碼裡。

「先隔離出最小可重現案例,再往回推」——這句話每個人都聽過,但實際除錯時總是想先試那個看起來很像的設定。這次又被教了一遍。

如果你也在用 Hexo,這個部落格的建站流程留言系統統計功能也都記錄過了。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(Hexo 裝 Mermaid 的三個坑:一個「五分鐘任務」的除錯全紀錄 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:murmur20260202@gmail.com

留言
分享

留言