用JavaScript和HTML5打造Web组态软件:渲染、数据驱动与工程实践 简介基于JavaScript与HTML5构建的组态软件项目源码适合Web前端开发者、组态软件学习者及工业/楼宇自动化领域的技术人员参考用于理解如何通过现代浏览器技术实现图形化配置界面与动态交互逻辑。压缩包共316个文件整体约4.29MB核心代码以33个js脚本、10个html页面和12个css样式为主配合121个png、75个gif等图像资源还包含scss/less预编译样式、字体文件、配置文件等目录结构清晰便于按模块阅读与二次开发。目前已有119人学习下载。项目中可看到Canvas绘图、SVG矢量图形、离线存储、WebSocket、多媒体播放等HTML5特性的落地应用同时结合JavaScript的事件监听与异步通信处理展示了从界面布局到逻辑控制的完整实现路径对希望掌握前端可视化开发、人机交互界面搭建或轻量级游戏化场景的读者具有直观的参考价值。1. 基于 JavaScript 和 HTML5 的组态软件把工控画面搬进浏览器交接班看板上那个污水池液位到今天还有人用 iFix 组态软件截图后往微信群里贴。基于 JavaScriptHTML5 创建的组态软件改掉的正是这条链路生产监控画面不再锁在 C/S 客户端里而是打包成一个 zip里面有页面、图元库、配置脚本和启动入口浏览器打开 index.html 就能进设计器拖拽图元、绑定变量、跑实时曲线。它解决的问题很具体——免安装客户端、看板多端复用、工程文件用 JSON 描述后可入库可回滚适合有 JavaScript 基础的前端或全栈工程师也适合想用开源方案替代商业组态授权的自动化团队。后面按渲染选型、拖拽与绑定、数据驱动、工程交付、运行时排错逐层展开全部是能落到代码的实现细节。2. HTML5 组态软件的渲染层选型Canvas 和 SVG 怎么分工更合理2.1 图元渲染为什么不能照搬 GDI 思路传统组态软件的画面引擎基于 GDI/GDI——一个图元就是一个绘图对象重画窗口时按 z 序重绘点位数据变化后调用局部刷新接口。把这套模型搬到浏览器时最容易踩的坑是用 DOM 模拟“控件树”每个图元是一个 div点位一变就改一百个 div 的样式帧率立刻难看起来。HTML5 真正有用的不是“能在网页里画画”而是同时提供两类能力SVG 保留对象模型Canvas 提供像素级绘制。组态软件因此可以采用双轨制编辑态用 SVG运行态用 Canvas这也是 fuxa 组态软件等开源项目的主流走向。需要说明的是这不是“哪个技术更强”的问题而是取舍问题。SVG 天然具备对象模型和事件委托图元选中、属性修改、组合缩放都非常顺手Canvas 牺牲了对象模型换来上万图元时可控的绘制开销。WebGL 在纯 3D 厂房可视化里有用但 2D 组态画面里引入它会带来渲染管线和交互层双份复杂度一般只在需要特效时兜底。做决定前先看一张对照表维度SVGCanvas 2DWebGL对象模型有元素即对象无需自建图元 JSON无命中检测原生事件委托手动遍历图元数组手动射线检测数千图元性能DOM 压力逐渐明显绘制耗时稳定高但开发成本高调试友好度可直接查 DOM黑盒黑盒在组态软件中的定位编辑态首选运行态首选3D/特效兜底表格里没有列“渲染质量”因为对 2D 组态来说两者在视觉上几乎没有差别真正的差别在对象更新方式上。2.2 一个 SVG 阀门图元的最小写法组态图元不是一张静态图片而是自带绑定锚点和交互属性的活对象。下面这个阀门图元是设计器里最常见的样子svg idvalve_1 x120 y80 width48 height48>function clientToCanvas(e, canvasRect, scale) { return { x: (e.clientX - canvasRect.left) / scale, y: (e.clientY - canvasRect.top) / scale }; }参数说明canvasRect 来自 getBoundingClientRect()包含画布相对视口的位置和尺寸scale 是当前缩放倍率比如放大到 125% 时传 1.25返回的是逻辑坐标。如果画布内部可滚动还要再减去 scrollLeft/scrollTop否则放大后拖动图元会“跟不上鼠标”。画布模块所有对外接口都以逻辑坐标入参渲染层再统一乘以 scale这样网格吸附、对齐辅助线、框选范围都不需要重复换算。3. 用原生 JavaScript 实现组态画布的拖拽摆放、属性面板与绑定解析3.1 拖拽拆成四步命中、换算、吸附、统一重绘初学者常把拖拽写成每次都修改被点元素的 left/top这在带缩放和网格的组态画布上会立刻出错。我一般把拖拽拆成四步pointerdown 时做命中检测并记录起点pointermove 时把屏幕坐标换算成逻辑坐标并计算位移位移按网格吸附取整最后统一重渲染一次画布而不是单独改写某个 DOM 元素的样式。改用 Pointer 事件而不是 mouse 事件是为了让同一个逻辑覆盖鼠标和触屏——工业现场大量使用带触摸的工控一体机。let dragging null; canvas.addEventListener(pointerdown, (e) { const p clientToCanvas(e, canvas.getBoundingClientRect(), scale); const el pickElement(p); // 按图元数组从后往前做矩形命中检测 if (el) dragging { id: el.id, start: p, origin: { x: el.x, y: el.y } }; }); canvas.addEventListener(pointermove, (e) { if (!dragging) return; const p clientToCanvas(e, canvas.getBoundingClientRect(), scale); const dx Math.round((p.x - dragging.start.x) / 5) * 5; const dy Math.round((p.y - dragging.start.y) / 5) * 5; updateElement(dragging.id, { x: dragging.origin.x dx, y: dragging.origin.y dy }); renderOnce(); // 合并到同一帧渲染 }); canvas.addEventListener(pointerup, () (dragging null));代码说明pickElement 遍历图元数组从最上层数组尾部向前找第一个坐标落进的矩形吸附粒度 5 是常用值改小到 1 就是无吸附。renderOnce 内部用 requestAnimationFrame 做节流连续的 pointermove 事件只触发一次真实绘制。这样 300 个图元的画布拖拽也能保持流畅。组态软件里多选框选也走同一套坐标换算区别只在命中检测从“点是否在矩形内”变成“矩形是否与矩形相交”。3.2 属性面板与画布之间用 Proxy 做统一同步右侧属性面板修改颜色、旋转角度或变量名后画布必须立刻刷新反过来选中另一个图元时面板也要回显当前值。为了避免散落的 setter 调用可以用 Proxy 拦截组态数据模型const store new Proxy(project, { set(target, key, value) { target[key] value; syncPropsPanel(value); // 面板刷新当前属性 invalidateCanvas(); // 标记画布需要重绘 return true; } });逻辑说明project 是当前打开工程的 JavaScript 对象set 拦截器把“改数据 → 刷界面”统一收口视图层不再各自监听点击事件去改模型。invalidateCanvas 只做标记等下一帧再渲染这个模式在组态软件里比直接同步重绘更稳——一次属性面板的连点操作可能触发几十次 set不需要每个中间值都上屏。深层次的图元数组变化比如拖动坐标用浅层 Proxy 拦不到我的做法是操作完数组后手动调用一次 notify或者给数组包一层自定义方法和 Proxy 的 set 并存。3.2.1 配置合并注意深拷贝陷阱图元默认配置与用户配置合并时最常见的坑是浅合并导致嵌套属性被覆盖。比如默认样式是{ style: { fill: #fff, stroke: #000 } }用户只改 fill 时如果直接展开对象stroke 会丢。用 Object.assign 或展开运算符只适合一层合并嵌套的 style、animation 配置需要手写递归合并或直接让默认对象提供缺省字段。JavaScript 合并两个对象这类问题在组态工程配置里遇到得非常多封装一个 deepMerge 工具函数比到处打补丁划算同时配合 JSON Schema 校验图元类型保证未知字段不会混入配置。3.3 绑定表达式走白名单解析不执行任意代码绑定不复杂但要设计得克制。设计师选中图元后在属性面板输入var:TANK_LEVEL表示“这个图元的填充色跟随 TANK_LEVEL 的值变化”。运行时引擎把var:前缀之后的部分当作变量名到变量表里取值function evalBinding(value, getVar) { if (typeof value string value.startsWith(var:)) { return getVar(value.slice(4)); } return value; }说明getVar 是变量表的取值函数传入变量名返回{ v, ts, q }对象。这里刻意不引入 eval 和 new Function因为组态工程文件是外部导入的一旦允许任意表达式就等同于把页面变成了脚本执行入口。需要自定义逻辑时把脚本能力收敛到一组受控函数表单里而不是开放表达式解析。绑定解析先取出变量名颜色映射、闪烁条件、数值缩放都放在图元渲染器里做渲染器只认最终值不关心值从哪个表达式来。4. JavaScript 组态画面实时数据驱动变量表与 WebSocket 订阅引擎4.1 变量表是数据流的中枢浏览器端组态软件至少要维护一张内存变量表。变量表负责回答“这个变量现在是什么值、新不新鲜、能不能正常显示”。完整记录包含值、时间戳和质量戳三个字段用 JavaScript 对象加 Map 实现如下const vt new Map(); function setVar(name, v) { const prev vt.get(name); const stale prev Date.now() - prev.ts 5000; vt.set(name, { v, ts: Date.now(), q: stale ? 7 : 0 }); } function getVar(name) { return vt.get(name) || { v: null, ts: 0, q: 9 }; }逻辑说明setVar 写入新值时检查上一条记录的时间戳如果已有值超过 5 秒没更新本次到达的值会被标记 q7表示链路中断过画面显示值但附黄色质量标记q9 表示从来没有任何数据图元整体置灰。质量戳这套字段在传统组态点表里叫 QSTAWeb 端实现时很多人会省略它但现场一旦出现通讯闪烁有没有质量戳决定了排查时间是一小时还是一整天。值、时间戳、质量戳各自的用途如下字段含义使用场景v当前值文本显示、曲线绘制、颜色映射ts最新更新时间戳判断数据是否过期q质量戳0 正常过期点显示灰色、断线闪烁等4.2 订阅总线把高频点位压进同一帧渲染WebSocket 收到一条采集消息就立刻更新一个 DOM 节点点位数一多必然卡顿。常见做法是在数据接收层和渲染层之间加一个订阅总线同一帧内的多次数据更新合并为一次批量派发class VarBus { constructor() { this.listeners new Map(); this.pending new Set(); this.tick null; } subscribe(name, fn) { if (!this.listeners.has(name)) this.listeners.set(name, new Set()); this.listeners.get(name).add(fn); } publish(name) { this.pending.add(name); if (this.tick) return; this.tick requestAnimationFrame(() { const names Array.from(this.pending); this.pending.clear(); this.tick null; for (const n of names) { const set this.listeners.get(n); if (set) for (const fn of set) fn(getVar(n)); } }); } } const bus new VarBus();代码说明publish 被调用时不立即回调而是把变量名加入 pending 集合统一在下一帧回调里批量派发。如果一个变量在一帧内被更新了 20 次订阅者只会收到一次回调且拿到的是最新值。这样一屏 200 个点位同时刷新时DOM 写入次数从 200 次降为一帧一次批量更新。高频波形类点位100ms 级刷新可以绕过这个总线直接让 Canvas 绘制循环读变量表避免每个点都生成一次函数调用。4.3 内存历史数据用环形缓冲回放与过滤都方便历史曲线不需要完整数据库浏览器端环形缓冲就能支撑近一小时的实时曲线。写入时覆盖头部位置读取时做一次拼接得到时间正序数组const RING new Float64Array(3600); let head 0; function append(v) { RING[head] v; head (head 1) % RING.length; } function timeSeries() { return Array.from(RING.slice(head)).concat(Array.from(RING.slice(0, head))); }参数说明3600 个点按 1 秒一个采集点存储可覆盖 1 小时的数据约 28KB 内存对现代浏览器可以忽略。取出时间序列后如果想画最近 10 分钟可以直接用 JavaScript 的 filter 函数按时间戳过滤或者用 slice 截取末尾 600 个元素。环形缓冲的写入和拼接都是 O(1) 或 O(n)对组态画面完全够用。如果现场要保留跨班次的历史曲线再把数据按批次抄送到后端时序库前端环形缓冲只负责当前画面实时预览。5. JavaScript 组态工程交付zip 结构、白名单安全与浏览器兼容检查5.1 纯静态交付一个 index.html 加一个静态服务组态项目打包成 zip 后应保持最小自包含结构入口页、图元模块、资源配置和运行引擎。下面的目录是一个常见布局hmi/ index.html assets/ viewer.js designer.js config/ variables.json project.json modules/ valve.js pump.js启动时只需一个静态文件服务# Node 环境 npx serve . # Python 环境 python -m http.server 8000参数说明npx serve 默认端口 3000python -m http.server 默认端口 8000浏览器访问时须与启动端口一致。直接用 file:// 协议打开 index.html 时fetch 加载 config 目录下的 JSON 会被同源策略拦截所以 README 里必须写清楚两种服务方式。生产部署在工控机上时我更愿意配一个 Nginx 静态站点把 dist 目录指到 root既不需要后端也不需要构建服务设备断电重启后页面恢复得非常干净。5.2 绑定表达式白名单校验杜绝图元脚本注入安全扫描报告里出现“检测到目标站点存在 javascript 框架库漏洞”这类问题时组态软件要从两条路径堵一是依赖库升级到无已知漏洞版本二是禁止工程文件中的绑定表达式携带可执行代码。绑定点只允许形如var:XXX的写法并对变量名做正则校验const VAR_RE /^[A-Z_][A-Z0-9_]{0,63}$/i; function sanitizeBinding(expr) { if (!expr.startsWith(var:)) return null; const name expr.slice(4); return VAR_RE.test(name) ? name : null; }说明合法变量名限定为字母开头、最长 64 字符的字母数字下划线组合这套规则兼容常见 PLC 点名。不满足规则的绑定直接丢弃并在设计器控制台报警而不是在运行时 try-catch——组态画面加载阶段就应把不合法配置拦在门外。渲染层写入变量值统一用 textContent不使用 innerHTML避免变量值本身携带标记符号时造成二次注入。提示组态工程文件来自不同厂商和现场人员任何时候都不要对导入的 JSON 或表达式执行 eval 或 new Function。白名单解析虽然少了一些灵活度但换来的是交付后不用半夜起来处理画面变白的问题。5.3 禁用 JavaScript 与老浏览器的兼容处理HTML5 组态应用完全依赖 JavaScript 运行没有降级成静态图的可行方案。入口页面需要放一个 noscript 提示浏览器禁用 JavaScript 时直接说明原因!DOCTYPE html html langzh-CN headmeta charsetUTF-8titleHMI 运行屏/title/head body div idapp/div noscript本组态页面需要启用 JavaScript 才能运行/noscript /body /html同时做能力检测无 WebSocket、无 canvas 的环境给一个明确的不兼容页面不要等到运行时报错再排查。动画性能方面同样要盯紧三个点优先用 transform 和 opacity 动画而不是 top/left只给高频更新且确实需要独立绘制的图元加 will-change: transform避免滥用合成层导致内存膨胀不要在 rAF 循环里读取布局属性再写样式那会触发强制布局抖动表现是 CPU 占用不高但画面一卡一顿。用 Performance 面板录制运行时紫色 Rendering 时间占比超过 20% 就应回查这些点。6. JavaScript 组态运行时排错绑定不生效先看数据流画面卡死先看绘制循环现场报障最集中的两类问题是“数据不动了”和“画面卡死”两类问题排查路径完全不同不应一起改代码。数据不动的排查顺序应该是变量值根本没进来、变量值进来了但质量戳非 0、绑定拼写不一致、渲染层把新值吞了。给订阅总线上加一个调试开关能快速定位在 bus.publish 里打印变量名、值和时间戳。日志里有值但画面不动问题在绑定名或渲染函数日志里没有值问题在采集链路重点检查 WebSocket 重连策略和协议字段大小写。另一个省事技巧控制台直接输入Object.entries(vt)输出当前所有变量的值和质量戳比在渲染层打断点快得多。画面卡死先看 Performance 面板的绿色 Script 和紫色 Rendering 占比。绿色 Script 高说明渲染循环逻辑过重最常见的元凶是每帧都在做深拷贝、创建新函数或拼接复杂样式字符串解决思路是把解析结果缓存下来只在变量名或配置变化时重新生成渲染指令。紫色 Rendering 高说明 DOM 写入太频繁除了合并订阅总线外还要检查是否把不变化的图元也全部重绘了一个按图元 ID 和值做 diff 的缓存可以省掉大量无效绘制。最后一个可以直接拿去用的技巧给运行态加一个调试模式按住 Shift 点击画面时把命中的图元在 SVG 上描红边框并在控制台输出该图元的 ID、data-bind 和最近一次渲染耗时。组态软件交付后现场人员有时候说不出是哪个阀门灯不亮只会说“下面那块屏幕有个图标不对”这个功能能让他们在三分钟内把对象 ID 发给你。所有调试代码都封装在window.HMI_DEBUG下生产构建默认关闭性能开销为零。改完参数后再用 Performance 面板录一段 30 秒对照修复前后的 Script/Rendering 占比确认没有引入新的重绘路径后再发新包。本文还有配套的精品资源点击获取