news 2026/10/14 22:55:25

原生JS组态软件实战:无后台监控界面的数据格式与架构设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原生JS组态软件实战:无后台监控界面的数据格式与架构设计

简介:一份基于原生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 做组态软件,我的建议是先别急着堆功能,把数据格式、图元注册表、增量渲染这三个地基打好,后面接什么场景都顺。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 22:55:08

SpringBoot启动流程全解析:从注解到内嵌Tomcat就绪

1. 从入口主函数到容器就绪&#xff1a;一次启动到底经历了几个阶段坦白说&#xff0c;我第一次把 SpringBoot 项目跑到Started Application in X.XXX seconds这句日志时&#xff0c;只觉得很爽&#xff0c;根本没想过这行字背后到底做了什么。直到有一次线上环境启动后接口迟迟…

作者头像 李华
网站建设 2026/10/14 22:54:30

Juniper EX交换机Zabbix监控模板:SNMP OID到告警的落地实践

简介&#xff1a;为Zabbix用户提供了一套开箱即用的Juniper EX系列交换机监控模板&#xff0c;适用于需要统一管理企业网络设备的管理员。资源包共3个文件&#xff0c;以XML模板文件为主&#xff0c;辅以Markdown说明文档和License授权文件&#xff0c;压缩包整体仅10KB&#x…

作者头像 李华
网站建设 2026/10/14 22:54:20

AI采购合规实战:从需求梳理到合同条款的完整指南

1. 为什么AI采购的合规问题值得提前布局很多团队在采购AI工具时&#xff0c;第一反应是看功能、比价格、测效果&#xff0c;合规这件事往往被排在最后。等到法务部门发来风险提示&#xff0c;或者审计环节被卡住&#xff0c;才发现之前省下的那点采购预算&#xff0c;根本不够填…

作者头像 李华
网站建设 2026/10/14 22:53:11

大模型写代码如何省token又保智商?分层配置与混合调度实战

1. 选型困局&#xff1a;省钱和聪明之间的真实博弈“写代码&#xff0c;希望节省token&#xff0c;但智商要高怎么选配置”——这个问题我在过去大半年里被问过不下几十次。问的人有刚入行的新手&#xff0c;也有带团队多年的老手&#xff0c;大家的痛点出奇一致&#xff1a;预…

作者头像 李华
网站建设 2026/10/14 22:53:06

通用工作Agent崛起:模型与芯片如何降格为可替换后端

1. 从"模型即产品"到"模型即插槽"的认知转变过去两年&#xff0c;绝大多数团队在构建智能体应用时&#xff0c;默认把大模型当成整个系统的"心脏"——选定了某个模型&#xff0c;整个技术栈、提示词体系、工具调用格式、甚至业务逻辑都围绕它来设…

作者头像 李华
网站建设 2026/10/14 22:51:52

银企直联对接工商银行:商业资料包如何减少联调返工

简介&#xff1a;这份资源聚焦工商银行银企直联的接口开发与系统对接&#xff0c;面向ERP、财务及资金管理系统开发者与实施人员&#xff0c;帮助解决企业内外部系统与工行核心系统、网银或现金管理平台之间的实时结算与信息查询问题。压缩包共2000个文件&#xff0c;约28.47MB…

作者头像 李华