我的部落格,還有我參與的另一個開源專案,最近都把 Mermaid 換成了 Obsidian 團隊開源的 pele。先看同一段語法畫出來的樣子,左邊是 Mermaid 官方套件,右邊是 pele:
不只是好看。同樣 136 張圖,在瀏覽器裡跟 Mermaid 官方套件比:
完整的數據和量法放在最後一節。
我的部落格很常用 Mermaid 畫圖,流程、架構、時序都靠它。現在站上 44 篇文章、五個語系加起來有 136 張圖。為了讓這些圖好看又不拖慢頁面,渲染器我前後換了三個:最早是 Mermaid 官方套件,八月換成 beautiful-mermaid,上週全站翻新時又換成 Obsidian 團隊開源的 pele。
換成 pele 之後,我拿站上這 136 張圖實測:全部渲染成功,總共只花 75 毫秒左右,比 beautiful-mermaid 快大約 9 倍,產出的 SVG 也小了四成。更重要的是它完全不碰 DOM、沒有任何相依套件,配色全部交給 CSS 變數,剛好可以做到「伺服器端畫好 SVG,瀏覽器一行渲染程式都不用載」。
這篇照順序寫:pele 是什麼、我為什麼一路換到它、實測數據和畫出來的差異、我怎麼把它接進 TanStack Start(伺服器端出圖、只用 CSS 換主題、下載圖片時把顏色寫回去),最後是另一個專案在瀏覽器端換掉 Mermaid 官方套件的前後比較。程式碼都附上,用 Mermaid 的部落格應該可以直接參考。
pele 是什麼#
pele 是 Obsidian 團隊開源的 Mermaid 渲染器,MIT 授權,原始碼在 GitHub 的 obsidianmd/pele,npm 上的發布者是 kepano、lishid 這幾位 Obsidian 的人,透過 GitHub Actions 發布。它只做一件事:把 Mermaid 語法變成 SVG 字串。
它的特點我整理成幾條:
- 同步、沒有相依套件:
render(text)直接回傳{ svg },不需要瀏覽器,也不需要 jsdom。 - 支援 Mermaid 12.1 內建的所有圖表類型:每種圖的解析器都跑 Mermaid 自己的測試規格,也拿 Mermaid 的解析器做模糊測試。
- 配色只靠 CSS 變數:輸出的 SVG 用
var(--pele-fg)、var(--pele-surface)這類變數上色,換主題不用重畫。 - 安全:輸出的 SVG 沒有 script、沒有事件處理器、沒有 HTML,也不會執行圖裡的任何程式碼。
- 可以只載需要的圖表類型:
pele/core加上單一圖表模組,或用pele/lazy按需載入。
版面和視覺風格是 pele 自己的,所以同一段語法畫出來會跟 GitHub、Mermaid Live Editor 上看到的不一樣,開頭那張對照圖就看得出來。
WARNING
npm 上的 pele 1.0.0 是 2020 年另一個專案(LavaMoat 的實驗),跟這個 pele 無關。Obsidian 的 pele 從 2026 年 10 月 6 日才開始發 1.x,三週內出到 1.12.0,幾乎天天有新版。我的站目前釘在 1.3.0。
為什麼一路換到 pele#
Mermaid 官方套件#
最早用的是 Mermaid 官方套件加 ELK 排版,在瀏覽器端渲染。它能畫,但有幾個一直困擾我的地方:
- 太重:渲染器加上 ELK,lazy chunk 大約 1.5 MB,只要文章裡有一張圖,讀者就得載這一包。
- 圖跟背景同色:預設的節點填色是半透明紫色,疊在站上的深色星空上幾乎看不見,我自己看文章都要去工具列切主題。
- 要寫一堆護欄:ELK 排版時會序列化物件,撞到 React fiber 造成 circular JSON,我為此寫了 35 行的
JSON.stringify護欄。
beautiful-mermaid#
八月我換成 beautiful-mermaid。它同步、不碰 DOM,輸出的 SVG 也用 CSS 變數上色,所以我可以把渲染整個搬到伺服器端:圖在伺服器畫好,瀏覽器只拿到一段 SVG 字串,client 端的 JS 從 20.18 MB 降到 18.80 MB。
它的問題是排版。內部一樣是 ELK,稍微複雜一點的圖就會連線交錯,邊上的標籤被裝進一個個小框裡,看起來很擠。再加上它的體積不小,壓縮後還有 1.5 MB。
pele#
十月翻新網站時,我要把全站的紫色收掉,圖表區塊也得重做,就順便換成了 pele。它保留了 beautiful-mermaid 那些讓我能把渲染搬到伺服器的特性:同步、不碰 DOM、配色走 CSS 變數,但體積小很多,畫出來也乾淨很多。
實測:跟 beautiful-mermaid 比#
我把站上 44 篇文章、五個語系裡的 136 張 Mermaid 圖全部抽出來(flowchart 107 張、graph 19 張、sequenceDiagram 10 張),在同一台機器上用 Node 分別跑 pele 1.3.0 和 beautiful-mermaid 1.1.3,各跑三次。
| pele 1.3.0 | beautiful-mermaid 1.1.3 | |
|---|---|---|
| 成功 | 136 / 136 | 136 / 136 |
| 全部跑完 | 72–91 ms | 657–693 ms |
| 單張中位數 | 0.4 ms | 3.7 ms |
| 單張 p95 | 1.4 ms | 8.2–11.3 ms |
| 最慢的一張 | 7–10 ms | 94–102 ms |
| SVG 平均大小 | 4.8 KB | 7.7 KB |
體積的部分,我用 esbuild 各打一包壓縮過的 browser bundle:
| 打包內容 | 壓縮後 | gzip |
|---|---|---|
| pele 全部圖表類型 | 626 KB | 231 KB |
| pele 只註冊 flowchart | 112 KB | 43 KB |
| beautiful-mermaid | 1,522 KB | 457 KB |
我的站是在伺服器端渲染,所以這些體積不會進到讀者的瀏覽器。如果你打算在 client 端用,pele 只註冊需要的圖表類型會差很多:只用 flowchart 的話,gzip 之後 43 KB。
畫出來差在哪#
下面是 CrowdSec 那篇裡的同一張圖。舊站凍結在換渲染器之前,所以還是 beautiful-mermaid 畫的:
換成 pele 之後:
一樣是由左到右的流程圖,pele 把它排成一條直線,往返的兩條線平行排好,標籤直接放在線上,沒有任何交錯。這張圖裡原本有我用 classDef 標成綠色和黃色的節點,新版裡看不到那兩個顏色,這是我在網站這邊另外做的處理,後面會講。
怎麼接進網站#
我的站用 TanStack Start。整體做法是:伺服器端把文章裡的 Mermaid 區塊都畫成 SVG,隨著文章資料一起送到瀏覽器,瀏覽器只負責把 SVG 放進頁面、處理縮放和工具列。
伺服器端出圖#
渲染只有一行,配色完全不進渲染器:
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';
export function renderMermaidSvg(body: string): string {
return render(body).svg;
}再包成一個 server function,把文章裡每張圖畫好,做成「圖的內容 hash → SVG」的對照表:
// src/lib/mdx/mermaidRender.ts
export const renderMermaidSvgs = createServerFn({ method: 'POST' })
.validator((content: string) => content)
.handler(async ({ data: content }) => {
const fences = extractMermaidFences(content);
if (fences.length === 0) return {};
// 一定要在 handler 裡動態 import,模組頂層不能有靜態 import
const { renderMermaidSvg } = await import('./mermaidSvg');
const out: Record<string, string> = {};
for (const body of fences) {
const key = mermaidKey(body);
if (out[key]) continue;
try {
out[key] = renderMermaidSvg(body);
} catch {
// 單張圖語法壞掉不該讓整篇文章失敗
}
}
return out;
});這張對照表在載入文章資料時取得,跟著 query 一起送到瀏覽器,圖表元件直接查表。因為 SVG 已經在伺服器端畫好,它也會出現在 SSR 的 HTML 裡,讀者一打開頁面就看得到圖,不用等 JS。
有一個坑一定要避開:渲染器只能動態 import。我第一次嘗試時,SSR 那邊靜態 import、瀏覽器那邊動態 import,結果同一個模組被兩種方式引用,rollup 沒辦法把它切開,直接併進主 chunk,文章頁的路由 chunk 從 127 KB 暴增到 1,613 KB。全部改成 server function 裡動態 import 之後,client 端完全找不到渲染器的程式碼。
換主題只換 CSS 變數#
pele 輸出的 SVG 用 --pele-* 變數上色,值由外層的 class 提供。我的站只留兩個主題:夜(站上的配色)和紙(淺色,方便下載成圖片貼到別處):
.mm-theme-ink {
--pele-bg: transparent;
--pele-fg: var(--paper);
--pele-muted: var(--paper-3);
--pele-line: var(--paper-2);
--pele-surface: rgba(var(--paper-rgb), 0.05);
--pele-surface-alt: rgba(var(--paper-rgb), 0.1);
--pele-border: var(--paper-3);
--pele-accent: var(--amber);
--pele-font: var(--cjk-font);
--pele-font-mono: var(--mono-font);
--pele-radius: 2px;
}讀者在工具列切到「紙」,就只是把 mm-theme-ink 換成 mm-theme-light,圖不用重畫,瀏覽器端也就不需要渲染器。
下載圖片要把顏色寫回去#
這個做法有一個副作用:SVG 本身沒有顏色,顏色是外層 CSS 給的。讀者按「下載 SVG」拿到的檔案離開頁面之後,變數沒有值,就會退回 pele 的預設白底黑字。所以下載前,我把當下算出來的變數值寫進 SVG 本身:
const PELE_VARS = ['--pele-bg', '--pele-fg', '--pele-muted', '--pele-line', '--pele-surface',
'--pele-surface-alt', '--pele-border', '--pele-accent', '--pele-font', '--pele-font-mono', '--pele-radius'];
function standaloneSvg(svg: SVGSVGElement): SVGSVGElement {
const clone = svg.cloneNode(true) as SVGSVGElement;
const cs = getComputedStyle(svg);
for (const name of PELE_VARS) {
const value = cs.getPropertyValue(name).trim();
if (value) clone.style.setProperty(name, value);
}
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
return clone;
}下載 PNG 也是先拿這份 SVG 畫到 canvas 上,底色跟著主題走:夜是站上的黑,紙是白。
我在圖裡標的顏色#
舊文章裡,我常用 classDef 幫節點標顏色,淺綠代表成立、淺紅代表排除、淡黃代表重點,一共 11 篇在用。翻新之後整站只留黑、白和一個琥珀色,這些顏色放進來會很突兀,但它們是內容的一部分,不能直接拿掉。
所以伺服器端出圖時,我多做了一步:讀出每個節點我當初標的顏色,依色相判斷它在圖裡代表什麼,歸成四種角色,再把原本的顏色拿掉,改掛上對應的 class,交給站上的 CSS 決定怎麼畫。也就是說,顏色不再直接決定節點長什麼樣子,而是先翻譯成「這個節點的意思」。
| 原本標的顏色 | 角色 | 畫法 |
|---|---|---|
| 綠 | 成立 | 最亮的框、很淡的底 |
| 黃、橙 | 重點 | 同上,左上角多一顆琥珀點,一張圖只留一個 |
| 藍、紫 | 次要 | 框和字退一階 |
| 紅 | 排除 | 點線框、沒有底、字再退一階 |
灰階和認不得的寫法照預設畫。前面那張圖裡,Jarvis bot 左上角的琥珀點就是這樣來的。文章本身一個字都不用改,下載圖片時這些樣式也會一起寫進去。
跟 Mermaid 官方正面比:在瀏覽器端快 20 倍#
我另外跟朋友一起維護一個開源專案 tessera,它是自架的檔案和筆記分享服務,分享出去的東西時間到了會自己清掉,目前還在開發中。筆記裡可以寫 Mermaid,原本用的是 Mermaid 官方套件。部落格換完之後,我請一起協作的夥伴把 tessera 也換成 pele,PR #148 在 10 月 28 日合併。
tessera 的情況跟部落格剛好相反:筆記是使用者在瀏覽器裡邊打邊看,圖只能在 client 端畫,而且內容是別人貼上來的,不能信任。每改一個字,圖就要重畫一次;打開別人分享的筆記,瀏覽器也得先把渲染器下載下來。所以渲染器的大小和速度,在這裡是直接感受得到的。
換完的效果#
PR 裡沒有量體積,所以我自己量了一次:照 tessera 換之前和換之後的寫法(同樣的版本、同樣的設定),用 esbuild 打包成按需載入的模組,在 Edge 裡依序畫站上那 136 張圖,各跑三次。
| Mermaid 11.17.2(換之前) | pele 1.12.0(換之後) | |
|---|---|---|
| 畫第一張圖要下載的 JS | 821 KB(gzip 224 KB) | 160 KB(gzip 67 KB) |
| 第一張圖畫出來(含載入) | 110–132 ms | 29–35 ms |
| 136 張全部畫完 | 4.1–4.5 秒 | 0.19–0.24 秒 |
| 單張中位數 | 28 ms | 1.1–1.4 ms |
| SVG 平均大小 | 27.5 KB | 4.8 KB |
| 切換亮暗模式 | 每張圖重畫 | 不用重畫 |
打開一篇有圖的筆記,要下載的渲染器剩下原本的三成左右;邊打邊看時,每次重畫從將近 30 毫秒變成 1 毫秒多,快了大約 20 倍。這些時間是在本機載入、沒有限速的情況下量的,主要反映解析和執行 JS 的成本;實際走網路時,Mermaid 要多下載的那 150 多 KB 還得另外算。136 張圖兩邊都全部畫成功,換完沒有哪張圖因此壞掉。
怎麼接#
接法是 pele 的另一種用法,在瀏覽器端按需載入:
// 有圖要畫的時候才載入渲染器,不進主 bundle
const { renderAsync } = await import('pele/lazy');
// 貼上來的內容不可信:輸出不帶連結,也不發圖片請求
const rendered = await renderAsync(trimmed, {
images: false,
links: false,
});配色也一樣交給 CSS 變數。tessera 本來就有一套設計 token,所以直接在 :root 把 --pele-* 綁上去,切亮暗模式或換強調色時,圖會跟著變,不用重畫:
:root {
--pele-bg: var(--card);
--pele-fg: var(--foreground);
--pele-surface: var(--muted);
--pele-border: var(--border);
--pele-accent: var(--primary);
/* 其餘的變數也照這樣對到原本的 token */
}刪掉的程式碼#
除了變快,維護起來也輕鬆很多。渲染用的 hook 刪了 66 行、只加回 12 行,刪掉的幾乎都是為了繞過 Mermaid 才寫的:
- 每次渲染都要產生一個不重複的 id。
- Mermaid 渲染失敗時,會把量尺寸用的容器留在
body上,越積越多(mermaid-js/mermaid#4730),要自己清掉。 - Mermaid 輸出的 SVG 寬度寫成 100%,真正的尺寸藏在 inline style 裡,得用正規表示式改寫成
width、height,縮放視窗才量得到。pele 的 SVG 本身就帶著寬高。 - 主題一換就要整張重新渲染。
lockfile 也跟著少了 d3、cytoscape、KaTeX、DOMPurify 這些 Mermaid 帶進來的 80 多個套件,新增的只有 pele 一個,它本身沒有任何相依。同一個渲染器,部落格在伺服器端用、tessera 在瀏覽器端用,兩種接法都很單純。
要注意的地方#
- 版本要釘死:pele 目前更新非常快,三週出了十幾個版本。建議把版本釘死,升級前先看變更,再拿自己的圖跑一遍。
- 長得跟 Mermaid 不一樣:pele 只承諾語法相容,版面和視覺是它自己的。如果你的讀者會拿圖去跟 GitHub 上的渲染結果對照,要有心理準備。
- 錯誤訊息會引用原始碼:
render()遇到語法錯誤或不支援的類型會丟PeleError,訊息裡帶著圖的原文,顯示時要當純文字處理,不要直接塞進 HTML。 - 瀏覽器端可以用
mount():它會從 CSS 讀字型、依容器寬度調整版面。我的站是伺服器端出圖,所以沒有用到。
換完到現在,站上 136 張圖全部正常,文章頁也不用再背任何渲染器;tessera 那邊,要下載的渲染器也只剩原本的三成。我平常就用 Obsidian 記筆記,沒想到部落格的圖表最後也用上了同一個團隊做的東西。
- pele 官網與文件pele.run
- pele 主題變數pele.run
- pele 原始碼GitHub
- tessera 換成 pele 的 PRLimatura/tessera#148
- Mermaidmermaid.js.org