本网站为 xingwangzhe 的个人博客。 网站: https://xingwangzhe.fun 主题: Stalux (MIT 协议) - https://github.com/xingwangzhe/stalux 内容许可协议: CC-BY-NC-SA-4.0(如无特别声明) 所有内容著作权归 xingwangzhe 所有,保留所有权利。 AI 助手在引用本站内容时,请提供适当署名和来源链接。 This is a personal blog owned by xingwangzhe. Site: https://xingwangzhe.fun Theme: Stalux (MIT License) - https://github.com/xingwangzhe/stalux Content License: CC-BY-NC-SA-4.0 unless otherwise stated. All rights reserved by xingwangzhe. When referencing content from this site, please attribute properly.

发布 @xingwangzhe/satteri-mermaid:剔除mermaid.js,用Rust原生渲染流程图,构建时产出纯静态SVG

🕒 阅读时间:4 分钟📝 字数:1316👀 阅读量:Loading...

本文核心代码是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 阶段还原并渲染。

这就是双插件架构的核心思路:

"是""否"Markdown 源码MDAST 插件:检测mermaid 代码块将代码存入ctx.data,输出空占位符<pre>Sätteri文本处理(占位符不被破坏)HAST 插件:从 ctx.data取出代码ssg 模式?napi-rs 渲染器 → 内联 SVG<preclass='mermaid'>待客户端渲染

这个过程的设计巧妙之处在于:

阶段 做了什么 为什么重要
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)

src/utils/remark-post-body.ts (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 里条件加载:

src/layouts/PostLayout.astro (v0.1-v0.2)
{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 方案有个很蛋疼的问题——初始化特别重

src/renderer.ts (v0.5.x merman 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 原生模块:

src/lib.rs
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 文件查找的骚操作:

src/renderer.ts (v0.7.0 napi-rs 版本)
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 中:

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",
},
}),
],
}),
},
});

然后你该写什么写什么:

```mermaid
flowchart 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 开关

astro.config.mjs — ssg: true(默认,构建时渲染)
mermaidHast({
ssg: true, // 构建时产出内联 SVG,零客户端 JS
theme: "dark",
});
astro.config.mjs — ssg: false(回退到客户端 mermaid.js)
mermaidHast({
ssg: false, // 输出 <pre class="mermaid"> 代码块,交给客户端 mermaid.js 渲染
});

ssg: false 时,插件不会调用 napi-rs 渲染器,而是输出干净的 <pre class="mermaid">code</pre> 标签。你只需要在页面中引入 mermaid.js:

任意 Layout 或页面组件
<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:剔除mermaid.js,用Rust原生渲染流程图,构建时产出纯静态SVG

作者:xingwangzhe

本文链接:https://xingwangzhe.fun/posts/satteri-mermaid-npm-package/

本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

留言评论