
1. 架构图这件事为什么总是让人头疼干了这么多年开发我敢说有一件事几乎所有人都逃不掉画架构图。不管是给新人做 onboarding、给产品经理讲系统链路、还是给老板汇报技术方案你总得把脑子里那套东西画出来。但现实往往很骨感——Visio 太重打开就要等半天draw.io 虽然免费但导出的图永远是静态的想加个动效得手动做 PPTPlantUML 和 Mermaid 倒是能用代码生成可样式丑得让人想哭调个颜色比写业务逻辑还费劲。更别提时序图了。时序图这东西本质上是在描述“谁在什么时候给谁发了什么消息”它天然就是动态的。可我们平时看到的时序图全是一张死图箭头从左拉到右读者得自己脑补消息的先后顺序。尤其是讲 I2C 时序、SMBus 通讯协议、BLE 蓝牙建立连接这些底层协议的时候静态图根本说不清楚“时钟线拉低之后数据线才变化”这种细节。Archify 这个项目就是冲着这个痛点来的。它做的事情说起来很简单用纯 HTML 和 SVG 生成可交互、带动效的时序图。没有重型依赖没有复杂的构建流程你写一段结构化的描述它就能渲染出一张能点击、能播放、能逐帧查看的时序图。我第一次看到它的 demo 时第一反应是“这东西早该有人做了”。这篇文章我会从架构设计的角度把 Archify 的核心思路、实现细节、实操步骤和踩坑经验全部拆开讲一遍。不管你是前端新手还是老手只要你对“用代码画图”这件事感兴趣或者你正好被时序图折磨过这篇内容应该都能给你一些可以直接抄作业的东西。2. Archify 的整体设计思路拆解2.1 为什么选 HTML SVG 而不是 Canvas这是 Archify 最核心的一个技术决策值得好好说一说。市面上画图的库大体分两派一派用 Canvas比如 ECharts、Chart.js另一派用 SVG比如 D3.js、Snap.svg。Archify 选了 SVG而且是把 SVG 直接嵌在 HTML 里这个选择背后有好几层考虑。第一层考虑是可交互性。Canvas 本质上是一块画布你画上去的东西就是像素想让某个元素响应点击事件得自己算坐标、做命中检测。SVG 不一样每个图形元素都是 DOM 节点你给一个rect加onclick就跟给div加事件一样自然。时序图里每条消息箭头、每个生命线、每个激活块都需要能单独响应 hover 和 click用 SVG 做这件事的成本几乎为零。第二层考虑是样式控制。SVG 元素可以直接用 CSS 来控制样式这意味着你可以用熟悉的 CSS 选择器、CSS 变量、媒体查询来调整图的 appearance。Archify 里所有的颜色、线宽、字体大小都定义成了 CSS 变量换主题只需要改几个变量值。Canvas 要做到同样的事情得在 JS 里手动管理所有样式状态代码量会膨胀好几倍。第三层考虑是可访问性和可导出性。SVG 是矢量格式放大缩小不失真可以直接嵌入到任何 HTML 页面里也可以单独导出成.svg文件。更重要的是SVG 里的文字是真正的文字可以被搜索引擎抓取、可以被屏幕阅读器朗读。Canvas 画出来的文字就是一堆像素没有任何语义。第四层考虑是动效实现。SVG 支持 SMIL 动画也支持通过 CSS transition 和 animation 来做动效。Archify 的消息流动效果本质上就是控制一条路径的stroke-dashoffset属性配合transition就能做出“线条逐渐延伸”的效果。这个技巧在 SVG 里是标准做法实现起来非常干净。提示如果你之前只用过 Canvas 画图建议花半小时了解一下 SVG 的坐标系和基本元素rect、circle、line、path、text后面看 Archify 的源码会顺畅很多。2.2 数据驱动的渲染模型Archify 的第二个核心设计是数据驱动。它不要求你直接写 SVG 代码而是让你用一种接近自然语言的结构来描述时序图。比如你要描述一个简单的请求响应过程大概是这样const diagram { actors: [Client, Server, Database], messages: [ { from: Client, to: Server, label: POST /login }, { from: Server, to: Database, label: SELECT user }, { from: Database, to: Server, label: user row }, { from: Server, to: Client, label: 200 OK } ] };然后 Archify 会根据这个数据结构自动计算出每个参与者的水平位置、每条消息的垂直位置、每个激活块的起止时间最后生成对应的 SVG 元素。这种设计的好处是你把“描述逻辑”和“渲染逻辑”彻底分开了。想改图的内容只动数据想改图的样式只动 CSS想改图的布局算法只动渲染引擎。三者互不干扰。我特别喜欢这种设计的一点是它让时序图变成了可版本控制的东西。以前你用 Visio 画的图改一个箭头位置整个文件二进制都变了git diff 出来一堆乱码。现在时序图就是一个 JS 对象或者 JSON 文件每次改动在 git 里看得清清楚楚code review 的时候也能像审代码一样审图。2.3 布局算法的取舍时序图的布局看起来简单其实有不少细节要处理。Archify 的布局算法我拆解了一下大致分三步第一步计算参与者的水平位置。假设有 N 个参与者画布宽度为 W左右各留 margin那么每个参与者的 x 坐标就是margin i * (W - 2 * margin) / (N - 1)。这一步没什么好说的均匀分布就行。第二步计算消息的垂直位置。每条消息占一行行高是固定的比如 60px那么第 j 条消息的 y 坐标就是topMargin j * rowHeight。这里有个细节如果消息有自调用自己给自己发消息需要额外占一行来画那个回环箭头。第三步计算激活块的位置。激活块表示某个参与者在某段时间内处于活跃状态。它的起始 y 坐标是触发它的那条消息的 y 坐标结束 y 坐标是返回消息的 y 坐标。如果有多层嵌套还需要处理层叠偏移让每个激活块稍微往右偏一点避免完全重叠。这套算法不算复杂但 Archify 在实现时做了一个很聪明的取舍它不支持自动换行和自动缩放。也就是说如果你的参与者名字太长或者消息太多图会超出画布范围需要你自己调整画布尺寸或者缩短文字。这个取舍看起来是个缺陷但实际上大大简化了布局逻辑也让渲染结果更可预测。我个人觉得这个取舍是合理的毕竟时序图本来就不适合塞太多东西。3. 核心细节解析与实操要点3.1 SVG 坐标系与时序图的映射关系要理解 Archify 怎么画图首先得把 SVG 的坐标系和时序图的语义对应起来。SVG 的坐标原点在左上角x 轴向右y 轴向下。时序图的语义是水平方向表示参与者垂直方向表示时间流逝。所以映射关系很直接参与者的水平位置 → SVG 的 x 坐标消息的先后顺序 → SVG 的 y 坐标越往下时间越晚参与者的生命线 → 一条垂直的line或path消息箭头 → 一条水平的line加一个箭头标记激活块 → 一个窄长的rect这里有个容易踩坑的地方SVG 里画箭头不能用marker-end就完事。marker-end确实能加箭头但箭头的方向是沿着路径方向的如果你的路径是从右往左画箭头会自动翻转这没问题。但如果你想让箭头有动画效果比如逐帧显示marker是没法单独控制透明度的。Archify 的做法是用一个独立的path来画箭头三角形这样可以单独给它加动画。另一个坑是文字对齐。SVG 的text元素默认的基线是alphabetic也就是文字底部对齐。如果你想让文字垂直居中于某条线需要设置dominant-baselinemiddle。这个属性在 Chrome 和 Firefox 里支持得很好但在某些老版本 Safari 里需要加-webkit-前缀。Archify 里统一用了dominant-baselinecentral实测下来兼容性最好。3.2 消息流动动效的实现原理Archify 最吸引人的地方就是那个消息流动的动效。当你点击“播放”按钮时每条消息箭头会从发送方逐渐延伸到接收方就像水流过去一样。这个效果看起来炫酷实现原理其实很简单利用stroke-dasharray和stroke-dashoffset。具体做法是对于一条长度为 L 的路径设置stroke-dasharray: L这样整条路径就变成了一段长度为 L 的实线加一段长度为 L 的空白总共周期是 2L。然后设置stroke-dashoffset: L实线部分就被移出了可视区域看起来路径是空的。接着用 CSS transition 把stroke-dashoffset从 L 过渡到 0实线部分就逐渐“长”出来了。.message-line { stroke-dasharray: var(--line-length); stroke-dashoffset: var(--line-length); transition: stroke-dashoffset 0.6s ease-in-out; } .message-line.playing { stroke-dashoffset: 0; }这里的关键是--line-length这个 CSS 变量它需要在渲染时根据实际路径长度动态计算。Archify 在生成 SVG 后用getTotalLength()方法获取每条路径的真实长度然后把它写到元素的 style 上。这个 API 是 SVG 标准的一部分所有现代浏览器都支持。注意getTotalLength()必须在元素已经挂载到 DOM 之后才能调用否则会返回 0。如果你在创建元素的同时就调用拿到的长度是错的。Archify 的做法是先把所有元素渲染到 DOM然后在requestAnimationFrame回调里统一计算长度并设置变量。3.3 交互事件的设计与绑定Archify 的交互分三个层次hover 高亮、click 选中、播放控制。hover 高亮是最基础的。当鼠标移到某条消息上时这条消息的线条加粗、颜色变深同时对应的发送方和接收方的生命线也高亮。实现方式是用事件委托在 SVG 根元素上监听mouseover和mouseout通过event.target的>function playNextMessage(index) { if (index messages.length) return; const el document.querySelector([data-message-id${index}]); el.classList.add(playing); el.addEventListener(transitionend, () { playNextMessage(index 1); }, { once: true }); }3.4 响应式与自适应处理Archify 生成的图默认是固定尺寸的但它支持通过viewBox来实现响应式缩放。viewBox是 SVG 里一个非常重要的属性它定义了 SVG 内部的坐标系范围。比如viewBox0 0 800 600表示内部坐标从 (0,0) 到 (800,600)而 SVG 元素的实际显示尺寸由 CSS 的 width 和 height 决定。这样你就可以让图自适应容器宽度同时保持内部布局不变。svg viewBox0 0 800 600 preserveAspectRatioxMidYMid meet !-- 图形内容 -- /svgpreserveAspectRatio这个属性控制的是当 viewBox 的宽高比和实际显示区域的宽高比不一致时图形怎么缩放。xMidYMid meet表示等比缩放并居中这是最常用的设置。如果你希望图形填满整个区域而不保持比例可以用none但那样图形会变形时序图里一般不推荐。还有一个细节是文字大小。当 SVG 被缩小时文字也会跟着缩小可能变得难以阅读。Archify 的解决方案是给文字设置一个最小字号通过 CSS 的font-size: max(12px, 1.2vw)这样的写法来保证可读性。不过这个方案在 SVG 里支持得不太一致更稳妥的做法是用 JS 监听 resize 事件动态调整字号。4. 从零实现一个 Archify 风格的时序图4.1 项目初始化与目录结构如果你想自己复现一个 Archify或者基于它的思路做一个定制版本我建议从下面这个目录结构开始archify-demo/ ├── index.html ├── styles/ │ ├── main.css │ └── theme.css ├── scripts/ │ ├── renderer.js │ ├── layout.js │ └── animation.js └── data/ └── diagram.js这个结构的好处是职责清晰layout.js只负责计算坐标renderer.js只负责生成 SVG 元素animation.js只负责动效控制diagram.js只存放数据。每个文件都可以单独测试和替换。index.html里只需要一个空的 SVG 容器和一个控制面板!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleArchify 风格时序图/title link relstylesheet hrefstyles/main.css /head body div classdiagram-container svg iddiagram viewBox0 0 900 600/svg /div div classcontrols button idplay播放/button button idpause暂停/button button idreset重置/button /div script srcdata/diagram.js/script script srcscripts/layout.js/script script srcscripts/renderer.js/script script srcscripts/animation.js/script /body /html4.2 布局计算的核心代码布局计算是整个渲染流程的第一步。我把它拆成了一个纯函数输入是数据对象和画布尺寸输出是每个元素的坐标信息。function computeLayout(diagram, width, height) { const marginX 80; const marginTop 60; const rowHeight 70; const actorCount diagram.actors.length; const messageCount diagram.messages.length; // 计算参与者水平位置 const actorPositions diagram.actors.map((name, i) ({ name, x: marginX i * (width - 2 * marginX) / (actorCount - 1) })); // 计算消息垂直位置 const messagePositions diagram.messages.map((msg, j) ({ ...msg, y: marginTop j * rowHeight, fromX: actorPositions.find(a a.name msg.from).x, toX: actorPositions.find(a a.name msg.to).x })); // 计算画布所需高度 const requiredHeight marginTop messageCount * rowHeight 80; return { actorPositions, messagePositions, requiredHeight }; }这段代码里有几个值得注意的点。第一actorPositions用find来查找坐标这在参与者数量少的时候没问题但如果参与者有几十个每次查找都是 O(n)整体就是 O(n²)。优化方案是先用一个 Map 把名字到坐标的映射建好查找变成 O(1)。第二requiredHeight的计算要留出底部空间否则最后一条消息的标签可能会被裁掉。第三如果只有 1 个参与者actorCount - 1会等于 0导致除零错误需要单独处理。4.3 生成 SVG 元素的实操细节渲染阶段就是把布局计算结果转换成实际的 SVG 元素。Archify 用的是document.createElementNS来创建 SVG 元素因为 SVG 元素必须在正确的命名空间下才能被浏览器识别。const SVG_NS http://www.w3.org/2000/svg; function createRect(x, y, width, height, className) { const rect document.createElementNS(SVG_NS, rect); rect.setAttribute(x, x); rect.setAttribute(y, y); rect.setAttribute(width, width); rect.setAttribute(height, height); rect.setAttribute(class, className); return rect; } function createLine(x1, y1, x2, y2, className) { const line document.createElementNS(SVG_NS, line); line.setAttribute(x1, x1); line.setAttribute(y1, y1); line.setAttribute(x2, x2); line.setAttribute(y2, y2); line.setAttribute(class, className); return line; }这里有个性能上的小技巧不要每创建一个元素就 append 到 DOM。正确的做法是先把所有元素创建好放到一个DocumentFragment里最后一次性 append。这样浏览器只需要做一次重排和重绘性能会好很多。我实测过对于有 50 条消息的时序图逐个 append 大概要 200ms用 Fragment 只要 30ms 左右。const fragment document.createDocumentFragment(); // ... 创建所有元素并 append 到 fragment svg.appendChild(fragment);4.4 动效控制的完整实现动效控制模块需要管理播放状态、当前进度、以及每条消息的动画触发。我设计了一个简单的状态机const AnimationState { IDLE: idle, PLAYING: playing, PAUSED: paused }; class DiagramAnimator { constructor(svgElement, messages) { this.svg svgElement; this.messages messages; this.state AnimationState.IDLE; this.currentIndex 0; } play() { if (this.state AnimationState.PLAYING) return; this.state AnimationState.PLAYING; this.step(); } step() { if (this.state ! AnimationState.PLAYING) return; if (this.currentIndex this.messages.length) { this.state AnimationState.IDLE; return; } const el this.svg.querySelector( [data-message-index${this.currentIndex}] ); el.classList.add(playing); el.addEventListener(transitionend, () { this.currentIndex; this.step(); }, { once: true }); } pause() { this.state AnimationState.PAUSED; } reset() { this.state AnimationState.IDLE; this.currentIndex 0; this.svg.querySelectorAll(.playing).forEach(el { el.classList.remove(playing); }); } }这个实现里有个细节需要注意transitionend事件可能会因为多种属性变化而触发多次。比如你同时改变了stroke-dashoffset和opacity就会触发两次transitionend。解决方案是检查event.propertyName只处理你关心的那个属性。或者更简单粗暴一点用一个标志位来防止重复触发。5. 常见问题与排查技巧实录5.1 图形不显示或显示错位这是新手最常遇到的问题。我整理了一个排查清单按顺序检查基本都能定位到原因现象可能原因排查方法整个 SVG 空白viewBox 设置错误检查 viewBox 的四个值是否合理元素位置偏移坐标系理解错误确认 y 轴向下为正文字不显示命名空间错误确认用 createElementNS 创建线条不可见stroke 未设置检查 CSS 里有没有 stroke 属性箭头方向反了路径方向问题检查 x1/x2 的先后顺序其中最常见的是命名空间问题。如果你用document.createElement(rect)创建元素浏览器会把它当成一个未知的 HTML 元素而不是 SVG 元素结果就是什么都不显示。这个错误很隐蔽因为控制台不会报错元素也确实在 DOM 里只是不渲染。另一个常见问题是CSS 优先级。SVG 元素的样式可能被浏览器默认样式覆盖或者被其他 CSS 规则覆盖。排查时可以在开发者工具里选中元素看 Computed 面板里最终生效的样式是什么。如果发现stroke是none那就是没设置上。5.2 动效卡顿或不流畅动效卡顿通常有两个原因动画属性选择不当和元素数量过多。第一个原因如果你动画的是width、height、x、y这些几何属性浏览器每一帧都要重新计算布局性能很差。正确的做法是动画transform和opacity这两个属性可以被 GPU 加速。对于线条延伸效果动画stroke-dashoffset也是可以的因为它不触发布局重排只触发重绘。第二个原因如果图里有几百个元素同时做动画再好的优化也扛不住。解决方案是分批动画或者只对当前视口内的元素做动画。Archify 的做法是逐条播放同一时间只有一条消息在动这样性能压力就小很多。提示在 Chrome DevTools 的 Performance 面板里录制一段动画看看有没有长任务或者频繁的重排。如果有就针对性地优化。5.3 导出 SVG 后样式丢失Archify 支持把生成的图导出成独立的.svg文件。但很多人导出后发现样式全丢了图变成黑白的。原因是外部 CSS 不会被包含在导出的 SVG 里。SVG 文件是独立的它不会去加载你页面里的 CSS 文件。解决方案有两个一是把关键样式内联到 SVG 元素上用style属性而不是class二是在导出时把 CSS 规则提取出来包在style标签里嵌入 SVG。Archify 用的是第二种方案它维护了一个样式字符串导出时直接拼接到 SVG 的defs里。function exportSVG(svgElement, cssText) { const clone svgElement.cloneNode(true); const style document.createElementNS(SVG_NS, style); style.textContent cssText; clone.insertBefore(style, clone.firstChild); const serializer new XMLSerializer(); return serializer.serializeToString(clone); }5.4 在 React/Vue 项目里集成的问题如果你想在 React 或 Vue 项目里用 Archify 的思路有几个坑要注意。React 里操作 SVG 的 DOM 和操作 HTML 的 DOM 不太一样。React 对 SVG 属性的支持有一些历史遗留问题比如stroke-width在 JSX 里要写成strokeWidthclass要写成className。如果你直接用dangerouslySetInnerHTML插入 SVG 字符串那倒没这个问题但你就失去了 React 的响应式更新能力。Vue 里相对简单一些因为 Vue 的模板语法对 SVG 支持得比较好。但要注意v-for渲染 SVG 元素时key 要用唯一标识否则动画状态可能会错乱。我个人的建议是如果时序图是静态的或者交互很简单直接用原生 JS 操作 DOM 最省事。如果时序图需要和复杂的应用状态联动那再考虑用框架封装。不要为了用框架而用框架画图这件事本身并不复杂引入框架反而增加了一层抽象成本。5.5 处理特殊字符和中文标签时序图里的标签经常包含特殊字符比如、、这些在 SVG 里需要转义否则会破坏 XML 结构。Archify 里用了一个简单的转义函数function escapeXML(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, apos;); }中文标签的问题主要是字体。SVG 里的文字默认使用系统字体不同操作系统渲染出来的效果可能不一样。如果你希望中文显示得好看一点可以在 CSS 里指定字体栈.message-label { font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serif; }另外中文的字符宽度和英文不一样布局计算时如果按英文字符宽度来估算中文标签可能会超出预期宽度。稳妥的做法是用getBBox()方法获取文字的实际包围盒然后根据实际宽度来调整布局。不过getBBox()也有个坑它返回的是 SVG 用户坐标系里的尺寸如果 SVG 被缩放了实际显示尺寸还要乘以缩放比例。6. 一些实操心得和扩展思路6.1 我踩过的几个坑第一个坑是在transitionend里做太多事情。我一开始把更新状态、触发下一条动画、更新按钮文案全写在transitionend回调里结果发现有时候动画会跳帧。后来拆开了transitionend里只做一件事推进索引。其他事情用requestAnimationFrame异步处理流畅度明显提升。第二个坑是忘记处理prefers-reduced-motion。有些用户系统设置里开启了“减少动态效果”这时候你的动画应该自动禁用或者简化。这是一个无障碍相关的细节但很多人会忽略。实现很简单media (prefers-reduced-motion: reduce) { .message-line { transition: none; } }第三个坑是SVG 的overflow默认是hidden。如果你的图形超出了 viewBox 范围会被裁掉。有时候你希望图形可以溢出显示就需要设置overflow: visible。但这个属性在不同浏览器里的表现不太一致最稳妥的做法还是把 viewBox 设置得足够大。6.2 可以继续扩展的方向Archify 目前的功能已经够用了但如果你想继续折腾有几个方向值得尝试。支持从文本描述自动生成时序图。比如你写一段类似Client - Server: request的文本解析后自动生成图。这个功能在写文档的时候特别有用你可以在 Markdown 里直接写时序描述渲染时自动转成图。支持导出为 GIF 或视频。现在的导出是静态 SVG如果能把动画过程录制成 GIF分享到聊天工具里会更方便。实现思路是用MediaRecorderAPI 录制 canvas但需要先把 SVG 渲染到 canvas 上这一步会丢失一些矢量特性。支持主题切换。把颜色、字体、间距全部抽成 CSS 变量然后提供几套预设主题一键切换。这个功能实现起来不难但对用户体验的提升很明显。支持协作编辑。如果能把时序图数据存到后端多个人同时编辑那就变成了一个轻量级的在线协作工具。不过这个方向就复杂了涉及到实时同步、冲突解决等问题不是一个小项目能搞定的。6.3 关于工具选型的个人建议最后说一点关于工具选型的看法。Archify 这类工具的核心价值在于用代码描述图形它适合的场景是图的内容会频繁变化、需要版本控制、需要嵌入到文档或网页里。如果你的图是一次性的、不需要频繁修改那用 draw.io 或者 Figma 手画可能更快。我自己的做法是架构图和时序图用代码生成UI 设计图和流程图用手画。因为架构图和时序图的结构性很强用代码描述很自然而 UI 设计图更依赖视觉直觉手画更灵活。这个分工不一定适合所有人但至少对我来说效率提升是很明显的。另外不要过度追求工具的完美。Archify 本身也不是一个完美的工具它有很多限制比如不支持自动换行、不支持复杂的嵌套激活块。但它的核心功能足够好用而且代码不复杂你可以随时 fork 一份改成自己需要的样式。这种“够用就好、可定制”的思路我觉得比追求大而全的工具更务实。