我的部落格,還有我參與的另一個開源專案,最近都把 Mermaid 換成了 Obsidian 團隊開源的 pele。先看同一段語法畫出來的樣子,左邊是 Mermaid 官方套件,右邊是 pele:

不只是好看。同樣 136 張圖,在瀏覽器裡跟 Mermaid 官方套件比:

1.1ms
每張圖重畫
Mermaid 要 28 ms
67KB
要下載的渲染器
gzip 後;Mermaid 224 KB
4.8KB
SVG 大小
Mermaid 27.5 KB
0次重畫
換主題
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.0beautiful-mermaid 1.1.3
成功136 / 136136 / 136
全部跑完72–91 ms657–693 ms
單張中位數0.4 ms3.7 ms
單張 p951.4 ms8.2–11.3 ms
最慢的一張7–10 ms94–102 ms
SVG 平均大小4.8 KB7.7 KB

體積的部分,我用 esbuild 各打一包壓縮過的 browser bundle:

打包內容壓縮後gzip
pele 全部圖表類型626 KB231 KB
pele 只註冊 flowchart112 KB43 KB
beautiful-mermaid1,522 KB457 KB

我的站是在伺服器端渲染,所以這些體積不會進到讀者的瀏覽器。如果你打算在 client 端用,pele 只註冊需要的圖表類型會差很多:只用 flowchart 的話,gzip 之後 43 KB。

畫出來差在哪#

下面是 CrowdSec 那篇裡的同一張圖。舊站凍結在換渲染器之前,所以還是 beautiful-mermaid 畫的:

換成 pele 之後:

一樣是由左到右的流程圖,pele 把它排成一條直線,往返的兩條線平行排好,標籤直接放在線上,沒有任何交錯。這張圖裡原本有我用 classDef 標成綠色和黃色的節點,新版裡看不到那兩個顏色,這是我在網站這邊另外做的處理,後面會講。

怎麼接進網站#

我的站用 TanStack Start。整體做法是:伺服器端把文章裡的 Mermaid 區塊都畫成 SVG,隨著文章資料一起送到瀏覽器,瀏覽器只負責把 SVG 放進頁面、處理縮放和工具列。

伺服器端出圖#

渲染只有一行,配色完全不進渲染器:

ts
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';

export function renderMermaidSvg(body: string): string {
  return render(body).svg;
}

再包成一個 server function,把文章裡每張圖畫好,做成「圖的內容 hash → SVG」的對照表:

ts
// 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 提供。我的站只留兩個主題:夜(站上的配色)和紙(淺色,方便下載成圖片貼到別處):

css
.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 本身:

ts
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(換之後)
畫第一張圖要下載的 JS821 KB(gzip 224 KB)160 KB(gzip 67 KB)
第一張圖畫出來(含載入)110–132 ms29–35 ms
136 張全部畫完4.1–4.5 秒0.19–0.24 秒
單張中位數28 ms1.1–1.4 ms
SVG 平均大小27.5 KB4.8 KB
切換亮暗模式每張圖重畫不用重畫

打開一篇有圖的筆記,要下載的渲染器剩下原本的三成左右;邊打邊看時,每次重畫從將近 30 毫秒變成 1 毫秒多,快了大約 20 倍。這些時間是在本機載入、沒有限速的情況下量的,主要反映解析和執行 JS 的成本;實際走網路時,Mermaid 要多下載的那 150 多 KB 還得另外算。136 張圖兩邊都全部畫成功,換完沒有哪張圖因此壞掉。

怎麼接#

接法是 pele 的另一種用法,在瀏覽器端按需載入:

ts
// 有圖要畫的時候才載入渲染器,不進主 bundle
const { renderAsync } = await import('pele/lazy');
// 貼上來的內容不可信:輸出不帶連結,也不發圖片請求
const rendered = await renderAsync(trimmed, {
  images: false,
  links: false,
});

配色也一樣交給 CSS 變數。tessera 本來就有一套設計 token,所以直接在 :root 把 --pele-* 綁上去,切亮暗模式或換強調色時,圖會跟著變,不用重畫:

css
: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 記筆記,沒想到部落格的圖表最後也用上了同一個團隊做的東西。

參考連結