发布 @xingwangzhe/satteri-mermaid:剔除mermaid.js,用Rust原生渲染流程图,构建时产出纯静态SVG
本文核心代码是Vibe Rust写的
在前文 Astro: 优化katex,mermaid和灯箱使用中,我已经通过构建时检测实现了 mermaid 的按需加载——只有包含流程图的页面才会 import mermaid.js。
但问题恰恰出在这里。
mermaid.js 有多重?
来,我们先看看客户端 mermaid.js 的体量:
| 指标 | 数值 | 吐槽 |
|---|---|---|
| 包体积 | ~1.2MB (min+gzip) | 比我的整个博客首页还大 |
| 初始化时间 | ~200-500ms | 用户看着白屏等你的流程图“画”出来 |
| 布局抖动 | 严重 | 图表渲染前是空的,渲染后突然撑开页面,像极了 2003 年的网页 |
| 对 JS 的依赖 | 100% | 不用 JS 就看不了图,搜索引擎也看不了 |
| 对浏览器的要求 | 支持 ES6+ | 虽然 2026 年了这不算问题,但凭什么流程图需要 JS 运行? |
用 1.2MB 的 JavaScript 渲染一张“Hello World”流程图——这是用火箭炮打蚊子。
说实话,对于一个纯静态博客而言,让客户端 JavaScript 来渲染流程图,本身就有点荒谬。这些图表在构建时就完全可以确定了——它们就是写死在 Markdown 里的 Mermaid 代码,变都不变的。我构建一次,生成静态 SVG,后面就永远不需要再碰 JS 了。
但当时我并没有合适的工具。直到我发现了 mermaid-rs-renderer——一个用 Rust 写的 Mermaid 渲染器,并决定把它包装成 Astro 插件。
这一路,跌跌撞撞走了三代。
出土文物清单:satteri-mermaid 的演化史
在最终方案落地之前,我的 @xingwangzhe/satteri-mermaid 插件经历了三个大版本的更迭。说实话,每一代都是在上一代的坑里爬出来的。
| 文物编号 | 代际 | 渲染引擎 | 速度 | 图类型 | 体积 | 致命缺陷 |
|---|---|---|---|---|---|---|
| 001 | v0.1-v0.2 | 客户端 mermaid.js | 慢(200ms+) | 不定 | 0(但用户承担 1.2MB) | 需要客户端 JS,SEO 不可见,布局抖动 |
| 002 | v0.3-v0.4 | beautiful-mermaid (JS) | 中 | 有限 | ~500KB | JavaScript 渲染器,Node.js 构建时性能一般 |
| 003 | v0.5.x | @mermanjs/web (WASM) | 中 | 24 | 8.8MB WASM | WASM 体积巨大,1px 边框硬编码不可调,初始化需要文件系统查找 |
| 004 | v0.7.0 | mermaid-rs (napi-rs) | 3ms/图 | 23 | 原生 .node | 相比 WASM 少了 xychart(但这个图类型本身就很少用) |
从 JavaScript 到 WASM 再到 napi-rs 原生绑定,这一路在优化。
核心架构:MDAST + HAST 双插件
在 v0.2 版本之前,我只有一个简单的 MDAST 插件:检测到 ```mermaid 代码块,直接把它原样输出到 HTML。然后让客户端 mermaid.js 来接手。
问题很快暴露了——Sätteri 处理器(本博客使用的 Markdown 处理器)在进行文本变换(smart punctuation 等)时,会破坏 Mermaid 代码。最典型的是 {" 这种花括号加引号的序列,会被 Sätteri 当成字符实体处理。结果就是渲染出来的流程图要么缺胳膊少腿,要么直接报错。
这个问题的根源在于:Mermaid 代码在 Markdown 的 AST(抽象语法树)层面还是一段“普通文本”,会被后续的各种 Rehype/Hast 插件随意蹂躏。唯一的解决方案是——在 MDAST 阶段把 Mermaid 代码隐藏起来,等所有文本处理都结束了,再在 HAST 阶段还原并渲染。
这就是双插件架构的核心思路:
这个过程的设计巧妙之处在于:
| 阶段 | 做了什么 | 为什么重要 |
|---|---|---|
| MDAST | 输出空的 <pre data-mermaid-id="..."> 占位符,代码存入 ctx.data |
占位符在 Sätteri 看来就是普通 HTML,不会触发任何文本变换 |
| 中间处理 | Sätteri 执行 smart punctuation、GFM 等文本变换 | Mermaid 代码已安全藏起,不被“误伤” |
| HAST | 从 ctx.data 取出代码,调用渲染器生成 SVG 并替换占位符 |
渲染失败时自动回退为原始代码块,不会导致页面空白 |
三代渲染引擎的技术细节
第一代:客户端 mermaid.js(v0.1 - v0.2)
// MDAST 插件:检测 mermaid 代码块,输出占位符visit(tree, (node: any) => { if (node.type === "code" && node.lang === "mermaid") { node.type = "html"; node.value = `<pre class="mermaid">${node.value}</pre>`; }});然后在 Layout 里条件加载:
{hasMermaid && ( <script> import mermaid from "mermaid"; mermaid.initialize({ startOnLoad: true }); </script>)}这倒也不是一无是处——至少比全局加载 mermaid.js 好。但问题在于,mermaid 依然是客户端的。搜索引擎爬虫看不到你的流程图内容,RSS 阅读器里是一片空白,关掉 JS 的用户看到的是 <pre class="mermaid">graph TD\n A-->B</pre> 这样的原始代码。
第二代:@mermanjs/web WASM(v0.3 - v0.5)
第一次尝试“构建时渲染”用的是 beautiful-mermaid(一个 JS 渲染器),很快换成了 @mermanjs/web——一个用 Rust 编译成的 WASM 模块。这比纯 JS 强了不少:
| 特性 | beautiful-mermaid (JS) | @mermanjs/web (WASM) |
|---|---|---|
| 图表类型 | 有限 | 24 种 |
| 主题预设 | 少 | 7 种 |
| 渲染方式 | JS 运行时 | 构建时出 SVG |
| 客户端 JS | 需要 | 零 |
但 WASM 方案有个很蛋疼的问题——初始化特别重:
import { initMerman, renderSvg } from "@mermanjs/web";import { existsSync, readFileSync } from "node:fs";
// 在 node_modules 里找 WASM 文件...这操作真的优雅吗?function findWasm(): string { let dir = process.cwd(); for (let i = 0; i < 20; i++) { const p = dir + "/node_modules/@mermanjs/web/pkg/merman_wasm_bg.wasm"; if (existsSync(p)) return p; const sep = dir.lastIndexOf("/"); if (sep <= 0) break; dir = dir.slice(0, sep); } throw new Error("Cannot find @mermanjs/web WASM");}
async function doInit(): Promise<void> { const wasmPath = findWasm(); const wasmBytes = readFileSync(wasmPath); const wasmModule = await WebAssembly.compile(wasmBytes); await initMerman({ loader: ..., wasm: wasmModule });}在
node_modules里逐级往上找 8.8MB 的 WASM 文件,然后readFileSync读进来,再WebAssembly.compile编译……这一套下来,构建启动时间直接多了几百毫秒。
而且最让人抓狂的是,merman 的边框宽度是硬编码 1px,没法自定义——在深色主题下,边框几乎看不见。
第三代:napi-rs 原生绑定(v0.7.0)——终局之战
WASM 方案还有一个更深层的问题——它是一个“中间人”。Rust 代码 → 编译成 WASM → Node.js 加载 WASM → WebAssembly 运行时执行。每一步都是开销。
最优解是什么?Rust 代码 → 编译成原生 .node 二进制 → Node.js 直接调用。中间的 WASM 层,砍掉。
这就是 v0.7.0 做的事——用 napi-rs 把 mermaid-rs-renderer 包装成一个 Node.js 原生模块:
use mermaid_rs_renderer::{render_with_options, RenderOptions as MrRenderOptions, Theme};use napi_derive::napi;
#[napi(object)]pub struct RenderOptions { pub theme: Option<String>, pub primary_color: Option<String>, pub primary_border_color: Option<String>, // ... 60+ 参数全覆盖}
#[napi]pub fn render(code: String, opts: Option<RenderOptions>) -> napi::Result<String> { let mr_opts = build_render_options(&opts); render_with_options(&code, mr_opts) .map_err(|e| napi::Error::from_reason(e.to_string()))}JS 端直接调用,不再需要什么 WASM 文件查找的骚操作:
import { createRequire } from "node:module";
function getBinding(): NativeBinding { const require = createRequire(import.meta.url); return require("../index.cjs") as NativeBinding;}
export function renderMermaidSVG(code: string, opts: Record<string, unknown>): string { const { render } = getBinding(); return render(code.trim(), opts);}就这。不需要 readFileSync,不需要 WebAssembly.compile,不需要在 node_modules 里大海捞针。Node.js 的 require 直接加载 .node 二进制,Rust 函数像 JS 函数一样调用。
效果对比
| 指标 | mermaid.js (客户端) | @mermanjs/web (WASM) | mermaid-rs (napi-rs) |
|---|---|---|---|
| 单图渲染时间 | 200-500ms(用户感知) | ~50ms(构建时) | ~3ms(构建时) |
| 首次初始化 | 0(已包含在渲染时间内) | ~300ms(WASM 编译) | ~0ms(原生加载) |
| 产物体积 | 0(但用户承担 1.2MB 下载) | 8.8MB WASM 文件 | <1MB .node 二进制 |
| 客户端 JS | 需要 1.2MB+ | 0 | 0 |
| 图类型支持 | 全面 | 24 | 23(少一个 xychart) |
| 主题预设 | 5 | 7 | 5(但全部颜色可覆盖) |
| 边框粗细控制 | 可配置 | 硬编码 1px,不可配 | 可配置(通过 theme overrides) |
| 可自定义颜色字段 | 有限 | 16 种 role | 60+ 参数全覆盖 |
| SEO 友好 | 否(爬虫看不到 SVG) | 是(内联 SVG) | 是(内联 SVG) |
| RSS 阅读器友好 | 否(需要 JS) | 是(静态 SVG) | 是(静态 SVG) |
| 平台支持 | 全平台 | 全平台 | Linux/Mac/Windows (x64 + arm64) |
表格最后一行需要说明一下——napi-rs 需要预编译平台二进制,目前我们覆盖了 Linux x64、Linux arm64、macOS arm64、Windows x64。对于小众平台(如 FreeBSD),可以回退到客户端 mermaid.js 模式(ssg: false),或者……自己编译(开源的好处)。
在 Astro 中的使用
整个配置出奇地简单。在 astro.config.mjs 中:
import { satteri } from "@astrojs/markdown-satteri";import { mermaidMdast, mermaidHast } from "@xingwangzhe/satteri-mermaid";
export default defineConfig({ markdown: { processor: satteri({ mdastPlugins: [mermaidMdast()], hastPlugins: [ mermaidHast({ theme: "dark", responsive: true, themeOverrides: { clusterBorder: "#cccccc", primaryBorderColor: "#ff6600", }, }), ], }), },});然后你该写什么写什么:
```mermaidflowchart TD A["开始"] --> B["处理"] B --> C["结束"]```构建后自动变成:
<div class="mermaid" data-mermaid-ssg="true" style="max-width:100%;overflow:hidden"> <svg viewBox="..." style="width:100%;display:block">...</svg></div>零 JS,零依赖,纯静态。搜索引擎能直接索引 SVG 内的文字,RSS 阅读器能正常显示,手机端也不需要加载 1.2MB 的脚本来渲染一张图。
ssg 开关:构建时 vs 客户端,由你决定
说实话,我也不想把话说死——并不是所有人都适合纯静态 SVG。有些场景下,你可能就是想用客户端 mermaid.js 自己渲染:
| 场景 | 说明 |
|---|---|
| 小众平台(FreeBSD、ARM 开发板等) | 没有预编译的 napi-rs 二进制,但 Node.js 跑得动 |
使用了 xychart 图表 |
mermaid-rs 目前支持的 23 种图表中不含 xychart |
| 渐进迁移 | 旧的客户端 mermaid.js 方案先不动,新文章逐步切到 SSG 模式 |
| 就是喜欢客户端渲染 | 没毛病,插件不强制任何选择 |
为此,插件提供了一个 ssg 开关:
mermaidHast({ ssg: true, // 构建时产出内联 SVG,零客户端 JS theme: "dark",});mermaidHast({ ssg: false, // 输出 <pre class="mermaid"> 代码块,交给客户端 mermaid.js 渲染});当 ssg: false 时,插件不会调用 napi-rs 渲染器,而是输出干净的 <pre class="mermaid">code</pre> 标签。你只需要在页面中引入 mermaid.js:
<script type="module"> import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; mermaid.initialize({ startOnLoad: true });</script>| 模式 | 适用场景 | 客户端 JS | SEO 友好 | 渲染时机 |
|---|---|---|---|---|
ssg: true |
主流平台、SSG 博客、追求极致性能 | 0 | 是 | astro build |
ssg: false |
小众平台、需要 xychart、渐进迁移 | 需要 | 否 | 浏览器运行时 |
这个开关的设计理念很明确:你不需要在“全有”和“全无”之间二选一。一台 x64 Linux 构建服务器上开
ssg: true享受 3ms/图的速度;改天换到 ARM 开发板上照样能跑ssg: false不掉链子。进可攻,退可守。
这一路踩过的坑
坑 1:Sätteri 破坏 Mermaid 代码
这是最早的坑,也是逼出双插件架构的根本原因。Sätteri 的 smart punctuation 会把 Mermaid 代码中的特殊字符序列(比如 {")当成文本进行处理变换,导致图渲染失败。
解决方案前面已经说了:MDAST 阶段把代码藏起来,HAST 阶段再取出来渲染。
坑 2:WASM 文件查找
@mermanjs/web 的 WASM 文件位置在不同环境下可能不同(npm link、workspace、monorepo 都会影响路径)。写了一个逐级向上查找的函数,最多找 20 层目录。说实话,这种代码写出来我就知道这方案活不长。
用 napi-rs 之后,.node 文件跟着 npm 包走,Node.js 的 require 机制自动解析路径。天经地义的事,不需要任何骚操作。
坑 3:Expressve Code 语法高亮器干扰
Astro 的 expressive-code 会把 ```mermaid 代码块当成普通代码块处理,输出时加了一堆 <span> 和 class。导致 HAST 插件找不到 mermaid 代码。
解决方案是在 HAST 插件中同时监听两种节点:raw 节点(原生的 HTML 输出)和 element 节点(被语法高亮器处理后的结构化输出)。双路径兜底,确保万无一失。
总结
现在回头看,这个问题的解决路径其实非常清晰:
| 步骤 | 问题 | 答案 |
|---|---|---|
| 1 | 流程图源码在哪? | Markdown 中就已确定,无需浏览器介入 |
| 2 | 什么时候渲染? | 构建时渲染成 SVG,而非客户端运行时 |
| 3 | 用什么渲染? | JavaScript 太慢,WASM 太重,原生绑定刚刚好 |
| 4 | 怎么保证不被打扰? | MDAST 藏代码,HAST 取回并渲染 |
最终,我们用 150 行 Rust + 450 行 TypeScript 的代码量,换来了:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 客户端 JS 体积 | 1.2MB | 0 |
| 流程图渲染延迟 | 200-500ms(用户感知) | 0(构建时已完成) |
| 搜索引擎可见性 | 看不到 SVG 内容 | 完全可索引 |
| 构建时单图渲染 | 不适用(客户端渲染) | 稳定在 3ms 以内 |
说实话,这种“在构建时就把所有事情做完”的理念,才是一个静态博客该有的样子。你的内容是什么,就生成什么——不需要用户在浏览器里再去编译、渲染、计算。
流程图不应该是一个运行时概念——它应该是一张图片。在你按下
Ctrl+S的那一刻,它就应该是图片了。
安装使用
npm install @xingwangzhe/satteri-mermaid依赖 satteri >= 0.8.0。无需其他运行时依赖——napi-rs 渲染器已内置在各平台的 .node 二进制中。开箱即用。
相关链接:
- @xingwangzhe/satteri-mermaid — npm 包源码
- mermaid-rs-renderer — 底层 Rust 渲染器
- Astro: 优化katex,mermaid和灯箱使用 — 上一阶段的优化记录
发布 @xingwangzhe/satteri-mermaid:剔除mermaid.js,用Rust原生渲染流程图,构建时产出纯静态SVG
作者:xingwangzhe
本文链接:https://xingwangzhe.fun/posts/satteri-mermaid-npm-package/
本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
留言评论