Fast Car

Back

在技术文档和博客写作中,图表是表达复杂概念和流程的重要工具。传统的做法是使用专业绘图软件制作图片,但这种方式存在维护困难、版本控制不友好等问题。Mermaid 作为一种基于文本的图表描述语言,让我们可以用代码的方式创建流程图、时序图、类图等各种图表。

在我的 Astro 博客项目中,我希望能够在 MDX 文件中直接写 Mermaid 代码,然后自动渲染成美观的图表。这个需求看似简单,但实际实现涉及到 Markdown 处理管道、remark 插件系统、客户端渲染等多个技术环节。

经过研究和实践,我实现了一套完整的 Mermaid 渲染方案:

  • 在 MDX 文件中直接写 ```mermaid 代码块
  • 构建时通过 remark 插件自动转换为 HTML
  • 客户端动态加载 Mermaid 库并渲染图表
  • 支持亮色/暗色主题自动切换
  • 响应式设计,移动端友好

这套方案的核心是理解 remark 在 Astro 构建流程中的作用,以及如何编写自定义插件来扩展 Markdown 处理能力。

演示#

Mermaid 图表渲染演示

我的主页:https://fastcar.fun

你可以在我其他的博客文章中看到 Mermaid 图表的实际渲染效果,包括流程图、时序图等多种类型的图表。

功能点详解#

remark 的作用和地位#

在深入实现细节之前,我们需要先理解 remark 在整个 Astro 构建流程中的作用。

什么是 remark? remark 是一个基于 unified 生态系统的 Markdown 处理器。它的核心理念是将 Markdown 文本转换为抽象语法树(AST),然后通过插件对 AST 进行各种转换,最后输出为目标格式。

remark 在 Astro 中的工作流程:

graph LR A[MDX 文件] --> B[remark 解析器] B --> C[Markdown AST] C --> D[remark 插件链] D --> E[转换后的 AST] E --> F[rehype 处理器] F --> G[HTML AST] G --> H[最终 HTML]

remark 插件的工作原理:

  1. AST 遍历:插件通过 visit 函数遍历语法树的每个节点
  2. 节点识别:根据节点类型和属性识别需要处理的内容
  3. 节点转换:修改、替换或删除节点
  4. 新节点创建:根据需要创建新的 AST 节点

Mermaid 渲染的技术挑战#

实现 Mermaid 渲染面临几个技术挑战:

1. 构建时 vs 运行时渲染

  • 构建时渲染:需要在 Node.js 环境中运行 Mermaid,但 Mermaid 依赖浏览器 API
  • 运行时渲染:需要在客户端动态加载和渲染,影响首屏性能

2. 主题适配

  • Mermaid 图表需要适配网站的亮色/暗色主题
  • 主题切换时需要重新渲染图表

3. 响应式设计

  • 图表需要在不同屏幕尺寸下正常显示
  • 需要处理图表过宽的情况

4. 错误处理

  • Mermaid 语法错误时需要优雅降级
  • 网络加载失败时的备选方案

核心实现策略#

基于以上挑战,我选择了以下实现策略:

1. 混合渲染方案

  • 构建时:remark 插件转换代码块为 HTML 容器
  • 运行时:客户端动态渲染 Mermaid 图表

2. 延迟加载

  • 使用 CDN 动态加载 Mermaid 库
  • 避免增加主包体积

3. 主题响应式

  • 监听 DOM 变化检测主题切换
  • 自动重新渲染图表

4. 优雅降级

  • 渲染失败时显示原始代码
  • 提供错误提示信息

架构图解#

整体处理流程#

graph TB A[MDX 文件] --> B[Astro 构建系统] B --> C[remark 处理管道] C --> D[remarkMermaid 插件] D --> E[AST 节点转换] E --> F[生成 HTML + Script] F --> G[输出到页面] G --> H[客户端加载 remark 生成的 HTML + Script] H --> I[Mermaid 库初始化] I --> J[图表渲染] J --> K[主题监听] K --> L[响应式调整]

remark 插件处理流程#

flowchart TD A[开始处理 AST] --> B[遍历所有节点] B --> C{是否为 code 节点?} C -->|否| D[继续下一个节点] C -->|是| E{lang 是否为 'mermaid'?} E -->|否| D E -->|是| F[提取 Mermaid 代码] F --> G[生成唯一 ID] G --> H[创建 HTML 容器] H --> I[嵌入渲染脚本] I --> J[替换原始节点] J --> D D --> K{还有节点?} K -->|是| B K -->|否| L[处理完成]

客户端渲染时序图#

sequenceDiagram participant P as 页面 participant S as 渲染脚本 participant M as Mermaid 库 participant D as DOM P->>S: 页面加载完成 S->>S: 检查是否已初始化 S->>M: 动态加载 Mermaid M->>S: 加载完成 S->>S: 检测当前主题 S->>M: 配置主题参数 S->>D: 查找所有图表容器 loop 渲染每个图表 S->>M: 调用 render 方法 M->>S: 返回 SVG 内容 S->>D: 插入 SVG 到容器 end S->>S: 设置主题监听器 Note over S: 监听主题变化并重新渲染

主题切换处理流程#

graph TD A[用户切换主题] --> B[DOM class 变化] B --> C[MutationObserver 检测] C --> D[触发主题检测函数] D --> E{当前是暗色主题?} E -->|是| F[配置暗色主题参数] E -->|否| G[配置亮色主题参数] F --> H[重新初始化 Mermaid] G --> H H --> I[清除所有图表渲染状态] I --> J[重新渲染所有图表] J --> K[应用响应式样式]

代码实现#

Astro 配置中注册插件#

// astro.config.ts
import { remarkMermaid } from './src/plugins/remark-mermaid.ts'

export default defineConfig({
  markdown: {
    remarkPlugins: [remarkMath, remarkMermaid],
    rehypePlugins: [
      [rehypeKatex, {}],
      rehypeHeadingIds,
      [rehypeAutolinkHeadings, { /* ... */ }]
    ],
    // ...其他配置
  }
})
typescript

配置要点:

  1. remarkMermaid 插件需要在 remarkPlugins 数组中注册
  2. 插件执行顺序很重要,Mermaid 插件应该在其他文本处理插件之前
  3. 可以与其他 remark/rehype 插件配合使用

remark 插件核心实现#

// src/plugins/remark-mermaid.ts
import type { Root } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'

export const remarkMermaid: Plugin<[], Root> = function () {
  return function (tree) {
    // 遍历 AST 中的所有节点
    visit(tree, 'code', (node, index, parent) => {
      // 只处理 mermaid 语言的代码块
      if (node.lang !== 'mermaid') {
        return
      }

      if (!parent || index === undefined) {
        return
      }

      // 生成唯一 ID 避免冲突
      const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`

      // 创建包含脚本的 HTML 节点
      const htmlNode = {
        type: 'html',
        value: `
<div class="mermaid-container">
  <div id="${id}" class="mermaid-diagram" data-code="${node.value.replace(/"/g, '&quot;')}">${node.value}</div>
</div>

<script type="module">
  // 动态加载和渲染逻辑
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
  // ... 渲染脚本,即客户端渲染核心逻辑
</script>
        `
      }

      // 替换原始代码块节点
      parent.children[index] = htmlNode as any
    })
  }
}
typescript

实现要点:

  1. 使用 visit 函数遍历 AST,只处理 type: 'code'lang: 'mermaid' 的节点
  2. 生成唯一 ID 避免多个图表之间的冲突
  3. 将 Mermaid 代码保存在 data-code 属性中,便于客户端获取
  4. 创建 type: 'html' 节点替换原始代码块

客户端渲染核心逻辑#

客户端渲染代码在 remark 插件中生成,并已嵌入在 HTML 中。

// 嵌入在 HTML 中的渲染脚本
async function renderMermaidDiagrams() {
  const currentTheme = getCurrentTheme()
  configureMermaid(currentTheme)

  const diagrams = document.querySelectorAll('.mermaid-diagram')

  for (const diagram of diagrams) {
    const element = diagram

    // 获取原始代码
    let code = element.dataset.code || element.textContent?.trim()

    if (!code || element.dataset.rendered === 'true') continue

    try {
      // 清除之前的内容
      element.innerHTML = ''

      // 渲染新的图表
      const { svg } = await mermaid.render(element.id + '-svg', code)
      element.innerHTML = svg
      element.dataset.rendered = 'true'

      // 添加响应式样式
      const svgElement = element.querySelector('svg')
      if (svgElement) {
        svgElement.style.maxWidth = '100%'
        svgElement.style.height = 'auto'
        svgElement.style.maxHeight = '720px'
      }
    } catch (error) {
      console.error('Mermaid rendering error:', error)
      // 优雅降级:显示原始代码
      element.innerHTML = `
        <pre class="mermaid-error"><code>${code}</code></pre>
        <p class="mermaid-error-message">Failed to render Mermaid diagram</p>
      `
    }
  }
}
javascript

实现要点:

  1. 使用 dataset.rendered 标记避免重复渲染
  2. 异步渲染支持大型图表
  3. 错误处理:渲染失败时显示原始代码
  4. 响应式处理:限制图表最大尺寸

主题适配实现#

// 主题检测和配置
function getCurrentTheme() {
  const isDark = document.documentElement.classList.contains('dark') ||
                document.body.classList.contains('dark') ||
                document.documentElement.getAttribute('data-theme') === 'dark'
  return isDark ? 'dark' : 'light'
}

function configureMermaid(theme) {
  const config = {
    startOnLoad: false,
    theme: theme === 'dark' ? 'dark' : 'default',
    themeVariables: {
      primaryColor: theme === 'dark' ? '#3b82f6' : '#2563eb',
      primaryTextColor: theme === 'dark' ? '#f8fafc' : '#1e293b',
      primaryBorderColor: theme === 'dark' ? '#475569' : '#cbd5e1',
      lineColor: theme === 'dark' ? '#64748b' : '#475569',
      // ... 更多主题变量
    },
    fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
    fontSize: 14
  }
  
  mermaid.initialize(config)
}

// 监听主题变化
function watchThemeChanges() {
  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.type === 'attributes' &&
          (mutation.attributeName === 'class' || mutation.attributeName === 'data-theme')) {
        // 重置渲染状态并重新渲染
        document.querySelectorAll('.mermaid-diagram').forEach(el => {
          el.dataset.rendered = 'false'
        })
        setTimeout(renderMermaidDiagrams, 100)
      }
    })
  })

  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['class', 'data-theme']
  })
}
javascript

实现要点:

  1. 支持多种主题检测方式,兼容不同的主题切换实现
  2. 自定义主题变量确保图表与网站风格一致
  3. 使用 MutationObserver 监听 DOM 变化
  4. 延迟重新渲染避免频繁触发

AST 节点结构理解#

// Markdown AST 中的代码块节点结构
interface CodeNode {
  type: 'code'
  lang: string | null     // 语言标识,如 'mermaid'
  meta: string | null     // 元信息
  value: string          // 代码内容
}

// 转换后的 HTML 节点结构
interface HtmlNode {
  type: 'html'
  value: string          // HTML 字符串
}
typescript

节点转换过程:

  1. remark 解析器将 ```mermaid 代码块解析为 CodeNode
  2. remarkMermaid 插件识别 lang: 'mermaid' 的节点
  3. 提取 value 中的 Mermaid 代码
  4. 生成包含容器和脚本的 HTML 字符串
  5. 创建 HtmlNode 替换原始 CodeNode

简单总结#

通过深入理解 remark 插件系统和 Astro 构建流程,我成功实现了 MDX 文件中 Mermaid 图表的自动渲染。这个方案的核心价值:

技术优势:

  • 构建时转换:利用 remark 插件在构建时处理,避免运行时解析开销
  • 按需加载:动态加载 Mermaid 库,不影响主包体积
  • 主题一致性:自动适配网站主题,保持视觉统一
  • 错误容错:优雅降级处理,提升用户体验

开发体验:

  • 语法简单:直接在 MDX 中写 ```mermaid 代码块
  • 实时预览:开发时可以实时看到图表效果
  • 版本控制友好:图表以文本形式存储,便于 diff 和协作
  • 维护简单:修改图表只需编辑文本,无需专业工具

架构设计亮点:

  • 插件化设计:通过 remark 插件扩展 Markdown 处理能力
  • 关注点分离:构建时转换和运行时渲染各司其职
  • 响应式适配:支持多种屏幕尺寸和主题模式
  • 性能优化:避免重复渲染,支持大型图表

后续改进方向:

  1. 服务端渲染:探索在构建时预渲染图表,提升首屏性能
  2. 图表缓存:实现图表渲染结果缓存,避免重复计算
  3. 交互增强:添加图表缩放、导出等交互功能
  4. 类型安全:为 Mermaid 代码添加语法检查和类型提示
  5. 性能监控:添加渲染性能监控,优化大型图表处理
  6. 插件扩展:支持更多图表类型和自定义样式

这套 Mermaid 渲染方案不仅解决了技术文档中图表展示的问题,更重要的是展示了如何通过理解和扩展构建工具来提升开发体验。remark 插件系统的强大之处在于它让我们可以用编程的方式扩展 Markdown 的能力,这为技术写作和文档工程化提供了无限可能。

Astro 项目中 MDX 文件内渲染 Mermaid 的实现原理
https://astro-pure.js.org/blog/astro-mdx-mermaid-rendering
Author Oliver Yeung
Published at 2025年9月9日
Comment seems to stuck. Try to refresh?✨