极简静态博客的 Mermaid 原生图表系统集成与禅意调优实践

在技术写作与系统设计复盘中,文字与代码往往只能呈现离散的逻辑点,而一张结构清晰的架构图、时序图或数据流向图,能够瞬间拉通读者的全局认知。

然而,在坚持 “纯净优先、零臃肿客户端、秒级静态构建” 的 Zen 架构静态博客中,引入现代图表系统面临着重重工程与美学挑战:

  • 体积痛点:Mermaid 完整的渲染引擎压缩后超过 2MB,若无脑全站注入,会让原本轻若鸿毛的纯静态博客瞬间变成重型 SPA;
  • 构建痛点:若在 Node.js 服务端通过 Headless Chrome(Puppeteer/JSDOM)进行 SSR 编译,CI 构建时间会暴增数十倍,且生成的静态 SVG 无法跟随前端主题进行实时动态重绘;
  • 视觉痛点:Mermaid 默认的刺眼亮蓝、高饱和度配色与纯白背景,与本博客典雅的东方抹茶绿/暗夜墨绿排版体系格格不入。

本文将完整复盘如何在不妥协性能与美学的前提下,通过 “构建期 AST 拦截分流 + 客户端动态 ESM 按需加载 + MutationObserver 毫秒级双主题热切换 + 东方 Zen 调色盘深度定制”,为静态博客带来原生级图表支持。


1. 架构设计:构建期 AST 分流与零成本标记

1.1 代码块分流拦截与语义化封装

在 Markdown 解析管线([core/markdown.js](file:///Users/Carl/Documents/GitHubProjects/Blog/core/markdown.js))中,所有的代码块(```lang)默认由 Shiki 在服务端完成语法分析与双主题 Tokenizer 染色。

为了支持 Mermaid,我们需要在 markdown-itfence 规则层面进行精准拦截:

// 自定义规则:Mermaid 图表拦截与语义化包装
const defaultFence = md.renderer.rules.fence || function(tokens, idx, options, env, self) {
  return self.renderToken(tokens, idx, options);
};

md.renderer.rules.fence = function (tokens, idx, options, env, self) {
  const token = tokens[idx];
  const info = token.info ? token.info.trim() : '';
  const lang = info ? info.split(/\s+/g)[0] : '';
  
  if (lang === 'mermaid') {
    const rawCode = token.content.trim();
    const escapedCode = md.utils.escapeHtml(rawCode);
    return `<figure class="figure-mermaid">
      <div class="mermaid" data-source="${escapedCode}">${escapedCode}</div>
      <button class="copy-code-button copy-mermaid-button" data-type="mermaid" title="Copy Mermaid">Copy</button>
    </figure>\n`;
  }
  
  return defaultFence(tokens, idx, options, env, self);
};

这里有三个至关重要的设计细节:

  1. 语义化 <figure> 容器:将图表包裹在标准的 <figure class="figure-mermaid"> 语义结构中,与普通代码块容器 .code-block-wrapper 实现 DOM 物理隔离;
  2. 避免 <pre> 污染:容器内使用 <div> 而非 <pre>,彻底防止 Cheerio 后置代码块处理逻辑(如 $('pre').each)误伤图表容器;
  3. data-source 原始代码无损挂载:通过属性挂载转义后的原始 Mermaid 语法,为后续双主题无损重绘与一键复制代码提供数据源。

1.2 hasMermaid 元数据联动与零成本加载

parseMarkdown 产出文章元数据时,自动检测页面是否包含 Mermaid 图表:

return {
  ...attributes,
  id,
  title: attributes.title || id,
  hasMath: html.includes('katex'),
  hasMermaid: html.includes('class="mermaid"') || html.includes('figure-mermaid'),
  content: $.html(),
};

在文章模板 [theme/layouts/content/index.ejs](file:///Users/Carl/Documents/GitHubProjects/Blog/theme/layouts/content/index.ejs) 底部:

<% if (locals.hasMermaid) { %>
<script type="module" src="/js/mermaid-init.js"></script>
<% } %>

成效:全站仅含有图表的文章才会加载驱动脚本,普通随笔、读书笔记保持 0 额外 JS 载荷,守护博客的秒开性能。


2. 运行时设计:ESM 动态按需与 MutationObserver 即时热切换

2.1 现代化公共 CDN 动态导入

我们采用现代化 ES Module 规范,通过动态 import 从全球 CDN 拉取轻量稳定版 Mermaid 核心引擎,无需在 Git 仓库中堆积 vendor 体积:

import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';

2.2 响应式热切换:MutationObserver 毫秒级重绘

传统的主题切换监听往往依赖 Checkbox 的 changeclick 事件。但在用户通过系统外观自动切换、多端同步或快捷键切换时,change 事件极易漏判,导致页面必须手动刷新才能换色。

我们引入原生的 MutationObserver 直接监听根节点 <html>class 属性变化:

// 1. MutationObserver 监听 <html> 的 class 变化(即时响应 dark-theme 增删)
const themeObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.attributeName === 'class') {
      const isDark = document.documentElement.classList.contains('dark-theme');
      if (isDark !== lastThemeIsDark) {
        renderAllDiagrams();
      }
    }
  }
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });

2.3 样式残留清除与代数 ID 隔离

Mermaid 在浏览器中渲染 SVG 时,会在 <head> 中生成局部的 <style id="mermaid-..."> 样式标签。如果不做清理,切换主题后旧的内联样式会覆盖新生成的 SVG,导致颜色出现脏数据。

我们在每次重绘前执行物理清理与代数 ID 递增:

let renderCount = 0;
let renderQueue = Promise.resolve();

async function renderAllDiagrams() {
  renderQueue = renderQueue.then(async () => {
    const isDark = document.documentElement.classList.contains('dark-theme');
    
    // 1. 彻底清除上一次遗留在 DOM 中的旧样式标签
    document.querySelectorAll('style[id^="mermaid-"], style[id^="dmermaid-"]').forEach(el => el.remove());

    // 2. 重新初始化主题调色板
    mermaid.initialize(getMermaidThemeConfig(isDark));

    renderCount++;
    const figures = document.querySelectorAll('.figure-mermaid');

    for (let i = 0; i < figures.length; i++) {
      const figure = figures[i];
      const container = figure.querySelector('.mermaid');
      if (!container) continue;

      const rawCode = container.getAttribute('data-source') || container.textContent.trim();
      const uniqueId = `mermaid-chart-v${renderCount}-${i}-${Date.now().toString(36)}`;
      
      try {
        const { svg } = await mermaid.render(uniqueId, rawCode);
        container.innerHTML = svg;
      } catch (err) {
        container.innerHTML = `<pre class="mermaid-error">Failed to render:\n${err.message || err}</pre>`;
      }
    }
  });
  return renderQueue;
}

3. 视觉与排版系统:东方 Zen 调色盘与细节打磨

3.1 东方 Zen 专属双主题调色板

为了让图表完全融入博客的排版环境,我们为深浅色双模式量身定制了色彩映射:

元素分类浅色模式(抹茶灰绿雅韵)深色模式(暗夜墨绿幽深)
画布背景透出博客底色 --background (#D5F5D9)透出博客底色 --background (#1A2421)
节点底色柔和浅绿 #E6F9E9墨绿深灰 #24332F
边框与主色经典古金 #7A6626晶莹翠绿 #8AE293
文字颜色雅致黛黑 #2C3E50柔和玉白 #E1E8E5
连线与箭头竹青色 #507554亮绿线 #8AE293

3.2 字体排版双轨制与防截断处理

在默认情况下,Mermaid 在计算文本边界盒(getBBox)时如果与页面字体设置冲突,极易导致长文字或中文被节点右边框截断。

我们进行了系统级调校:

  1. 字体与字号:强制继承系统默认无衬线字体(-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif),字号精确设定为 13px(比正文 16px 小一阶,紧凑精致);
  2. 防裁剪与内边距:配置 padding: 14nodeSpacing: 45,在 CSS 中注入 line-height: 1.4 !important,确保中英混排、标点与代码路径均有充足的呼吸感。

3.3 高反差饼图(Pie Chart)色盘定制

Mermaid 默认的饼图颜色为单一色系渐变,扇区之间极难区分。我们定制了 8 组高辨识度色彩:

  • 浅色模式:深茶绿 (#4A6B53)、琥珀赭黄 (#C28836)、靛湖蓝 (#3D6C85)、茜草红 (#A84E4E)、暮菖蒲紫 (#7E658E)、青瓷青 (#4E8075)、橄榄绿 (#7D8C51)、沉香褐 (#8C5E45);
  • 深色模式:明亮翡翠绿 (#8AE293)、琥珀金 (#F0C674)、晴空蓝 (#68B6E8)、珊瑚红 (#F07178)、紫水晶 (#C792EA)、碧湖青 (#4FD6BE)、嫩草绿 (#B5D166)、暖橙 (#FF9E64)。

4. 全图表类型实战效果演示

以下是在本博客环境中原生渲染的 5 类典型 Mermaid 图表。你可以点击页面右上角的主题切换开关,实时体验图表的毫秒级换色动效。

示例 1:架构与数据流向流程图 (Flowchart TD)

flowchart TD subgraph Build["1. 构建期 (Node.js Build Pipeline)"] MD["Markdown 文档 (content/*.md)"] --> AST["markdown-it AST 拦截分流"] AST -->|lang === 'mermaid'| FIG["生成 figure.figure-mermaid 语义容器"] AST -->|其他语言| SHIKI["Shiki 服务端代码高亮"] FIG --> META["注入 hasMermaid: true 元数据"] end subgraph Client["2. 客户端 (Client-Side Zen Driver)"] META -->|按需加载| ESM["动态导入 Mermaid v11 ESM"] OBS["MutationObserver 监听 dark-theme 变化"] --> RENDER["renderAllDiagrams() 队列重绘"] ESM --> RENDER RENDER --> SVG["注入高颜值自适应 SVG 矢量图"] end

示例 2:主题切换实时重绘时序图 (Sequence Diagram)

sequenceDiagram autonumber actor Reader as 读者 participant UI as 导航栏开关 participant DOM as HTML 根节点 participant Observer as MutationObserver participant Driver as mermaid-init.js participant Engine as Mermaid 引擎 Reader->>UI: 点击浅色/深色模式切换按钮 UI->>DOM: classList.toggle('dark-theme') DOM-->>Observer: 捕获 class 属性变更通知 Observer->>Driver: 触发 renderAllDiagrams() Driver->>Driver: 清除 DOM 中遗留的旧 <style> Driver->>Engine: mermaid.initialize(最新 Zen 调色板) Driver->>Engine: 读取 data-source 并传入代数唯一 ID Engine-->>Driver: 返回全新调色板渲染的 SVG 字符串 Driver->>DOM: 毫秒级无缝注入最新图表

示例 3:Git 分支演进图 (GitGraph)

gitGraph commit id: "v9.0.0 (Zen Architecture)" commit id: "Core Shiki Parser" branch feature/mermaid-engine checkout feature/mermaid-engine commit id: "AST Fence Interceptor" commit id: "Zen Palette Theme Variables" commit id: "MutationObserver Reactivity" checkout main merge feature/mermaid-engine id: "Merge v9.1.0 Mermaid Engine" commit id: "Release 9.1.0"

示例 4:资产分布高对比饼图 (Pie Chart)

pie title 博客静态资源与渲染耗时占比分析 "HTML 骨架与语义标签" : 20 "Zen CSS 核心排版引擎" : 30 "书影音本地化海报元数据" : 25 "按需异步 Mermaid ESM" : 15 "PWA 离线缓存服务" : 10

示例 5:图表生命周期状态机图 (State Diagram)

stateDiagram-v2 [*] --> MarkdownSource: 编写 ```mermaid 代码块 MarkdownSource --> AstParsing: core/build.js 解析 state AstParsing { [*] --> CheckLang CheckLang --> FigureWrapper: lang == 'mermaid' CheckLang --> ShikiHighlight: lang != 'mermaid' } FigureWrapper --> StaticHtml: 吐出包含 data-source 的 HTML StaticHtml --> ClientLoad: 浏览器访问页面 state ClientLoad { [*] --> CheckHasMermaid CheckHasMermaid --> LoadEsmModule: hasMermaid == true CheckHasMermaid --> Idle: hasMermaid == false } LoadEsmModule --> InitialRender: 首次根据当前主题着色 InitialRender --> Listening: 启动 MutationObserver Listening --> ReRender: 读者切换主题 (dark-theme 变动) ReRender --> Listening: 重新应用 Zen 调色板并重绘

5. 总结与 Zen 哲学思考

这一套方案落地之后,博客的图表支持达到了理想的工程状态:

  • 零冗余:不包含图表的页面没有多引入 1 字节的额外 JS,纯净度与构建速度一如既往;
  • 全受控:从 Markdown AST 解析、HTML 语义化包裹到客户端深浅色监听,每一行代码都处于绝对掌控之中;
  • 排版融合:没有突兀的粗劣边框与花哨动画,图表与正文在字号、字体、边框阶差与配色上浑然一体。

技术的演进并非一味引入复杂的全家桶工具,而是在极简的物理约束下,用最优雅的代码实现恰到好处的表达。希望这篇实践能为同样追求纯粹静态与极致排版体验的开发者带来一些启发。