Skip to content

实现 Mermaid图表编辑器 工具

分类:developer | 标签:Mermaid、流程图、图表 Mermaid 语法实时渲染流程图/时序图/甘特图,导出 SVG/PNG

2.1 功能说明

Mermaid 语法实时渲染流程图/时序图/甘特图,导出 SVG/PNG

2.1.1 使用指南

功能说明

用文本语法描述图表,右侧实时渲染成矢量图。支持流程图、时序图、甘特图、类图、状态图、饼图、实体关系图等 Mermaid 全部常用图形,渲染完全在浏览器本地完成,代码不会上传。

快速上手

第一行声明图表类型,例如 graph TD(自上而下的流程图)、sequenceDiagram(时序图)、gantt(甘特图)。不确定语法时,先点击上方模板按钮填入示例,再在示例基础上修改。

流程图方向可选 TD(上下)、LR(左右)、BTRL。节点形状由括号决定:[] 矩形、() 圆角、{} 菱形判断、(()) 圆形。

连线写法:A --> B 实线箭头,A --- B 实线无箭头,A -.-> B 虚线,A ==> B 粗线,A --> |条件| B 带标签。

渲染与排错

停止输入约 0.5 秒后自动渲染。语法有误时右侧会保留上一次渲染成功的图形,并在下方给出错误提示,定位到出问题的行,不会白屏。

节点文字中若包含 (): 等特殊字符,请用英文双引号包裹,例如 A["计算 (含税)"]

导出

「导出 SVG」保存为矢量图,可无限缩放,适合插入文档或用 Illustrator 二次编辑;「导出 PNG」按所选倍数把矢量图栅格化为位图,2x 及以上在高分屏与打印时更清晰。

注意事项

渲染引擎体积较大(约 3.5MB),首次进入本页时才会按需加载,加载完成后同一次会话内不再重复下载。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • ensureEngine()
  • render()
  • applyTemplate()
  • download()
  • timestamp()
  • exportSvg()
  • measureSvg()
  • exportPng()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import {onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {ElMessage} from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'

const TEMPLATES = [
  {
    name: '流程图',
    code: `graph TD
    A([开始]) --> B[接收订单]
    B --> C{库存充足?}
    C -->|是| D[锁定库存]
    C -->|否| E[通知补货]
    E --> F[进入等待队列]
    D --> G[生成支付单]
    G --> H{支付成功?}
    H -->|是| I[发货]
    H -->|否| J[释放库存]
    J --> K([结束])
    I --> K
    F --> K`
  },
  {
    name: '时序图',
    code: `sequenceDiagram
    autonumber
    participant U as 用户
    participant W as 前端页面
    participant A as 网关
    participant S as 订单服务
    participant P as 支付服务

    U->>W: 点击提交订单
    W->>A: POST /orders
    A->>S: 创建订单
    S-->>A: 订单号 202601
    A-->>W: 返回订单号
    W->>P: 发起支付
    P-->>W: 支付结果
    alt 支付成功
        W-->>U: 跳转成功页
    else 支付失败
        W-->>U: 提示重新支付
    end`
  },
  {
    name: '甘特图',
    code: `gantt
    title 项目排期
    dateFormat YYYY-MM-DD
    axisFormat %m-%d

    section 需求阶段
    需求调研        :done,    des1, 2026-03-01, 7d
    需求评审        :done,    des2, after des1, 3d

    section 开发阶段
    接口设计        :active,  dev1, 2026-03-11, 5d
    前端开发        :         dev2, after dev1, 12d
    后端开发        :         dev3, after dev1, 14d

    section 测试上线
    联调测试        :         test1, after dev3, 7d
    灰度发布        :crit,    rel1, after test1, 3d`
  },
  {
    name: '类图',
    code: `classDiagram
    class Animal {
        +String name
        +int age
        +eat() void
        +sleep() void
    }
    class Dog {
        +String breed
        +bark() void
    }
    class Cat {
        +boolean indoor
        +meow() void
    }
    class Owner {
        +String name
        +feed(Animal) void
    }

    Animal <|-- Dog : 继承
    Animal <|-- Cat : 继承
    Owner "1" o-- "*" Animal : 饲养`
  },
  {
    name: '状态图',
    code: `stateDiagram-v2
    [*] --> 待提交
    待提交 --> 审批中 : 提交申请
    审批中 --> 已通过 : 审批同意
    审批中 --> 已驳回 : 审批拒绝
    已驳回 --> 待提交 : 修改后重新提交
    已通过 --> 已归档 : 超过 30 天
    已归档 --> [*]

    note right of 审批中
        超时 48 小时
        自动提醒审批人
    end note`
  },
  {
    name: '饼图',
    code: `pie showData
    title 各端流量占比
    "移动端" : 526
    "桌面端" : 312
    "小程序" : 187
    "其他" : 45`
  },
  {
    name: 'ER 图',
    code: `erDiagram
    客户 ||--o{ 订单 : 下单
    订单 ||--|{ 订单明细 : 包含
    商品 ||--o{ 订单明细 : 对应

    客户 {
        string 客户编号 PK
        string 姓名
        string 手机号
    }
    订单 {
        string 订单号 PK
        string 客户编号 FK
        datetime 下单时间
        decimal 总金额
    }
    订单明细 {
        string 明细号 PK
        string 订单号 FK
        int 数量
    }`
  }
]

const code = ref(TEMPLATES[0].code)
const svgCode = ref('')
const errorMsg = ref('')
const rendering = ref(false)
const loadingEngine = ref(false)
const exportingPng = ref(false)
const autoRender = ref(true)
const pngScale = ref(2)

let mermaidApi = null
let enginePromise = null
let timer = null
let renderSeq = 0

// 渲染引擎体积较大,改为首次使用时动态加载,避免拖慢首屏
async function ensureEngine() {
  if (mermaidApi) return mermaidApi
  if (enginePromise) return enginePromise

  loadingEngine.value = true
  enginePromise = import('@/vendor/mermaid/index.js')
      .then(mod => {
        mermaidApi = mod.default
        mermaidApi.initialize({
          startOnLoad: false,
          theme: 'default',
          securityLevel: 'loose',
          fontFamily: 'inherit'
        })
        return mermaidApi
      })
      .catch(err => {
        enginePromise = null
        throw err
      })
      .finally(() => {
        loadingEngine.value = false
      })

  return enginePromise
}

async function render() {
  const source = code.value.trim()
  if (!source) {
    svgCode.value = ''
    errorMsg.value = ''
    return
  }

  const seq = ++renderSeq
  rendering.value = true
  try {
    const mermaid = await ensureEngine()
    const {svg} = await mermaid.render('mermaid-preview-' + seq, source)
    // 丢弃过期的渲染结果,避免快速输入时旧结果覆盖新结果
    if (seq !== renderSeq) return
    svgCode.value = svg
    errorMsg.value = ''
  } catch (err) {
    if (seq !== renderSeq) return
    errorMsg.value = (err && (err.str || err.message)) || String(err)
    // mermaid 渲染失败时会在 body 上遗留临时节点,这里主动清理
    document.getElementById('dmermaid-preview-' + seq)?.remove()
    document.getElementById('mermaid-preview-' + seq)?.remove()
  } finally {
    if (seq === renderSeq) rendering.value = false
  }
}

watch(code, () => {
  if (!autoRender.value) return
  clearTimeout(timer)
  timer = setTimeout(render, 500)
})

watch(autoRender, val => {
  if (val) render()
})

function applyTemplate(tpl) {
  code.value = tpl.code
  if (!autoRender.value) render()
}

/* ------------------------- 导出 ------------------------- */

function download(blob, filename) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

function timestamp() {
  const d = new Date()
  const p = n => String(n).padStart(2, '0')
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}

function exportSvg() {
  if (!svgCode.value) return
  download(new Blob([svgCode.value], {type: 'image/svg+xml;charset=utf-8'}), `mermaid-${timestamp()}.svg`)
}

// 读取 SVG 的固有尺寸:优先 viewBox,其次 width/height 属性
function measureSvg(svgEl) {
  const viewBox = svgEl.getAttribute('viewBox')
  if (viewBox) {
    const parts = viewBox.split(/[\s,]+/).map(Number)
    if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
      return {width: parts[2], height: parts[3]}
    }
  }
  const w = parseFloat(svgEl.getAttribute('width'))
  const h = parseFloat(svgEl.getAttribute('height'))
  if (w > 0 && h > 0) return {width: w, height: h}
  return {width: 1200, height: 800}
}

async function exportPng() {
  if (!svgCode.value) return
  exportingPng.value = true
  try {
    const doc = new DOMParser().parseFromString(svgCode.value, 'image/svg+xml')
    const svgEl = doc.documentElement
    const {width, height} = measureSvg(svgEl)

    // 显式写死尺寸并补上白色底,避免 PNG 透明背景与尺寸丢失
    svgEl.setAttribute('width', String(width))
    svgEl.setAttribute('height', String(height))
    svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
    svgEl.style.removeProperty('max-width')

    const source = new XMLSerializer().serializeToString(svgEl)
    const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source)

    const img = await new Promise((resolve, reject) => {
      const image = new Image()
      image.onload = () => resolve(image)
      image.onerror = () => reject(new Error('图形转换失败,请尝试导出 SVG'))
      image.src = dataUrl
    })

    const scale = pngScale.value
    const canvas = document.createElement('canvas')
    canvas.width = Math.max(1, Math.round(width * scale))
    canvas.height = Math.max(1, Math.round(height * scale))
    const ctx = canvas.getContext('2d')
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height)

    const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'))
    if (!blob) throw new Error('生成 PNG 失败')
    download(blob, `mermaid-${timestamp()}.png`)
    ElMessage.success(`已导出 ${canvas.width} × ${canvas.height} 的 PNG`)
  } catch (err) {
    ElMessage.error(err.message || '导出失败')
  } finally {
    exportingPng.value = false
  }
}

onMounted(render)
onBeforeUnmount(() => clearTimeout(timer))

2.3 效果截图

Mermaid图表编辑器 效果截图

工具访问地址:https://www.i91tools.com/tools/mermaid-editor