Vue3和SVG.js打造电力系统拓扑图:摆脱手搓SVG的困境
2026/9/20 16:15:27 网站建设 项目流程

开发电力系统拓扑图这事儿,不少人都是一开始信心满满地选择手搓SVG,结果画到一半就开始崩溃——满屏的createElementNS、连坐标都要自己一点一点算,图还没画完头发先掉完。后来我把技术栈切到Vue3 + SVG.js这套组合,拓扑图的开发效率直接上了一个台阶,这篇就把我从实际电力监控项目里沉淀下来的完整思路和可运行代码一次性分享出来。如果你正在做能源调度、配电网、变电站监控这类项目,或者刚接触前端可视化但不想一上来就啃D3、用着又嫌ECharts定制不灵活,这篇内容应该能帮你少走不少弯路。

1. 为什么我不再手搓SVG画拓扑图

1.1 手写SVG的真实体验

老实说,早年做电力监控项目时,我第一次接触SVG就是从手写DOM开始的。画一个变压器要写七八行createElementNS,设置属性又是一个个setAttribute,画完以后为了响应设备状态变化,还得给每个图形打标记、找父节点、再逐个改填充色。如果用户拖拽设备,那就更麻烦,所有关联线段的坐标都得手动重算,稍不留神就是一个错位。

这种方式在图形很少时还能勉强凑合,一旦图纸上有几十个设备、上百条连线,代码量就会变得极其恐怖,而且维护成本呈指数级上涨。我不止一次在深夜面对一个三千多行的SVG操作代码,心里只有一个念头:这东西是谁写的,真的有必要这么写吗。

还有一个更隐蔽的坑:手写SVG的时候,大家很容易把“数据”和“渲染”混在一个函数里。今天改个坐标、明天改个颜色,改完又忘记同步业务数据,最终图上显示的和设备实际状态完全对不上,排查半天才发现只是某个setAttribute少写了。

1.2 SVG.js到底解决了什么问题

SVG.js是一个轻量级绘图库,核心思路和早年jQuery有点像——用链式API代替繁琐的原生DOM操作。同样是创建一个矩形,原生写法要五六行,SVG.js一行就能搞定。而且文本、事件、动画、拖拽都有现成封装,很多我在手写SVG时踩过的坑,库内部其实已经处理好了。

拿最简单的例子来说,创建一个矩形并设置样式:

// 原生SVG方式 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') rect.setAttribute('x', 10) rect.setAttribute('y', 10) rect.setAttribute('width', 100) rect.setAttribute('height', 60) rect.setAttribute('fill', '#ffffff') rect.setAttribute('stroke', '#475569') svg.appendChild(rect) // SVG.js方式 draw.rect(100, 60).move(10, 10).fill('#ffffff').stroke({ width: 1, color: '#475569' })

代码量只是表面差异,更关键的是SVG.js支持组合图元。电力设备不是一个简单矩形,它是多个圆、线、文本拼在一起的整体,手写时你得自己管理每个子元素的关系,SVG.js里一个group()就能把多个图形打包成一个可整体操作的对象。这个能力对拓扑图开发来说是决定性的。

1.3 常见方案对比:手写SVG、SVG.js、ECharts怎么选

不少朋友问过我:为什么不直接用ECharts的graph关系图?这里有个很现实的原因。ECharts的graph擅长的是网络关系可视化,它的坐标是算法自动布局的,而电力系统拓扑图恰恰需要精确到每个设备的地理位置或屏柜位置,很多场景是设计院出的底图上叠加设备。另外ECharts对自定义图元的支持要绕很多弯,你需要通过symbol写SVG路径,交互回调也不如直接在DOM元素上绑定事件来得自然。

还有一部分人用D3.js,D3确实强大,但学习曲线太陡,它的核心思路是数据绑定和操作符,对只想画一张拓扑图的前端同学来说,很多概念会比较重。下面这张表是我在这些年的实际项目里总结出来的对比:

方案上手难度图元自定义事件交互性能表现适合场景
手写SVG较高很灵活繁琐简单静态图、一次性图形
SVG.js很灵活简洁中高中大型交互拓扑图
ECharts受限简洁通用数据可视化、关系图
D3.js很高很灵活复杂深度数据可视化研究

在我看来,电力拓扑图最看重的是“精确坐标 + 自定义图元 + 快速交互”,这三条SVG.js都占齐了。它的性能在中大型拓扑图(几百到一千个设备)范围内完全够用,而且因为SVG本身是DOM,天然支持浏览器事件,点击、悬浮、右键这些操作不需要额外做坐标拾取,写起来非常顺手。当然,如果你的设备数量到几千上万,那就得考虑Canvas方案了,不过那种规模在电力二次系统里其实很少见。

2. 电力系统拓扑图的核心拆解:先建模,再画图

2.1 拓扑图画的不只是“图”

很多新手一上来就急着画图元,画完就开始头疼——数据结构是乱的、图元和业务数据对不上、改状态要重新createElement。我的建议是,画图之前先想明白一件事:电力拓扑图本质上是一个“数据可视化界面”,而不是一张静态图片。

到电力系统里,每个设备都有明确的业务含义:变压器有高中低压三侧,断路器有合分状态,母线是电能的汇集点,负荷代表用电侧。图纸上每个图形背后都对应着实时数据、历史数据、告警信息。如果只把它当图来画,后面做数据联动时一定会重构。所以第一步永远是定义好数据模型。

2.2 数据结构先行:节点与连线

我习惯把拓扑图拆成“节点”和“连线”两大类。节点代表设备,比如变压器、断路器、母线、负荷、发电机;连线代表一次接线关系,可能是输电线路、母线段,也可以是逻辑连接。

一个基本的节点对象长这样:

const node = { id: 'bus-1', // 全局唯一ID,SVG图元和数据关联的桥梁 type: 'busbar', // 设备类型,决定用哪个渲染函数 label: '10kV I段母线', // 图上显示的文本 x: 100, // 图元中心点的x坐标 y: 250, // 图元中心点的y坐标 status: 'running', // 运行状态:running / stopped / fault / warning extra: {} // 其他业务字段,比如额定电压、电流互感器变比等 }

连线对象的建模方式是一对节点ID:

const link = { id: 'link-1', from: 'bus-1', to: 'tf-1', status: 'running' }

注意,连线里不存坐标,连线的坐标是从两端的节点实时计算出来的。这样设计的好处非常明显:拖拽节点时,只要更新节点坐标,然后遍历一下所有关联连线,重新plot一下就行,不用维护一套冗余的坐标缓存。

2.3 图层规划:底图、连线层、设备层、标注层

如果你只是画二三十个设备,分区的问题可能不明显。但真实电力监控项目的拓扑图通常是一张很大的接线图,上面还叠着底图、设备、动态标注,如果全画在一个层级里,后面做样式调整会非常痛苦。

我一般在初始化画布时就规划四个独立分组:

  • 底图层:放电网背景图、网格、静态装饰
  • 连线层:所有线路元素
  • 设备层:所有设备图元,包含状态点、文本
  • 标注层:批量操作的矩形框、临时提示、水印

在SVG.js里,实现图层非常简单,就是创建几个group:

const bgLayer = draw.group() const linkLayer = draw.group() const deviceLayer = draw.group() const annotationLayer = draw.group()

后面所有元素创建时,只用从对应图层而不是draw直接创建,比如变压器图元就调用deviceLayer.group()。这样做的好处是,想批量隐藏设备、给设备层加滤镜、或者让连线整体变半透明,这些操作都只需要针对一个group处理,代码逻辑会清晰很多。

3. 搭建Vue3 + SVG.js开发环境

3.1 初始化项目和安装依赖

我用Vite来初始化Vue3项目,比webpack配置轻不少。如果你已经有一个现成的Vue3项目,可以直接跳到依赖安装。

npm create vite@latest power-topo-demo -- --template vue cd power-topo-demo npm install

然后安装SVG.js本体以及两个常用插件:拖拽插件svg.draggable.js和画布平移缩放插件svg.panzoom.js

npm install @svgdotjs/svg.js npm install @svgdotjs/svg.draggable.js npm install @svgdotjs/svg.panzoom.js

注意:SVG.js目前的稳定版本是3.x,包名是@svgdotjs/svg.js,不是老教程里的svg.js。网上很多旧文章用的还是2.x的API,安装之后如果报错,先检查是不是版本问题。这里插一句,这个坑我后面专门写一节。

3.2 初始化画布与viewBox

在Vue3组合式API中,我一般用一个div容器来包裹SVG画布,然后在onMounted里创建SVG实例。为什么必须在onMounted而不是setup里创建?因为setup执行时DOM还没挂载,ref拿到的是undefined,SVG实例没有挂载点。这是新人最容易踩的时序问题。

import { ref, onMounted, onBeforeUnmount } from 'vue' import { SVG } from '@svgdotjs/svg.js' const containerRef = ref(null) let draw = null onMounted(() => { draw = SVG().addTo(containerRef.value).size('100%', '100%').viewbox(0, 0, 1200, 800) })

viewbox(0, 0, 1200, 800)这行很关键,它定义了一个坐标系:所有图元的坐标都基于这个1200x800的逻辑空间。之后不管容器实际多宽多高,SVG都会按比例缩放,这样我们写坐标时不需要关心设备的像素密度和容器尺寸,直接按逻辑坐标布局就行。

如果你希望初始画布能适配容器比例,也可以动态计算:

const width = containerRef.value.clientWidth const height = containerRef.value.clientHeight draw = SVG().addTo(containerRef.value).size('100%', '100%').viewbox(0, 0, width, height)

3.3 引入拖拽与缩放插件

SVG.js的插件机制非常轻量,只需要在入口文件中import一次,就会给SVG实例和元素扩展新方法。

import '@svgdotjs/svg.draggable.js' import '@svgdotjs/svg.panzoom.js'

然后初始化时给画布开启平移缩放:

draw.panZoom({ zoomMin: 0.3, zoomMax: 3 })

这样画布天然支持鼠标滚轮缩放和拖拽平移。要注意的是,panzoom插件在缩放时会改变SVG的viewBox,这会影响后续拖拽时的坐标计算,不过draggable插件在大多数情况下能处理好和viewBox的配合,实测下来大比例缩放时仍然可以用。

4. 核心代码实现:设备图元与连线绘制

4.1 设备图元工厂函数

电力系统设备种类很多,但核心图元可以归纳为几类:变压器、断路器、母线、负荷、发电机。我不建议每种设备都写一个独立的组件,更实用的做法是写一个图元工厂,根据node.type分发到对应的绘制函数。

以变压器为例,一个常见的简化画法是两个同心圆表示绕组,左右两侧引出连线,下面标注设备名称。SVG.js里group化的优势在这里会体现得非常明显:

function drawTransformer(g, node) { // 外圈 g.circle(36).center(0, 0).fill('#ffffff').stroke({ color: '#475569', width: 2 }) // 内圈 g.circle(20).center(0, 0).fill('#ffffff').stroke({ color: '#475569', width: 1.5 }) // 左右引出端子 g.line(-48, -18, -48, -36).stroke({ color: '#475569', width: 2 }) g.line(48, -18, 48, -36).stroke({ color: '#475569', width: 2 }) }

断路器则可以画成一个矩形加一条斜杠,表示分合状态:

function drawBreaker(g, node) { g.rect(32, 14).center(0, 0).fill('#ffffff').stroke({ color: '#475569', width: 2 }) g.line(-16, 0, 16, 0).stroke({ color: '#dc2626', width: 2 }) g.line(-12, 7, 12, -7).stroke({ color: '#dc2626', width: 1.5 }) }

母线是拓扑图中的“干线”,一般画成一条粗线段:

function drawBusbar(g, node) { const width = node.width || 240 g.rect(width, 8).center(0, 0).fill('#94a3b8').stroke({ color: '#64748b', width: 1 }) }

负荷可能是一个带箭头的矩形:

function drawLoad(g, node) { g.rect(40, 24).center(0, 0).fill('#ffffff').stroke({ color: '#475569', width: 2 }) g.polygon('-8,6 8,6 0,-6').fill('#3b82f6') // 箭头 }

注意,图元内部的所有坐标都是相对group原点的,最后统一用g.move(node.x, node.y)把整个group定位到节点坐标上。这样做最大的好处是:拖拽时只要读g.x()g.y(),就能直接得到节点的中心坐标,不用自己做偏移换算。

4.2 完整渲染函数与状态颜色体系

有了图元工厂,再定义一个统一的渲染函数。它的职责是创建group、调用工厂绘制形状、追加状态点和标签、注册事件,最后把group和业务数据一起存进一个Map缓存里。

const STATUS_COLOR = { running: '#16a34a', stopped: '#94a3b8', fault: '#dc2626', warning: '#d97706' } const elementMap = new Map() const nodeRenderers = { transformer: drawTransformer, breaker: drawBreaker, busbar: drawBusbar, load: drawLoad } function renderNode(node) { const g = draw.group() const renderer = nodeRenderers[node.type] if (renderer) { renderer(g, node) } // 右上角状态点 const statusCircle = g.circle(10).center(24, -24).fill(STATUS_COLOR[node.status] || STATUS_COLOR.stopped) // 设备名称 g.text(node.label).font({ size: 13, weight: 'bold', family: 'sans-serif' }) .fill('#334155').center(0, 34) .attr('text-anchor', 'middle') g.move(node.x, node.y) elementMap.set(node.id, { el: g, data: node, statusCircle }) return g }

这里的elementMap非常重要,它用业务数据的ID直接关联SVG图元。后面任何数据更新,都能通过ID在常数时间内找到对应的SVG元素,而不是去遍历整个文档或者重新渲染所有图元。这在画面元素多起来时是性能的关键。

4.3 连线绘制与动态更新

连线是最能体现“数据驱动”思想的部分。渲染连线时只保存线段本身,不保存任何坐标,所有坐标都从两端的节点实时计算。

function renderLink(link) { const fromNode = nodes.find(n => n.id === link.from) const toNode = nodes.find(n => n.id === link.to) if (!fromNode || !toNode) return const line = draw.line(fromNode.x, fromNode.y, toNode.x, toNode.y) .stroke({ width: 3, color: STATUS_COLOR[link.status] || STATUS_COLOR.running, linecap: 'round' }) line.back() // 把连线沉到最底层,避免覆盖设备图元 elementMap.set(link.id, { el: line, data: link }) }

当节点被拖拽时,只需要重新计算与它关联的所有连线:

function updateLinksByNode(nodeId) { links.forEach(link => { if (link.from === nodeId || link.to === nodeId) { const fromNode = nodes.find(n => n.id === link.from) const toNode = nodes.find(n => n.id === link.to) const cached = elementMap.get(link.id) if (cached && fromNode && toNode) { cached.el.plot(fromNode.x, fromNode.y, toNode.x, toNode.y) } } }) }

SVG.js的plot()方法可以直接修改线的起止坐标,这是原生SVG里需要用setAttribute反复操作x1y1x2y2才能完成的事,在这里一行搞定。

5. 交互能力:拖拽、缩放、事件绑定

5.1 节点拖拽与连线跟随

设备图元创建后,给它调用.draggable()方法,拖拽能力就出来了。核心在于拖拽过程中同步业务数据和关联连线。

g.draggable() g.on('dragmove', () => { node.x = g.x() node.y = g.y() updateLinksByNode(node.id) })

注意这里有个很微妙的点:如果拖拽时不更新node.xnode.y,后续任何业务逻辑都拿不到设备的最新位置,状态刷新时图元可能被重置到旧坐标,所以一定要在dragmove事件里先同步数据,再更新连线。

有一个小细节可能在新手阶段会踩到:SVG.js的draggable插件默认约束是自由的,但如果你希望设备只能在一个范围内移动,可以传入约束函数:

g.draggable((x, y) => { return { x: Math.min(Math.max(x, 0), 1200), y: Math.min(Math.max(y, 0), 800) } })

这在实际项目中很有用,可以防止用户把设备拖到电网背景图之外。

5.2 画布缩放与平移

电力系统拓扑图通常很大,用户需要放大看局部,也需要缩小看全局。svg.panzoom.js插件提供了很好的交互能力,用户滚轮缩放、按住鼠标拖动画布,体验和在线地图差不多。

draw.panZoom({ zoomMin: 0.3, zoomMax: 3 })

我一般还喜欢在缩放时更新一个状态,显示当前比例。可以监听panzoom事件:

draw.on('panZoom', e => { const currentScale = e.detail.scale // 显示到界面上 })

这个细节在正式项目中很实用,用户放大缩小后能直观知道自己在什么比例下看图。

5.3 点击、右键与悬浮事件

SVG.js对事件做了统一封装,元素上直接.click.hover就行。但右键菜单没有内置,需要绑定原生DOM事件。因为SVG.js元素有一个.node属性指向原生的SVG DOM节点,所以我们可以这样处理:

g.click(() => { // 弹出设备详情抽屉 selectedNode.value = node }) g.hover( () => { g.opacity(0.85) }, // 鼠标移入 () => { g.opacity(1) } // 鼠标移出 ) g.node.addEventListener('contextmenu', (e) => { e.preventDefault() // 这里弹出自定义右键菜单,记录坐标 contextMenu.value = { x: e.clientX, y: e.clientY, nodeId: node.id } })

事件绑定也遵循同一个原则:所有事件里只操作数据,或者只操作当前图元,不要每次都重新渲染整张图。这样即使事件很多,页面也不会卡顿。

6. 数据驱动实时刷新:让拓扑图“活”起来

6.1 Vue3响应式数据与SVG.js的同步策略

电力监控系统的数据是不断变化的:遥测值每几秒刷新一次,设备状态可能随时置位为告警。我们要让拓扑图和实时数据保持同步。

很多人在Vue3里会有个直觉:用reactive包住SVG.js的元素对象,让Vue自动追踪变化。这个思路我在早期项目里试过,结果并不理想。SVG.js的Element内部结构很复杂,直接放进reactive会触发大量无意义的依赖收集,性能反而下降,而且容易把SVG.js内部状态搞乱。

正确做法是:业务数据放在Vue的reactive里,SVG图元放在普通Map里,两者通过id关联。更新时显式地调用更新函数,把数据变更同步到图元上。

function updateNodeStatus(id, status) { // 1. 更新业务数据 const node = nodes.find(n => n.id === id) if (!node) return node.status = status // 2. 更新SVG图元 const cached = elementMap.get(id) if (cached) { cached.statusCircle.fill(STATUS_COLOR[status] || STATUS_COLOR.stopped) } // 3. 关联线路状态同步 links.forEach(link => { if (link.from === id || link.to === id) { const linkCached = elementMap.get(link.id) if (linkCached) { linkCached.el.stroke({ color: STATUS_COLOR[status] || STATUS_COLOR.running }) } } }) }

这样做的好处是,图形的更新路径非常明确,哪里改了、改了什么、影响到了哪些元素,全部在掌控之中,调试的时候也很容易定位问题。

6.2 告警闪烁与脉冲动画

设备告警时,只改颜色往往不够醒目,运维人员更希望能一眼看到哪个设备异常。我常用的是闪烁效果,SVG.js内置了动画方法:

function startAlarmFlash(nodeId) { const cached = elementMap.get(nodeId) if (!cached) return cached.statusCircle.animate(500).opacity(0.2) .animate(500).opacity(1) .loop() } function stopAlarmFlash(nodeId) { const cached = elementMap.get(nodeId) if (!cached) return cached.statusCircle.stop(true) cached.statusCircle.opacity(1) }

如果想让线路也具备“流动”效果,可以用stroke-dasharray结合动画:

line.attr({ 'stroke-dasharray': '6 4' }) .animate(800).attr({ 'stroke-dashoffset': -10 }).loop()

这种流动线条在表示电流方向、故障路径时非常直观,也是电力拓扑图区别于普通关系图的常见需求。

6.3 性能优化:千级节点怎么保持流畅

电力系统拓扑图的设备数量,少的几十个,多的一两千个。SVG是DOM元素,元素过多时渲染性能会下降。我实战中的经验是:先做分区裁剪,再做批量更新,最后减少动画。

分区裁剪的意思是,只创建视口范围内的图元,移出视野的设备暂时不渲染,可以参考地图的瓦片加载思路。不过这个方案对SVG.html来说实现成本较高,我的建议是,如果设备数量到了一千以上,优先检查是不是真的需要一次性全显示,很多电力场景有“分区图”和“系统图”的层级关系,并不需要把所有设备堆在一张图上。

批量更新也很关键,避免循环里频繁操作DOM。比如一次性更新100个设备状态时,先用普通循环计算结果,最后再统一操作SVG元素。Vue3的diff算法帮不了SVG内部的DOM,SVG.js层面的批量策略才更实际。

最后,动画数量要克制。实时监控页面里,几十个设备同时做闪烁动画,就算SVG.js内部做了优化,浏览器也会感到吃力。可以在页面可见性变化时暂停动画,也可以用requestAnimationFrame统一调度。

7. 那些年踩过的坑:排查与避坑清单

7.1 SVG.js版本升级的破坏性变更

现在网上搜SVG.js教程,很容易找到2.x时代的代码。2.x和3.x的API有不少差异,最典型的是包名从svg.js变成了@svgdotjs/svg.js,还有draw.size()的参数含义变了,text()的用法也改了。我遇到过同事把旧代码直接拷进新项目,结果svg.text()返回的不是文本元素,排查了半天。

我的建议是:新建项目直接用最新3.x,遇到问题优先查官方文档的Migration Guide,不要盲目相信旧博客的代码。如果项目里确实用的是旧版,注意锁定版本号,不要让后续安装把大版本升上去。

7.2 Vue3生命周期与SVG实例的时序问题

SVG实例必须在DOM挂载后创建,这句话说起来简单,实际操作中经常有人把初始化逻辑放到setup函数体里直接执行,结果containerRef.value是null,画布怎么都出不来。另一个常见问题是:从后端异步拿到的配置数据到达时,SVG实例还没建好,渲染函数里直接draw.group()会报错。

稳妥的做法是在onMounted里先初始化画布,再通过watch或异步回调触发渲染。如果配置数据到达时机不可控,可以在一个状态里记录isDrawReady,渲染函数里先判断:

function safeRender() { if (!draw) return renderAll() }

7.3 坐标漂移:viewBox和拖拽的配合

有个比较隐蔽的问题:启用panZoom之后,如果用户放大了画布,某些版本的draggable插件在拖拽时会出现坐标偏移,节点会“跳”到另一个位置。这是因为鼠标事件返回的是屏幕坐标,而viewBox改变了之后,屏幕坐标和逻辑坐标的换算关系变了。

SVG.js较新的插件版本已经处理了这个问题,但如果你用的插件版本较旧,可以手动换算:

function screenToSvg(clientX, clientY) { const rect = containerRef.value.getBoundingClientRect() const scaleX = draw.viewbox().width / rect.width const scaleY = draw.viewbox().height / rect.height return { x: (clientX - rect.left) * scaleX + draw.viewbox().x, y: (clientY - rect.top) * scaleY + draw.viewbox().y } }

实测下来的经验是:先升级插件版本,大部分坐标漂移问题都能解决。只有项目被锁死在旧版时才需要手动换算,不然维护成本很高。

7.4 资源释放与内存泄漏

Vue组件销毁时,SVG画布如果不清理,事件监听和动画循环会一直存在,造成内存泄漏。尤其是用了loop()的动画,组件销毁后还在跑,非常浪费性能。

onBeforeUnmount里一定要做清理:

onBeforeUnmount(() => { if (draw) { draw.off('panZoom') draw.clear() draw = null } elementMap.clear() })

draw.clear()会移除画布上的所有元素并清理相关事件,draw = null方便GC回收。如果你在全局事件或定时器里引用了SVG实例,也要一并清掉。这些细节在长期运行的监控大屏项目里特别重要,页面来回切换几次,内存如果不释放,用户会明显感觉到卡顿。

从我个人经验来看,用Vue3 + SVG.js做电力系统拓扑图,最大的收益不是“代码少写了多少行”,而是整个渲染逻辑变得可预期了。设备图元就是工厂函数加数据,连线就是两端节点的坐标映射,状态更新就是通过ID找到图元然后改颜色。这套模式一经理顺,后续加新设备类型、新交互、新业务数据,都是沿着同一套路径扩展,不会出现牵一发而动全身的情况。

如果你正在做的项目还停留在手搓SVG的阶段,我建议可以抽一个不复杂的页面试试这套组合,画一个母线、挂两台变压器、再接几个断路器,体验一下拖拽和状态刷新带来的直观反馈。等跑通之后,你会发现电力拓扑图这件事,其实没有想象中那么难。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询