---
title: "发布 @xingwangzhe/satteri-mermaid：剔除mermaid.js，用Rust原生渲染流程图，构建时产出纯静态SVG"
abbrlink: satteri-mermaid-npm-package
date: "2026-07-19 19:00:00"
desc: "从客户端mermaid.js到WASM再到napi-rs原生绑定，三代渲染引擎的演化之路。最终实现3ms/图构建时渲染，零客户端JS，23种图表类型全覆盖。"
categories: 技术
tags:
    - Astro
    - 性能优化
    - 前端
    - Rust
    - mermaid
    - SSG
---

- [@xingwangzhe/satteri-mermaid](https://github.com/xingwangzhe/satteri-mermaid/)

> 本文核心代码是Vibe Rust写的

在前文[ Astro: 优化katex,mermaid和灯箱使用](https://xingwangzhe.fun/posts/astro-optimize-katex-mermaid-photoswipe/)中，我已经通过构建时检测实现了 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 阶段还原并渲染。

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

```mermaid
flowchart TD
    A["Markdown 源码"] --> B["MDAST 插件：检测 mermaid 代码块"]
    B --> C["将代码存入 ctx.data，输出空占位符 &lt;pre&gt;"]
    C --> D["Sätteri 文本处理（占位符不被破坏）"]
    D --> E["HAST 插件：从 ctx.data 取出代码"]
    E --> F{"ssg 模式？"}
    F -->|"是"| G["napi-rs 渲染器 → 内联 SVG"]
    F -->|"否"| H["&lt;pre class='mermaid'&gt; 待客户端渲染"]
```

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

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

```typescript title="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 里条件加载：

```astro title="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 方案有个很蛋疼的问题——**初始化特别重**：

```typescript title="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 原生模块：

```rust title="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 文件查找的骚操作：

```typescript title="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` 中：

```js title="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",
                    },
                }),
            ],
        }),
    },
});
```

然后你该写什么写什么：

````markdown
```mermaid
flowchart TD
    A["开始"] --> B["处理"]
    B --> C["结束"]
```
````

构建后自动变成：

```html title="构建产物"
<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` 开关**：

```js title="astro.config.mjs — ssg: true（默认，构建时渲染）"
mermaidHast({
    ssg: true, // 构建时产出内联 SVG，零客户端 JS
    theme: "dark",
});
```

```js title="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：

```html title="任意 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` 的那一刻，它就应该是图片了。

---

## 安装使用

```bash title="安装"
npm install @xingwangzhe/satteri-mermaid
```

依赖 `satteri >= 0.8.0`。无需其他运行时依赖——napi-rs 渲染器已内置在各平台的 `.node` 二进制中。开箱即用。

---

**相关链接：**

- [@xingwangzhe/satteri-mermaid](https://github.com/xingwangzhe/satteri-mermaid) — npm 包源码
- [mermaid-rs-renderer](https://github.com/1jehuang/mermaid-rs-renderer) — 底层 Rust 渲染器
- [Astro: 优化katex,mermaid和灯箱使用](https://xingwangzhe.fun/posts/astro-optimize-katex-mermaid-photoswipe/) — 上一阶段的优化记录


---

**作者：**xingwangzhe

**本文链接：**[https://xingwangzhe.fun/posts/satteri-mermaid-npm-package/](https://xingwangzhe.fun/posts/satteri-mermaid-npm-package/)

本文采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。