在技术文档和博客写作中,图表是表达复杂概念和流程的重要工具。传统的做法是使用专业绘图软件制作图片,但这种方式存在维护困难、版本控制不友好等问题。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 中的工作流程:

MDX 文件
remark 解析器
Markdown AST
remark 插件链
转换后的 AST
rehype 处理器
HTML AST
最终 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. 优雅降级

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

架构图解

整体处理流程

MDX 文件
Astro 构建系统
remark 处理管道
remarkMermaid 插件
AST 节点转换
生成 HTML + Script
输出到页面
客户端加载 remark 生成的 HTML + Script
Mermaid 库初始化
图表渲染
主题监听
响应式调整

remark 插件处理流程

开始处理 AST
遍历所有节点
是否为 code 节点?
继续下一个节点
lang 是否为 'mermaid'?
提取 Mermaid 代码
生成唯一 ID
创建 HTML 容器
嵌入渲染脚本
替换原始节点
还有节点?
处理完成

客户端渲染时序图

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

主题切换处理流程

用户切换主题
DOM class 变化
MutationObserver 检测
触发主题检测函数
当前是暗色主题?
配置暗色主题参数
配置亮色主题参数
重新初始化 Mermaid
清除所有图表渲染状态
重新渲染所有图表
应用响应式样式

代码实现

Astro 配置中注册插件

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

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

配置要点:

  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
    })
  }
}

实现要点:

  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>
      `
    }
  }
}

实现要点:

  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']
  })
}

实现要点:

  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 字符串
}

节点转换过程:

  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 的能力,这为技术写作和文档工程化提供了无限可能。

Logo

一座年轻的奋斗人之城,一个温馨的开发者之家。在这里,代码改变人生,开发创造未来!

更多推荐