我的部落格,还有我参与的另一个开源专案,最近都把 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 记笔记,没想到部落格的图表最后也用上了同一个团队做的东西。

參考連結