三步跑起 Mermaid 本地编辑器:零依赖离线画图工作台的构建与源码解读
【免费下载链接】mermaidGeneration of diagrams like flowcharts or sequence diagrams from text in a similar manner as markdown项目地址: https://gitcode.com/GitHub_Trending/me/mermaid
这是 Mermaid 官方仓库内置的 Mermaid 本地编辑器:零依赖、一条命令构建,业务代码不足十个文件,离线即可在浏览器里画图、存图、导出 SVG。读完你能独立复现整条构建链,并照这套模式搭出自己的离线画图台。
幕一:三步跑起来 🚀
一条命令背后的五步构建链
在仓库根目录敲下:
pnpm build:mermaid:full操作就这么多。真正的工序由根 package.json 里这条脚本串起来:
pnpm clean && pnpm build:mermaid && pnpm copy:editor && pnpm copy:mermaid && pnpm copy:dompurify && pnpm serve:dist五段各自分工:
| 顺序 | 脚本 | 实际命令 | 作用 |
|---|---|---|---|
| 1 | clean | rimraf packages/mermaid/dist | 清掉上次产物,保证从零开始 |
| 2 | build:mermaid | pnpm build:esbuild --mermaid | 走仓库统一的 esbuild 管线编译出mermaid.min.js |
| 3 | copy:editor | cpy "packages/mermaid-local-editor/static/**/*" … | 把编辑器页面源码原样搬进输出目录 |
| 4 | copy:mermaid/copy:dompurify | 两条cpy | 将mermaid.min.js、purify.min.js收进vendor/ |
| 5 | serve:dist | sirv packages/mermaid/dist/mermaid-local-editor --port 8081 --dev --no-clear | 静态托管产物 |
人话版:先编译、再搬页面、最后把两个运行时依赖塞进同一个文件夹,起个静态服务器收工。其中copy:dompurify的路径写成node_modules/.pnpm/dompurify@*/…/purify.min.js,是因为 pnpm 把依赖摊在.pnpm存储目录里,只能用通配符去捞这份预构建文件。
打开 localhost:8081 看到什么
浏览器打开http://localhost:8081:顶部一条工具栏——折叠按钮、图表下拉框、名称输入框,外加 Save / New / Delete / Reset View / Export SVG 五个操作按钮;下面左边是源码文本区,右边是渲染预览。往左边输入 Mermaid 语法,右边立刻出现图,全程没有任何网络请求。产物是纯静态文件,拷到别的机器、内网服务器照样能完成 Mermaid 离线使用。
构建产物目录树
跑完之后,可独立运行的目录长这样:
packages/mermaid/dist/mermaid-local-editor/ ├── index.html ├── styles.css ├── app.js ├── js/ │ ├── config.js │ ├── renderer.js │ ├── storage.js │ ├── ui.js │ └── navigation.js └── vendor/ ├── mermaid.min.js └── purify.min.jsvendor/里两个文件正好对上 index.html 中的两条<script>,页面只走相对路径。README 的定位就是"直接从dist/里跑",理论上连服务器这一步都能省掉。
幕二:打开代码之前,先看地图 🗺️
static/ 目录树与五个 js 模块职责
源码集中在 packages/mermaid-local-editor/static/:
static/ ├── index.html # 页面骨架与工具栏 ├── styles.css # 界面样式 ├── app.js # 唯一入口,装配各模块 └── js/ ├── config.js ├── renderer.js ├── storage.js ├── ui.js └── navigation.js五个模块各管一摊,调用关系一目了然:
| 模块 | 一句话职责 | 被谁调用 |
|---|---|---|
js/config.js | 导出initMermaid初始化参数、共享视图状态state、IS_E2E开关 | app.js |
js/renderer.js | renderDiagram:渲染 SVG、净化、写入 iframe、绑定缩放平移 | app.js的render() |
js/storage.js | createStorage:管理两个 localStorage key 的读写 | app.js、ui.js |
js/ui.js | setupUI/refreshList:工具栏按钮、下拉切换、300ms 自动保存、SVG 导出 | app.js |
js/navigation.js | createNavigation:键盘在节点间逐跳导航 | app.js,渲染完成后由renderer.js回调rebuildNavNodes |
app.js 的五步装配顺序
app.js 顶层流程是一条直线:
initMermaid()—— 按编辑器偏好初始化运行时,参数详解留到下一幕;createStorage()—— 建立存储层,首次访问时自动生成main图(幕四展开);createNavigation({ state, preview, srcPanel, applyTransform })—— 把共享状态和视图变换函数注入导航模块;setupUI({...})—— 绑完全部工具栏事件;navigation.setupKeyboardNav()接load(storage.current)—— 载入当前图并首渲。
三个本地函数撑起骨架:render()调渲染器;load(name)负责"从存储读源 → 写进文本区 → 恢复 scale/panX/panY → 触发渲染";applyTransform()把视图状态落到 SVG 的 style 上,并顺手同步回存储。先配运行时、再建存储、最后才首渲——这个顺序保证load执行时所有依赖已就位。
幕三:一行源码如何变成可交互的 SVG
四个初始化参数,改一行会发生什么
js/config.js 只有 24 行,initMermaid()里四个参数就是四道闸门:
startOnLoad: false:去掉这行,Mermaid 会在页面加载时自动扫描class="mermaid"的元素并渲染。而本编辑器的渲染入口只有左侧文本区,靠 JS 手动触发,自动启动开着只会打架。securityLevel: 'strict':改成loose会放开图内点击跳转等交互能力。输入源是用户随手敲的文本,解析这一关就把门打开,后面几道过滤压力全变大(幕五细说)。deterministicIds: true:元素 id 跨渲染保持稳定。键盘导航模块靠稳定的g.node收集节点列表,一旦 id 每次渲染都变,已收集的列表立刻作废。flowchart.useMaxWidth: false:若为true,流程图 SVG 会按容器宽度自适应缩放,你放大到三倍,布局又把它压回容器尺寸,缩放手感"抓不住"。
其余配置(theme: 'dark'、fontFamily: 'Arial'、htmlLabels: false)是外观偏好,不碰主流程。同文件还导出了IS_E2E(navigator.webdriver或 URL 带graph=参数时为真)和全局共享的state = { scale, panX, panY, iframeRef },后者是渲染器、导航、UI 三方共用的"视图状态单例"。
渲染管线:mermaid.render 之后经过哪五站
js/renderer.js 的renderDiagram()按时间线走完五站:
- 渲染:
await mermaid.render(id, srcValue)把源码编译成 SVG 字符串;id 常规模式取Date.now()防连续渲染冲突,E2E 模式固定m1。 - 净化:
DOMPurify.sanitize(svg, { ADD_TAGS: ['foreignObject'], ADD_ATTR: ['xmlns'] })。为什么单独放行这两个?Mermaid 的文本排版会用到<foreignObject>,SVG 根节点离开xmlns无法被当作独立文档解析——两者都在 DOMPurify 默认拦截名单里,不显式加白就出不来图;其余危险节点照旧被剥掉。 - 二次加固:遍历所有节点,删除以
on开头的属性(onclick、onload等)。净化器放行的是标签与命名空间,事件类残留属性由这一遍兜底。 - 沙箱写入:创建
<iframe>,sandbox只给allow-same-origin,不给allow-scripts,再importNode把 SVG 搬进 iframe 文档。这个框里最多"展示",不能"执行"。 - 样式注入:向 iframe 写一段
<style>,定义 hover 蓝色光晕、.selected-node蓝色描边加发光、浅色文字、body { overflow: hidden }。键盘导航的高亮、鼠标悬停的反馈全靠它。
一句话:源码进、SVG 字符串出,中途过一道白名单过滤,再关进无脚本的框里,最后贴上交互皮肤。
支线一:缩放、平移与 E2E 直出
- 滚轮缩放:
state.scale += e.deltaY * -0.0015,随后钳制在0.2~4倍,防止放大到糊掉或缩到看不见; - 拖拽平移:
mousedown/mousemove/mouseup三件套,拖拽中光标切grabbing; applyTransform()(在 app.js)把translate(panX, panY) scale(scale)写到 SVG 上,平移量额外钳在±20000px,并把view同步写回存储——视图位置随图保存;- E2E 分支:
IS_E2E为真时跳过 iframe,净化后的 SVG 经DOMParser直接塞进#preview就返回。自动化截图不必等 iframe 布局,断言与截图都能直接对着 SVG 做。
支线二:错误不白屏
整段流程套在 try/catch 里:抛异常就清空预览区,用红色<pre>显示e.message。输入是自由文本,语法错误是常态,右侧一条清晰的报错比白屏加一屏控制台警告有用得多。
幕四:关掉页面,数据还在 💾
localStorage 双 key 持久化结构
js/storage.js 只依赖两个 key,用 localStorage 保存全部图表:
| Key | 值形态 | 含义 |
|---|---|---|
mermaid-diagrams | JSON 对象{ [图名]: { src, view } } | 所有图表;src是 Mermaid 源码,view是{ scale, panX, panY } |
mermaid-current | 字符串,默认main | 当前选中的图名 |
首次访问发现main不存在,自动补一条默认图:
diagrams[current] = { src: `flowchart LR\n UI --> RuntimeBus --> Orchestrator --> Agents`, view: { scale: 1, panX: 0, panY: 0 }, };这就是开箱即用的来源:打开就有图。注意view是按图存的,缩放和平移位置跟着图走,切走再切回来,现场原样。
四个按钮各自的存储行为
| 按钮 | 存储侧动作 |
|---|---|
| Save | 取#name输入框的值当当前图名,写入src与当前视图,刷新下拉框 |
| New | 弹窗要名字,写入默认内容flowchart LR\n A --> B,当前图切过去 |
| Delete | confirm确认后删当前图,回落到剩余第一张或main |
| 下拉切换 | setCurrent记新当前图,重新走load |
300ms 防抖自动保存是怎么实现的
文本区每次input事件:先无条件调render()刷新预览(渲染要实时),再clearTimeout + setTimeout(300)把最新源码落盘。连续打字时定时器反复重置,只算最后一次写。所以"忘了点 Save"也不会丢多少东西——防抖把高频输入收敛成一次持久化。
SVG 导出与视图重置
- Reset View:
scale/panX/panY归位1/0/0后调applyTransform(),后者会把重置后的视图存回去——"重置"这个动作本身也被持久化。 - Export SVG:从 iframe 取出当前 SVG,
new Blob([svg.outerHTML], { type: 'image/svg+xml' })→URL.createObjectURL生成临时地址 → 挂到隐藏<a download="图名.svg">上点击 →revokeObjectURL释放。下载全程在客户端完成,不经过任何上传。
另外还有个toolbar-collapsedkey('1'/'0')记住工具栏折叠状态,属于界面偏好记忆,与图表数据无关。
幕五:没有后端,凭什么敢直接渲染用户输入 🛡️
前面几幕摆完事实,这幕回答一个问题:为什么"把用户输入直接喂给浏览器渲染"这件事成立。
把数据流拆成输入、解析、输出、运行四段,每段设防:
- 解析端——Mermaid 配置层。
securityLevel: 'strict'让点击导航、脚本类能力在解析阶段就不可用;deterministicIds顺带稳住元素 id。若在这层松手,SVG 字符串天然带上可执行语义,下游过滤成本成倍上涨。 - 输出端——净化层。所有 SVG 字符串统一过
DOMPurify.sanitize,白名单只加回foreignObject与xmlns,再叠加on*属性剥离。净化器管标签与属性,遍历管事件类残留,两头不重不漏。 - 运行端——沙箱层。最终 SVG 落进只授
allow-same-origin的 iframe。即便前两层漏下残余载荷,这个框里没有脚本执行权限,也摸不到主页面 JS 上下文。
至于"完全离线、不做服务端校验为何依然成立":服务端校验解决的是"可信服务器 ↔ 不可信传输链路"的问题;这里输入和执行环境本来就在同一个浏览器里,等价的"服务端"就是你自己搭的 sandbox iframe。三道防线把不可信内容拦在"可执行领地"之外,中间不需要任何网络环节——离线不是省事的妥协,而是这条链路本来就够短。
幕六:拿它当模板 🔧
键盘节点导航的三个可借鉴细节
js/navigation.js 体量不大,细节值得抄进自己的项目:
- 只用两个键:
ArrowDown下一节点、ArrowUp上一节点。节点列表来自 iframe 内全部g.node,每次渲染结束由rebuildNavNodes回调重新收集并重置索引,避免操作失效的旧节点。 - 居中偏移取 1/6 而非 1/2:
centerCurrentNode里"中心"按top + height / 6计算。严格居中会让焦点节点恰好顶到画面上缘,LR/TD 布局下后续节点直接掉出视野;取 1/6 把焦点压向画布左上,"下一步"仍在可视区里,连续下键体验才顺。 - 编辑时不劫持按键:事件处理先判断焦点是否在源码文本区,是则直接 return。代码输入优先用键盘,导航只在非输入态接管。
高亮本身由 renderer 注入的 CSS(.selected-node蓝框加发光)呈现,导航模块只负责切换 class——样式与逻辑分家,两边可独立改。
二次开发:可裁剪点与可迁移模式
子包业务代码合计就八个文件,入口只有一个app.js,裁剪点很明显:
- 删导航:
js/navigation.js及 app.js 里两处调用,主流程无感; - 换存储层:
createStorage的返回值只被用到setCurrent / updateCurrent / deleteCurrent / create四个方法,换成 IndexedDB 或文件方案只需对齐这几个签名; - 换主题:
theme: 'dark'与注入 iframe 的那段 CSS 是仅有的两处外观相关代码。
可迁移的模式更短:构建期把mermaid.min.js和purify.min.js打进vendor/;运行期走"渲染 → 净化 → 沙箱 → 持久化"一整圈。凡是需要离线画图、内网工具嵌入图表渲染的场景,照这个装配顺序就能复刻。
它在 monorepo 构建与测试体系中的位置
根 package.json 把整条构建链固化成build:mermaid:full一条命令,CI 或本地都可一键复现;e2e 范围脚本 e2e-diagram-scope.mjs 显式列出了packages/mermaid-local-editor/路径,涉及该包的改动会同步触发对应端到端测试。根lint命令对整个仓库跑 ESLint,这份源码也在同一套静态检查范围内。
【免费下载链接】mermaidGeneration of diagrams like flowcharts or sequence diagrams from text in a similar manner as markdown项目地址: https://gitcode.com/GitHub_Trending/me/mermaid
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考