
简介一份基于原生JavaScript实现的组态软件源码包面向Web前端与工业自动化开发者用于在无后台依赖环境中通过拖拽方式快速构建电力一次接线图、工业流程图等图形界面。压缩包共1013个文件约14.4MB以svg、png、gif图形素材为主配合js逻辑、json配置及html/css等文件涵盖图标、样式、字体与示例代码目录结构完整便于二次开发。该软件不依赖React、Vue等框架体积轻量、加载迅速后端只需按约定数据格式返回即可对接也可作为插件融入现有Web应用素材中大量的svg/png/gif图元覆盖开关、电机、管道等常见工业元素便于直接修改或扩展组件库。已有190人学习适合熟悉JavaScript、期望提升图形化配置与工业自动化技能的开发者学习实践可从中掌握原生JS组件封装、拖拽交互和数据接口设计思路。1. 原生 JS 组态软件没有后台的监控界面数据格式才是核心门槛我自己做过几套工控看板最深的体会是组态软件也就是配置监控画面那种系统市面上大部分都是重型产品要么带服务端要么依赖特定运行时。而这个标题点出的方向很直接——用原生 JavaScript 实现组态软件前端一整套编辑器加运行环境不搭后台、不部署服务后端只需要按照约定好的数据格式把值送过来就行。没有后台意味着整个工程可以静态部署甚至双击 index.html 就能运行这对中小型监控项目、演示 Demo、设备调试界面来说开发成本和交付门槛都降了一大截。适合谁适合手里已经有数据源、不想碰服务端、只需要快速拼出监控画面的前端工程师或集成商。但要注意没有后台不代表没有约束数据格式定得清不清楚直接决定这个方案能不能落地。2. 组态软件的架构是怎么拆的画布、图元、数据绑定三件套2.1 原生 JS 实现的画布系统不用框架怎么管理状态用原生 JS 做组态软件第一关不是绘图而是状态管理。没有 Vue/React 的响应式机制图元的位置、尺寸、属性变化全要自己管。常见做法是维护一个顶层数据模型类似const project { version: 1.0, canvas: { width: 1920, height: 1080, bgColor: #1e2a38 }, widgets: [], // 所有图元实例 variables: {}, // 变量表运行期数据都映射到这 connections: [] // 连线关系 };绘制用 Canvas 2D 还是 SVG我一般建议组态软件用 SVG 做图元渲染因为图元需要响应鼠标事件、需要单独变色/位移SVG 的 DOM 特性天然支持这些操作Canvas 适合做高性能大屏动画但图元命中检测要自己写组态场景里图元数量一般不超过几百个SVG 完全扛得住。画布层用svg标签每个图元是一个g分组拖拽、选中、缩放通过监听 pointer 事件完成。状态变更后手动调用render()重绘整个场景这是原生方案最朴素也最可控的路径。图元的抽象很关键。每个图元至少要有 id、type、x、y、width、height、属性集形状、颜色、文字、绑定变量。比如一个阀门图元{ id: widget_001, type: valve, x: 320, y: 180, width: 60, height: 60, props: { color: #3b82f6, label: 进水阀, rotate: 0 }, binding: { // 运行期数据绑定dataSource 表示变量名attribute 表示绑定的属性 dataSource: valve_status, expression: value 1 ? #10b981 : #ef4444 } }2.2 编辑器与运行时的关系一套数据两个环境组态软件的内在逻辑可以拆成编辑态和运行态。编辑态负责拖拽图元、配置属性、保存工程运行态负责接收实时数据、更新画面。数据模型必须同一套编辑器改的就是运行时要用的那份 JSON。这个设计约束很关键图元属性里哪些是可运行期动态改的颜色、文字、位置、角度哪些是静态的图元类型、id在一开始就要分清楚。编辑器里常见的操作是选中图元后在右侧属性面板修改值。原生 JS 里可以监听 input 事件直接改 project.widgets 里面对象的属性然后重绘。保存就是把这个 project 对象序列化成 JSON 文件加载就是 JSON.parse 后重建场景。这里有个坑如果你把函数写进 JSON比如表达式字符串加载后要 eval 执行或者用 Function 构造器转换。表达式字符串是组态软件的数据格式里最灵活也最危险的部分后面避坑章细说。2.3 连线与拓扑组态画面里能不能做流程式连线许多组态场景需要表示管道、流程、电气连接。连线不能用静态 SVG 直线糊弄得支持拖动端点、自动吸附图元连接点。做法是给每个图元注册连接点anchors比如矩形四边中点各一个连线存储时记录起点图元 id 连接点名和终点图元 id 连接点名运行时再根据两边位置动态绘制路径{ id: conn_001, from: { widgetId: widget_001, anchor: right }, to: { widgetId: widget_002, anchor: left }, style: { stroke: #94a3b8, strokeWidth: 2, dashArray: 4 2 } }渲染时遍历连接数组找到对应图元锚点的坐标用二次贝塞尔曲线画路径数据变化时比如某个变量为 1 管道变绿单独更新这条 path 的 stroke 属性即可。这套方案比用 Canvas 画线要省事得多也不需要引入图形库几十行代码就能完成路径计算。3. 后端返回的数据格式没有后台数据从哪进画面3.1 一个约定好的 JSON 包装格式标题里强调后台需要根据数据格式返回——这意味着前端定好了数据契约后端照着给数据就行。我见过的组态软件常见做法是定一个统一的数据帧格式不管底层是 MQTT、WebSocket、HTTP 轮询还是 Modbus 网关转发最终到前端都是一个 JSON// 后端返回的数据帧示例 { ts: 1710000000000, // 时间戳毫秒 values: { valve_status: 1, // 阀门状态1开 0关 pipe_pressure: 2.5, // 管道压力 MPa tank_level: 68.3, // 液位百分比 motor_speed: 1450, // 电机转速 alarm_list: [ // 报警数组可绑定到报警列表组件 { code: OVP, text: 过压报警 } ] } }前端运行时的做法是维护一个变量映射表把values里的每个 key 记录下来当收到新数据帧时遍历每个图元的 binding 配置找到对应变量名重新计算绑定的属性值并更新视图。这套交互非常像 Vue 的响应式但我们是手动 diff 的只对比变量值有没有变变了就更新对应图元没变就跳过。3.2 三种常见的数据接入方式轮询、WebSocket、模拟数据没有后台意味着前端要自己去拉数据。根据部署场景不同我一般推荐三种接入方式表格数据接入方式对比方式适用场景实现要点注意点HTTP 定时轮询数据变更不频繁几秒一次够用setInterval fetch更新变量表请求频率别太高避免被打WebSocket毫秒级刷新、大量实时告警连接建立后 onmessage 里解析数据帧断线重连机制要写心跳要处理本地模拟数据演示、开发调试、无后端环境封装一个 mock 数据源setInterval 改 values模拟数据也要走同一个 parse 流程避免上线时换数据源出问题选择轮询还是长连接不能只看实时性还要看这个组态页面部署在哪。组态大屏通常跑在监控室的内网大屏机上网络环境稳定WebSocket 更合适如果是边缘盒子通过 4G 上报轮询反而更稳因为长连接容易被运营商断开。我一般会把数据源封装成统一接口//>// 简单映射变量值直接写入属性 function applyBindingSimple(widget, variableMap) { if (!widget.binding) return; const variableValue variableMap[widget.binding.dataSource]; if (variableValue ! undefined) { widget.props[widget.binding.attribute] variableValue; } }表达式映射稍微复杂一点但组态画面的很多效果靠它实现// 表达式映射支持三元运算符、比较运算、数值转换 function applyBindingExpression(widget, variableMap) { const rawValue variableMap[widget.binding.dataSource]; // 示例表达式value 1 ? #10b981 : #ef4444 const expression widget.binding.expression.replace(/\bvalue\b/g, JSON.stringify(rawValue)); try { const result Function(use strict; return (${expression});)(); widget.props[widget.binding.attribute] result; } catch (e) { console.warn(表达式执行失败, e); } }这里Function构造器代替 eval 是比较安全的做法它只执行表达式不会污染外部作用域。但要提醒一句表达式的执行环境是在浏览器端如果这个组态平台要开放给第三方制作工程文件那么表达式字符串执行就是一个潜在攻击面——恶意表达式理论上可以读取页面数据。所以表达式解析尽量用白名单替换限制只能用value作为变量名且不允许出现document、window、globalThis等关键字。这块是组态软件安全绕不开的点。4. 从零搭一个可复用的组态画布核心函数与参数细节4.1 图元注册机制怎么扩展自定义图形组态软件的图元不能写死在代码里要做成注册表模式。每种图元本质是一个渲染函数接收 props 和坐标返回 SVG 节点。我通常维护一个widgetRegistry// widget-registry.js图元注册表 const widgetRegistry {}; // 注册一个罐体图元 widgetRegistry.tank (props) { const svgNS http://www.w3.org/2000/svg; const g document.createElementNS(svgNS, g); g.setAttribute(transform, translate(${props.x},${props.y})); // 罐体外形一个圆角矩形加底部锥形 const body document.createElementNS(svgNS, rect); body.setAttribute(width, props.width); body.setAttribute(height, props.height); body.setAttribute(rx, 8); body.setAttribute(fill, props.fillColor || #334155); const label document.createElementNS(svgNS, text); label.setAttribute(x, props.width / 2); label.setAttribute(y, props.height / 2); label.setAttribute(text-anchor, middle); label.setAttribute(dominant-baseline, middle); label.textContent props.label || ; g.appendChild(body); g.appendChild(label); return g; }; // 渲染单个图元 function renderWidget(widget) { const renderFn widgetRegistry[widget.type]; if (!renderFn) { console.warn(未注册的图元类型${widget.type}); return null; } const node renderFn(widget.props); node.dataset.widgetId widget.id; node.classList.add(widget-item); return node; }4.2 画布缩放与坐标换算拖动缩放不跳变组态画布最常见的翻车点就是缩放后鼠标事件错位。因为给图元绑定的是 DOM 事件而画布整体有transform: scale()时event.offsetX/offsetY拿到的坐标和实际图元坐标不一致。解决方法有两种一是每个事件里用getScreenCTM()逆向换算坐标系二是在 SVG 外层包一个 div不对 SVG 本身做 scale 变换而是用 CSS transform 作用于外层容器。后者更简单但鼠标坐标换算依旧要处理。我常用的做法是写一个坐标转换函数// coordinate.js缩放后坐标转换 function toCanvasCoord(clientX, clientY, svgElement) { const pt new DOMPoint(clientX, clientY); const matrix svgElement.getScreenCTM(); const transformed pt.matrixTransform(matrix.inverse()); return { x: transformed.x, y: transformed.y }; } // 用法拖拽图元时 svg.addEventListener(pointermove, (e) { const { x, y } toCanvasCoord(e.clientX, e.clientY, svg); // 更新图元坐标 updateWidgetPosition(selectedWidget, x, y); });这里核心是getScreenCTM()返回的变换矩阵直接逆变换就把屏幕坐标映射回画布内部坐标。如果不用这个 API 而手动累加缩放比例一旦中间嵌套层级变了或者画布平移过坐标就会越偏越多。血的教训写组态画布尽量第一时间把坐标转换封装好不要等拖拽错位了再补。4.3 属性和事件面板的最小实现编辑态除了画布还有一个属性面板。原生 JS 实现属性面板的思路是根据当前选中图元的类型动态渲染表单type决定有哪些字段可编辑。比如阀门的表单包括 label、color、binding.dataSource、binding.expression罐体的表单包括 fillColor、label、height。表单里每一项>// property-panel.js属性面板更新逻辑 function handlePropChange(path, value) { // path 形如 props.color 或 binding.dataSource const keys path.split(.); let target selectedWidget; for (let i 0; i keys.length - 1; i) { target target[keys[i]]; } target[keys[keys.length - 1]] value; renderScene(); // 重绘画布 }这个做法的优势是表单和渲染完全解耦新增图元类型只需要加一个配置表告诉面板有哪些字段、字段类型文本/颜色/下拉框。做实际工程时你会发现属性面板的配置表设计得好不好直接决定这个组态软件的组态体验像不像商业产品。如果配置表只有字段名没有字段类型后期加下拉选项、数值范围校验就要打补丁所以我建议字段配置里直接带上type: select | color | number | text一次性把规矩定好。5. 避坑指南组态软件从能跑到能用的四个关键坎5.1 表达式执行时全局变量被污染现象执行 binding.expression 时表达式里用value变量结果第二次执行报错或拿到上一次的值。原因用eval执行表达式时value变量泄漏到了全局作用域下一次执行时被其它脚本覆盖或者变量名冲突。解决统一改用Function构造器表达式内部闭包执行变量通过参数传入const fn new Function(value, return expression); const result fn(rawValue);同时表达式中禁止出现value之外的自由变量执行前做一次关键字过滤/document|window|globalThis|process|require/命中则拒绝执行。5.2 拖拽图元松手后位置偏移现象在缩放率不是 100% 时拖拽图元图元松手后跑到了鼠标点不到的位置而且每次拖拽误差累积。原因拖拽过程中直接用了e.offsetX作为画布坐标没有做屏幕坐标到画布坐标的换算缩放比例越大偏移越严重。解决拖拽起始时记录图元初始位置和鼠标在画布内的初始坐标用getScreenCTM().inverse()换算拖拽中计算的是(当前画布坐标 - 起始画布坐标)与图元初始位置叠加而不是直接用鼠标坐标赋值。误差不会累积。这个修正只需要十几行代码但能让整体手感从能用变顺手。5.3 WebSocket 断线后画面不更新且不重连现象组态页面上线运行一段时间后画面数据停止刷新打开浏览器控制台才发现 WebSocket 早已断开而且没有重新连接。原因只写了onmessage和onopen没有处理onclose与onerror。网络波动或服务端重启后连接死亡但前端没有感知。解决我一般封装一个HeartbeatClient类内部维护状态机class HeartbeatClient { constructor(url, onMessage) { this.url url; this.onMessage onMessage; this.heartbeatTimer null; this.reconnectTimer null; this.manualClosed false; } connect() { this.manualClosed false; this.ws new WebSocket(this.url); this.ws.onopen () this.startHeartbeat(); this.ws.onmessage (e) this.onMessage(JSON.parse(e.data)); this.ws.onclose () { clearInterval(this.heartbeatTimer); if (!this.manualClosed) { this.reconnectTimer setTimeout(() this.connect(), 3000); } }; this.ws.onerror () this.ws.close(); // 触发 onclose 进而重连 } startHeartbeat() { this.heartbeatTimer setInterval(() { this.ws.send(JSON.stringify({ type: ping, ts: Date.now() })); }, 15000); } close() { this.manualClosed true; clearTimeout(this.reconnectTimer); this.ws.close(); } }核心思路是onerror里主动close()然后走onclose统一重连逻辑避免错误处理分支分叉。5.4 JSON 数据格式变化导致图元批量失效现象后端一期返回的是value字段二期改成了data字段前端定义的变量映射全部失效画面大面积不动。原因前后端没有统一数据帧格式的版本管理后端改字段时前端不知情或版本不匹配。解决数据帧里加上version或schema字段前端在parseData时先校验版本不匹配就报警提示而不是静默失败。同时变量表里做一道字段别名映射让后端可以按他们习惯的字段名返回前端通过配置别名适配//>// export.html 中内嵌工程数据 window.__PROJECT__ { /* 序列化的完整工程 JSON */ };这样双击 HTML 即进入运行态没有跨域加载本地文件的限制如果直接 fetch 本地 JSON 会被浏览器拦截但 script 内嵌不会。如果要对接真实后端数据就在同一个 HTML 里配置数据源地址通过 WebSocket 或轮询更新变量。另外可以把系统时间本身当作一个变量源组态画面里的时钟、日期、倒计时不需要后端参与用本地setInterval每秒注入一次变量表。这个方向还有一个值得做深的点把运行时和编辑器彻底分离成两个 HTML。编辑器只在开发时使用交付时只给客户运行时页面这样工程文件里的表达式不会被客户随意修改也降低了表达式执行面被攻击的风险。我曾经在交付时遇到客户拿编辑器界面里看到自己不怎么看得懂的 JSON要求再改改颜色结果在编辑器里能调的东西有限客户不满意。后来我把运行时页面做成支持只读模式属性面板隐藏客户直观看到画面又动不了内部结构沟通顺畅了很多。这个经验分享给做组态方向的人编辑器是制作工具运行页面是交付物两者分离越干净后续维护越省心。如果你正打算用原生 JS 做组态软件我的建议是先别急着堆功能把数据格式、图元注册表、增量渲染这三个地基打好后面接什么场景都顺。希望帮到你。本文还有配套的精品资源点击获取