Appearance
实现 Mermaid图表编辑器 工具
分类:developer | 标签:Mermaid、流程图、图表 Mermaid 语法实时渲染流程图/时序图/甘特图,导出 SVG/PNG
2.1 功能说明
Mermaid 语法实时渲染流程图/时序图/甘特图,导出 SVG/PNG
2.1.1 使用指南
功能说明
用文本语法描述图表,右侧实时渲染成矢量图。支持流程图、时序图、甘特图、类图、状态图、饼图、实体关系图等 Mermaid 全部常用图形,渲染完全在浏览器本地完成,代码不会上传。
快速上手
第一行声明图表类型,例如 graph TD(自上而下的流程图)、sequenceDiagram(时序图)、gantt(甘特图)。不确定语法时,先点击上方模板按钮填入示例,再在示例基础上修改。
流程图方向可选 TD(上下)、LR(左右)、BT、RL。节点形状由括号决定:[] 矩形、() 圆角、{} 菱形判断、(()) 圆形。
连线写法: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-input、el-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 效果截图
