ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue3 + SVG.js构建电力系统拓扑图:从手写SVG到数据驱动

Vue3 + SVG.js构建电力系统拓扑图:从手写SVG到数据驱动 手搓SVG画电力系统拓扑图这事我大概干过两年。当时在一个电力监控小项目里每个节点、每条线都是rect、path堆出来的坐标全靠人肉算改一处电缆走向就要连带调七八个点的坐标localhost一刷新就是满屏错位。后来换了Vue3 SVG.js这套组合图元封装成函数连线用数据驱动状态刷新一行代码搞定才算是从泥潭里爬了出来。这篇文章就把这套方案的完整思路、核心代码和踩过的坑都写出来给正在被电力拓扑、网络拓扑、SCADA画面这类需求折磨的朋友做个参考。1. 手写SVG做电力拓扑到底难在哪1.1 电力拓扑图的核心诉求和普通流程图完全不一样先掰扯清楚需求。电力系统拓扑图不是简单地画几个方框连几条线它有几个典型特征直接决定了技术选型的方向。第一是图元类型固定但形态多样。发电机、变压器、断路器、隔离开关、母线、电抗器、负荷……每个设备都有行业约定俗成的图形符号而且同一类设备在不同电压等级、不同状态下还有不同画法。第二是状态刷新特别频繁。实时监控场景下断路器要分闸/合闸切换颜色输电线路要正常/故障/检修三色切换有时还要闪烁告警。这个频率可能是几秒钟一次全量更新如果是手写DOM字符串再innerHTML性能不说光是维护那些className就够受了。第三是设备之间有一种几何连接关系。电力系统讲究“电气接线”母线连接多个间隔变压器连接高低压侧线路穿越多个间隔。这种关系反映在画布上就是线必须从设备端子连出来经过折线拐弯进入另一台设备的端子不能随便画条直线了事。第四是交互需求不算重但必须有。点击设备查看详情、拖拽调整布局、双击修改命名这些是刚需。而手写SVG想实现这些得给每个元素手动绑定事件、手动做命中测试、手动计算拖拽后的所有关联连线位置工程量相当可观。我那时候的项目从最初三百多行SVG写到后来两千多行画布上密密麻麻全是硬编码坐标和path参数新来的同事打开文件直接沉默了。后来痛下决心重构核心思路就一句话把SVG当成绘图引擎而不是把SVG当作文书格式去手写。1.2 手写SVG的三大痛点坐标、坐标还是坐标复盘我当时踩的坑几乎全部集中在坐标处理上。先看一个典型的变压器图元手写版本g transformtranslate(300, 240) circle cx0 cy0 r32 fill#fff stroke#595959 stroke-width2/ circle cx-10 cy-8 r9 fill#f9f9f9 stroke#333/ circle cx10 cy8 r9 fill#f9f9f9 stroke#333/ text x0 y45 text-anchormiddle font-size12#1主变/text /g单个图元看起来还行但一旦有几十个图元、几十条连线问题就暴露了每个图元的内部坐标是局部的但连线两端必须使用全局坐标。一个图元平移10px所有连到它的线都要改端点坐标。折线连线的拐点计算极度繁琐手动算一次就要草稿纸线上还有电压等级颜色区分维护成本翻倍。设备状态变化时如果要更新颜色得先通过id找到对应DOM元素再逐个改attribute。数据变了但DOM没跟着变或者DOM变了数据没同步的情况频繁发生。1.3 为什么是SVG.js而不是Canvas或者ECharts在选型阶段我认真对比了四条路线简单列个表方案优势在我这个场景下的问题手写原生SVG无依赖、体积小坐标维护爆炸、交互代码量巨大、复用性差Canvas 2D / WebGL大数据量渲染性能强事件命中需要自己做坐标反算图元没有DOM层级Hover/Click都得手写ECharts / G6等图表库自带数据配置和交互内置图形体系偏统计图表或通用图分析对电力设备的自定义符号支持很别扭SVG.js轻量、链式API、有分组/事件/插件机制生态相对小众但用作绘图引擎完全够我最终选了SVG.js核心原因是它正好补齐了手写SVG最痛的那几个点。它不替代SVG标准而是在SVG之上提供了一套接近“绘图模型”的API可以创建画布、创建组、绘制图形、绑定事件、做动画而且链式调用非常顺手。最重要的是它和Vue3的响应式体系可以很好地配合数据变化时清空重绘就好几秒钟一次全量重绘在几百个节点的规模下完全没压力完全不需要做局部diff优化。2. 工程准备Vue3 SVG.js的最小环境与核心底子2.1 初始化项目与安装依赖我这里直接用Vite来创建Vue3项目干净利落npm create vitelatest power-topology -- --template vue cd power-topology npm install npm install svgdotjs/svg.js npm install svgdotjs/svg.draggable.js第二个npm包是SVG.js的官方拖拽插件。注意SVG.js 3.x版本的拖拽功能不再内置在核心包里必须单独安装这个插件否则调用.draggable()方法时会直接报错。安装完成后在src/main.js里不需要任何全局注册只要在用到SVG.js的组件中按需导入即可。2.2 SVG.js的核心概念画布、图形、组SVG.js的整体模型非常直观自动对应SVG文档树结构。先创建一个画布实例import { SVG } from svgdotjs/svg.js const draw SVG() draw.addTo(#topologyContainer) draw.size(1200, 800)SVG()函数创建一个画布实例addTo指定挂载的DOM容器size设置像素宽高。这里有一点值得展开SVG.js的.size()设置的是SVG元素的CSS尺寸和viewBox基础尺寸后续所有子元素的坐标都在这个坐标系里排布。然后是画一个最简单的矩形draw.rect(100, 60) .attr({ fill: #4a90d9, stroke: #1d3a63, stroke-width: 2 }) .center(200, 150)这一段的逻辑直译就是“创建宽100高60的矩形填充色和描边色通过attr设置然后中心点在(200, 150)”。对比原生SVGrect x150 y120 width100 height60中心点和左上角坐标之间的换算完全不用我操心。再来看组group这是图元封装的关键const g draw.group() g.circle(50).fill(#f00).center(100, 100) g.text(发电机).attr({ text-anchor: middle }).center(100, 130)组可以容纳多个图形元素组本身有独立坐标系方便整体移动、显示隐藏、事件绑定。电力设备的图元几乎都是“一个圆几根线一行文字”这种组合用组封装之后一次创建、整体复用拖拽的时候也只绑组上的事件。2.3 跨过第一个坎SVG.js在Vue3中的正确初始化方式很多第一次在Vue3里用SVG.js的朋友会踩一个相同的坑在setup顶层直接创建实例结果发现容器ref还是null。这是因为Vue3的script setup在组件实例挂载之前就执行了此时ref绑定的DOM元素尚未真正渲染到页面上。正确的做法是把初始化逻辑放在onMounted生命周期里template div refcontainerRef classtopology-container/div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import { SVG } from svgdotjs/svg.js const containerRef ref(null) let draw null onMounted(() { if (!containerRef.value) return draw SVG().addTo(containerRef.value) draw.size(1200, 800) // 后续所有绘图逻辑都从这里开始 renderTopology() }) onBeforeUnmount(() { if (draw) { draw.clear() draw null } }) function renderTopology() { // 绘制逻辑 } /script style scoped .topology-container { width: 100%; height: 100%; background: #fafafa; border: 1px solid #e8e8e8; border-radius: 6px; overflow: hidden; } /styleonBeforeUnmount里做画布清理这个细节后面在踩坑章节会展开说但建议一开始就养成习惯。组件销毁时不清理SVG实例在单页应用里会导致大量残留DOM和事件监听器堆积页面切几次就卡了。3. 图元库设计用函数封装电力设备二十行画出全部核心元件3.1 先约定一个可扩展的数据模型绘图代码之前先把数据模型定清楚。一个电力系统拓扑图可以抽象成两层数据结构节点node一个设备就是一个节点const nodes [ { id: g1, type: generator, label: 1号发电机, x: 180, y: 120 }, { id: g2, type: generator, label: 2号发电机, x: 480, y: 120 }, { id: t1, type: transformer, label: 1号主变, x: 330, y: 300 }, { id: b1, type: busbar, label: 10kV I段母线, x: 330, y: 460 }, { id: load1, type: load, label: 厂用电负荷, x: 180, y: 580 }, { id: load2, type: load, label: 出线负荷, x: 480, y: 580 }, ]type字段决定了画什么图元x和y是节点在图中的坐标我统一约定为图元中心点的坐标。边edge一条电气连接就是一条边const edges [ { from: g1, to: t1, voltage: 10kV }, { from: g2, to: t1, voltage: 10kV }, { from: t1, to: b1, voltage: 10kV }, { from: b1, to: load1, voltage: 10kV }, { from: b1, to: load2, voltage: 10kV }, ]from和to直接引用节点的id不需要存储具体坐标。这样布线时通过查表就能拿到两端坐标节点移动后边也会跟着移动这是数据驱动带来的核心收益。3.2 核心设备图元的绘制函数基于这个数据模型我把常用电力设备各封装成一个函数。每个函数接收画布实例和坐标参数返回一个已经画好的组。发电机图元function drawGenerator(draw, x, y, label, state normal) { const group draw.group() const colorMap { normal: #3b7bc8, fault: #f5222d, running: #52c41a } group.circle(56) .attr({ fill: colorMap[state] || colorMap.normal, stroke: #1d3a63, stroke-width: 2 }) .center(x, y) group.text(G) .attr({ fill: #ffffff, font-size: 26, font-weight: bold, text-anchor: middle, dominant-baseline: central }) .center(x, y) group.text(label) .attr({ fill: #333333, font-size: 12, text-anchor: middle }) .center(x, y 48) return group }这里的state参数是三态的normal正常、fault故障、running运行。实际电力监控中发电机组的运行状态直接决定图形颜色这样封装之后刷新状态只需要重新调用一次函数即可。变压器图元变压器在电力系统中是个很常见的符号我在这版里用一个外圆加两个小圆表示双绕组变压器function drawTransformer(draw, x, y, label) { const group draw.group() group.circle(64) .attr({ fill: #ffffff, stroke: #595959, stroke-width: 2 }) .center(x, y) group.circle(18) .attr({ fill: #f9f9f9, stroke: #333333, stroke-width: 1.5 }) .center(x - 10, y - 8) group.circle(18) .attr({ fill: #f9f9f9, stroke: #333333, stroke-width: 1.5 }) .center(x 10, y 8) group.text(label) .attr({ fill: #333333, font-size: 12, text-anchor: middle }) .center(x, y 55) return group }断路器图元function drawBreaker(draw, x, y, label) { const group draw.group() group.rect(28, 44) .attr({ fill: #ffffff, stroke: #333333, stroke-width: 1.5 }) .center(x, y) group.line(x - 9, y - 14, x 9, y 14) .attr({ stroke: #333333, stroke-width: 1.5 }) if (label) { group.text(label) .attr({ text-anchor: middle, font-size: 10 }) .center(x, y 38) } return group }母线图元母线在拓扑图里通常是一根粗横线用来连接多个间隔。它和普通设备不太一样需要支持动态宽度function drawBusbar(draw, x, y, width 200) { return draw.rect(width, 10) .attr({ fill: #d9534f, stroke: #a94442, stroke-width: 1 }) .center(x, y) }负荷图元function drawLoad(draw, x, y, label) { const group draw.group() group.rect(40, 32) .attr({ fill: #ffec99, stroke: #d4a106, stroke-width: 1.5 }) .center(x, y) // 添加一个小箭头表示负荷方向 group.line(x 20, y, x 32, y 12) .attr({ stroke: #333, stroke-width: 1.5 }) group.line(x 28, y 4, x 32, y 12) .attr({ stroke: #333, stroke-width: 1.5 }) group.line(x 32, y 12, x 24, y 12) .attr({ stroke: #333, stroke-width: 1.5 }) group.text(label) .attr({ text-anchor: middle, font-size: 10 }) .center(x, y 32) return group }到这里一个最小图元库就成型了。在实际工程里可以把这些函数统一放到src/components/topology/symbols.js文件里通过一个drawSymbol(type, draw, node, ...)的总入口分发后面加新设备只需加一个case分支。3.3 组合图元的关键组内偏移与标签排版图元函数里有一个容易被忽视的细节所有子图形都以传入的(x, y)为中心而不是以某个子图形的局部坐标为中心。这样做的好处是绘图时不用记忆每个图元内部的相对偏移只需要关心“这个设备放在哪个位置”连线的计算也会非常简单。标签排版方面我通常把设备名称放在图元正下方水平和垂直居中。SVG.js的text元素做居中需要同时设置text-anchor: middle和dominant-baseline: central前者控制水平居中后者控制垂直居中。很多初学者只设置前者结果用.center()定位文字时总差半个字符的高度。4. 连线逻辑与交互让拓扑图真正具备电力系统的形态4.1 分层排布按电压等级和设备层级确定坐标很多初学者拿到数据后第一反应是问“这些坐标怎么算”。真实项目中坐标来源无非三种人工拖拽保存、后端下发的屏柜坐标、前端自动布局。这里说一种适合中小系统的自动布局思路——分层排布。电力系统一次接线大体上遵循“电源—升压—输电—降压—配电—负荷”的垂直层次关系。我把画布按y轴分成若干层const LAYERS { source: 120, // 电源层发电机 transformer: 300, // 变压器层主变 busbar: 460, // 母线层各电压等级母线 load: 580 // 负荷层出线/用电设备 }在同一层里x坐标根据设备数量均分画布宽度。例如画布宽1200该层有3台设备那么x就取200、600、1000。设备少就靠中间聚拢设备多就铺开这样生成的图整体均匀不会出现一堆设备挤在角落里。这种分层布局不追求美观极致但非常实用至少比一上来就强行上力导向算法靠谱得多。对于绝大多数电站接线图层次关系足够清晰等有特殊布局需求时再让用户手动拖拽调整节点位置调整结果回存到数据里。4.2 折线连线与电压等级颜色规范在电力拓扑里直接画一条斜线连接两个设备是不专业的因为电气接线讲究“横平竖直”。所以连线要生成正交折线路径。我封装的连线函数如下function computeOrthPath(x1, y1, x2, y2) { const offset 35 // 设备中心到端子的距离根据图元大小调整 if (Math.abs(x1 - x2) 30) { // 两个设备垂直对齐直接画竖线 return M ${x1} ${y1} L ${x2} ${y2} } const midY (y1 y2) / 2 return M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2} }在实际绘制边的时候从from节点底部坐标(node.x, node.y offset)出发到to节点顶部坐标(node.x, node.y - offset)结束这样线不会穿过设备内部而是贴着设备端子接出来function drawEdge(draw, from, to, color #666) { const fromX from.x const fromY from.y 35 const toX to.x const toY to.y - 35 const path computeOrthPath(fromX, fromY, toX, toY) return draw.path(path) .attr({ fill: none, stroke: color, stroke-width: 2 }) }电压等级配色是电力系统的老规矩可以固化成常量表const VOLTAGE_COLORS { 500kV: #d32f2f, 220kV: #2e7d32, 110kV: #1976d2, 35kV: #f57c00, 10kV: #424242 }这样一句话就能给边指定颜色drawEdge(draw, from, to, VOLTAGE_COLORS[edge.voltage] || #666)。如果希望体现潮流方向或线路检修状态还可以在attr里追加虚线设置line.attr(stroke-dasharray, 6 4) // 虚线表示检修或计划状态4.3 节点拖拽、点击高亮与实时刷新拖拽依赖前面安装的draggable插件。在画完每个图元组之后直接调用.draggable()即可group.draggable().on(dragend, (event) { const box event.detail.box node.x box.cx node.y box.cy // 重绘所有与该节点相连的边 updateConnectedEdges(node.id) })这里有个细节需要留意拖拽的group是整个图元组但event.detail.box返回的是组内所有元素的边界盒中心不一定就是设备图形的中心。如果组里包含文字标签中心会轻微偏移。稳妥的做法是在创建组时给组内图形元素加一个标记类比如.addClass(equipment-core)拖拽结束后从组内找到这个类再取中心坐标。点击高亮group.on(click, () { // 清除其他高亮 draw.find(.selected).attr(opacity, 0.7) group.addClass(selected).attr(opacity, 1) // 触发组件的事件把节点数据抛给父级 emit(node-click, node) })配合CSS可以给选中的设备描边加粗、加阴影效果这里不展开了。实时刷新这是我最后悔没早点用数据驱动做的一件事。原先手写SVG时改一个断路器颜色要用document.getElementById拿到SVG里的圈再改stroke和fill。现在只需要这样做function updateNodeState(nodeId, newState) { const node nodes.find(n n.id nodeId) if (!node) return node.state newState renderTopology() // 全量重绘 }全量重绘听起来很“笨”但在几十个节点规模的拓扑图里完全是划算的。SVG.js创建几百个元素也就几十毫秒加上Vue3的响应式通知整个图刷新在肉眼看来就是瞬间的事。与其费劲维护“只更新某一个小圆”的逻辑不如让引擎负责渲染我们只管数据。5. 完整可运行代码一个最小可用的电力系统拓扑组件5.1 TopologyView.vue 组件完整代码把前面所有关键逻辑拼装起来就是一个可直接运行的最小组件。下面是完整代码template div refcontainerRef classtopology-view/div /template script setup import { ref, onMounted, onBeforeUnmount, watch } from vue import { SVG } from svgdotjs/svg.js import svgdotjs/svg.draggable.js const props defineProps({ nodes: { type: Array, default: () [] }, edges: { type: Array, default: () [] } }) const emit defineEmits([node-click]) const containerRef ref(null) let draw null onMounted(() { if (!containerRef.value) return draw SVG().addTo(containerRef.value) draw.size(1200, 800) draw.viewbox(0, 0, 1200, 800) renderTopology() }) onBeforeUnmount(() { if (draw) { draw.clear() draw null } }) watch( () [props.nodes, props.edges], () renderTopology(), { deep: true } ) function getNode(id) { return props.nodes.find(n n.id id) } function renderTopology() { if (!draw) return draw.clear() drawEdgeLayer() drawNodeLayer() } function drawEdgeLayer() { props.edges.forEach(edge { const from getNode(edge.from) const to getNode(edge.to) if (!from || !to) return const path computeOrthPath( from.x, from.y 35, to.x, to.y - 35 ) const line draw.path(path) .attr({ fill: none, stroke: edge.color || #666666, stroke-width: 2 }) if (edge.dashed) line.attr(stroke-dasharray, 6 4) }) } function drawNodeLayer() { props.nodes.forEach(node { const group drawSymbol(draw, node) if (!group) return // 点击高亮 group.on(click, () { draw.find(.selected).attr(opacity, 0.7) group.addClass(selected).attr(opacity, 1) emit(node-click, { ...node }) }) // 拖拽更新 group.draggable().on(dragend, (event) { const box event.detail.box const core group.findOne(.equipment-core) const cx core ? core.cx() : box.cx const cy core ? core.cy() : box.cy node.x cx node.y cy drawEdgeLayer() // 在实际项目中这里应只更新关联边 }) }) } function drawSymbol(draw, node) { const { x, y, label, state normal } node const group draw.group() switch (node.type) { case generator: group.circle(56) .attr({ fill: #3b7bc8, stroke: #1d3a63, stroke-width: 2 }) .center(x, y) .addClass(equipment-core) group.text(G) .attr({ fill: #ffffff, font-size: 26, font-weight: bold, text-anchor: middle, dominant-baseline: central }) .center(x, y) group.text(label) .attr({ text-anchor: middle, font-size: 12 }) .center(x, y 48) break case transformer: group.circle(64) .attr({ fill: #fff, stroke: #595959, stroke-width: 2 }) .center(x, y) .addClass(equipment-core) group.circle(18) .attr({ fill: #f9f9f9, stroke: #333, stroke-width: 1.5 }) .center(x - 10, y - 8) group.circle(18) .attr({ fill: #f9f9f9, stroke: #333, stroke-width: 1.5 }) .center(x 10, y 8) group.text(label) .attr({ text-anchor: middle, font-size: 12 }) .center(x, y 55) break case breaker: group.rect(28, 44) .attr({ fill: #fff, stroke: #333, stroke-width: 1.5 }) .center(x, y) .addClass(equipment-core) group.line(x - 9, y - 14, x 9, y 14) .attr({ stroke: #333, stroke-width: 1.5 }) break case busbar: group.rect(200, 10) .attr({ fill: #d9534f, stroke: #a94442, stroke-width: 1 }) .center(x, y) .addClass(equipment-core) group.text(label) .attr({ text-anchor: middle, font-size: 10 }) .center(x, y - 20) break case load: group.rect(40, 32) .attr({ fill: #ffec99, stroke: #d4a106, stroke-width: 1.5 }) .center(x, y) .addClass(equipment-core) group.text(label) .attr({ text-anchor: middle, font-size: 10 }) .center(x, y 32) break default: return null } return group } function computeOrthPath(x1, y1, x2, y2) { if (Math.abs(x1 - x2) 30) { return M ${x1} ${y1} L ${x2} ${y2} } const midY (y1 y2) / 2 return M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2} } /script style scoped .topology-view { width: 100%; height: 100%; min-height: 600px; background: #f9f9f9; border: 1px solid #e6e6e6; border-radius: 8px; overflow: hidden; } .topology-view :deep(svg) { display: block; cursor: grab; } .topology-view :deep(svg:active) { cursor: grabbing; } /style这份代码虽然精简但已经包含了完整的数据驱动渲染、点击高亮、拖拽移动三个核心能力。实际项目在这个基座上扩展不会太费劲。5.2 父页面调用与数据配置示例父页面如下template div classpage h3示例站一次系统图/h3 TopologyView :nodesnodes :edgesedges node-clickhandleNodeClick / /div /template script setup import { ref } from vue import TopologyView from ./components/TopologyView.vue const nodes ref([ { id: g1, type: generator, label: 1号发电机, x: 200, y: 120 }, { id: g2, type: generator, label: 2号发电机, x: 1000, y: 120 }, { id: t1, type: transformer, label: #1主变, x: 600, y: 300 }, { id: b1, type: busbar, label: 10kV I段母线, x: 400, y: 460 }, { id: b2, type: busbar, label: 10kV II段母线, x: 800, y: 460 }, { id: load1, type: load, label: 1号馈线, x: 250, y: 580 }, { id: load2, type: load, label: 2号馈线, x: 550, y: 580 }, { id: load3, type: load, label: 3号馈线, x: 950, y: 580 } ]) const edges ref([ { from: g1, to: t1, voltage: 10kV, color: #424242 }, { from: g2, to: t1, voltage: 10kV, color: #424242 }, { from: t1, to: b1, voltage: 10kV, color: #424242 }, { from: t1, to: b2, voltage: 10kV, color: #424242 }, { from: b1, to: load1, voltage: 10kV, color: #424242 }, { from: b2, to: load2, voltage: 10kV, color: #424242 }, { from: b2, to: load3, voltage: 10kV, color: #424242 } ]) function handleNodeClick(node) { console.log(点击了节点, node) } /script style scoped .page { padding: 24px; height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; } .page h3 { margin: 0 0 16px; } /style这个示例虽然只有8个节点7条边但结构上已经是一个完整的小型配电系统图了。把数据替换成真实项目的设备清单再补上颜色状态就能直接作为监控画面使用。5.3 组件内核心逻辑与注意事项上面代码里有一个方法顺序问题需要强调drawEdgeLayer()一定要在drawNodeLayer()之前执行。因为SVG遵循文档画布的后绘制覆盖原则先画线、再画设备设备才能压在连线上方反过来画的话所有的线都会穿过设备图面会非常杂乱。另外在拖拽的dragend回调里我直接调用了drawEdgeLayer()而不是renderTopology()为的是只重绘连线层避免整个画布闪烁。但这样做有一个副作用前一次拖拽中旧的重绘组件没有被清除造成连线重复绘制叠加。实际项目中正确的做法是给连线元素也加一个类似core的标记类重绘之前先删除所有.edge-line元素再重新画。修改很小这里补一下function drawEdgeLayer() { draw.find(.edge-line).remove() props.edges.forEach(edge { // 原来的绘制逻辑最后加上 line.addClass(edge-line) }) }6. 避坑记录SVG.js与Vue3配合的几条实战教训6.1 版本与导入方式SVG.js目前最新的3.x版本与2.x有破坏性变更。网上很多老教程还是2.x的写法比如直接import SVG from svg.js然后全局使用。在3.x里正确写法是import { SVG } from svgdotjs/svg.js。draggable插件同样要注意版本匹配。svgdotjs/svg.draggable.js这个包名是3.x配套的2.x时代的插件名是sweetalert这种风格装错包之后draggable()方法会一直报undefined。6.2 生命周期清理单页应用切换路由时如果组件销毁后SVG画布还挂在DOM上再次进入页面时会出现两个画布重叠或者事件重复触发。必须在onBeforeUnmount中执行onBeforeUnmount(() { if (draw) { draw.clear() draw null } })draw.clear()会移除画布内的所有子元素draw null让Vue垃圾回收可以正常回收实例。这一步别偷懒。6.3 文字居中与层叠顺序前面提过文字居中要设置两个属性这里再补一个容易踩的坑dominant-baseline: central在部分老旧浏览器的SVG渲染中表现不一致如果发现文字垂直居中失效可以用transform: translateY(0.35em)或者直接设置dy偏移来做兼容替代。层叠顺序问题在复杂图元里尤其明显。当分组内部有多个子元素且需要某些子元素固定在上层时SVG.js提供了.front()和.back()方法手动调整层级。例如给变压器加一个红色告警外圈可以先画圆圈再画内部绕组最后把外圈调回最上层const outerRing group.rect(70, 70) .attr({ fill: none, stroke: #f5222d, stroke-width: 2, stroke-dasharray: 4 2 }) outerRing.back()6.4 数据响应式的边界问题Vue3的ref响应式对象会自动深响应。但如果直接把响应式props.nodes里的某个node对象传到SVG.js的attr()方法里某些情况下会因为代理对象的特殊性导致序列化异常。我在项目中踩过一次把一个node对象直接当作attr()参数结果SVG.js内部循环读取属性时触发了Vue的响应式代理报了警告“Cannot read properties of undefined”。规避方法很简单传参前先浅拷贝一份group.attr({ data-id: node.id }) // 而不是 group.attr(node)另外如果用watch监听props.nodes的深变化每次拖拽更新节点坐标都会触发整个renderTopology()全量重绘。数据量小没问题数据量大时建议给watch加flush: post或者手动控制更新频率。6.5 画布尺寸与viewbox的取舍draw.size(1200, 800)和draw.viewbox(0, 0, 1200, 800)是一对容易混淆的API。size()设置的是SVG元素在页面上的CSS像素尺寸viewbox()设置的是内部坐标系的可见区域。如果只设置size()而不设置viewbox()那么SVG元素的宽度和高度就是坐标系的宽高比例固定为1:1。如果希望缩放适配容器宽度需要动态设置viewbox通常结合容器宽度计算缩放倍数。一个简化做法是给viewbox设置成比实际画布略小的区域这样当容器变宽时整体图形按比例放大设备不会变形draw.viewbox(0, 0, 1000, 700) draw.size(100%, 100%)但要注意viewbox放大之后拖拽坐标仍然是原始坐标系的值不需要额外换算SVG.js内部已经处理好了。这个特性让我省掉了大量坐标换算的体力活。6.6 marker箭头的全局id冲突如果想给连线加箭头通常用SVG的marker功能。SVG.js的写法是const marker draw.marker(10, 10, (add) { add.path(M 0 0 L 10 5 L 0 10 z).fill(#666) }) line.marker(end, marker)问题在于marker的id在整个SVG文档中是全局的。同一个页面如果挂载了多个SVG实例marker的id会互相覆盖导致明明配置了箭头但有的线不显示箭头或者箭头样式错乱。解决办法是给marker设置一个带前缀的唯一id例如marker.id(arrow-${Date.now()}-${Math.random()})或者干脆用draw.__instance等内部标识生成前缀。这个坑在维护二次接线图时特别容易遇到因为一张页面往往会有多张站内图。6.7 别急着写代码先把数据结构和坐标规范定下来最后一条不算技术坑但比技术坑更致命。SVG.js虽然大大减轻了绘图负担但如果没有清晰的数据结构和坐标约定写到后期照样会乱。我在重构之初就把规范写在了代码注释里节点坐标统一定义为设备几何中心点连线默认从节点正下方出发上端进入时偏移固定端子距离35px与图元尺寸相关电压等级用键值对统一管理不允许在边数据里直接写死颜色值新图元必须封装成函数并走统一的drawSymbol分发入口禁止在业务代码里直接draw.circle散落绘图这份规范让我后续接入真实数据时省了巨量返工。现在接到新的站点画面需求拿到设备清单和电气连接关系把数据往nodes和edges数组里一填图就出来了改坐标、改颜色、调布局都是配置层面的变化不再动绘图代码。以上这些经验和代码都是我在实际项目里反复调试验证过的。如果你正准备用Vue3做电力系统拓扑图、网络拓扑图、管网系统图这类偏工业场景的可视化SVG.js这套方案值得一试。把图元、连线和交互封装成一套自己的“小引擎”后面无论是接实时数据还是做告警联动都会从容很多。
返回列表