异步数据处理
DarraRT 的 HMI 常面临 高频变量 场景: PLC 每秒推送数百个变量、趋势图累积数万点、报警列表滚动不停。本章讲如何在浏览器前端异步处理这些数据流, 避免 UI 卡顿。
注: 这里的"数据处理"是 PLC 变量的前端处理, 不是业务数据 ETL, 也不涉及订单/客户/物料等业务管道。
卡顿根源
典型卡顿来源:
| 根源 | 症状 | 解法 |
|---|---|---|
| 主线程 JS 计算阻塞 | 点击无响应, 动画卡 | Web Worker |
| DOM 频繁更新 | 滚动抖动, FPS 掉 | requestAnimationFrame / 虚拟列表 |
| Canvas 重绘 | 趋势图更新慢 | 双缓冲 / OffscreenCanvas |
| 内存泄漏 | 数小时后页面崩溃 | IndexedDB 归档 + 定期 GC |
| 网络反复重连 | 数据跳跃 | darra.on('offline') 切到缓存 |
darra.bus: 内置事件总线
darra-plc.js 在 v1.5+ 暴露了 darra.bus 作为内部事件总线, 多组件共享同一份订阅, 避免重复绑定:
// 模块 A
darra.bus.on('var:MD.Temp', v => updateGauge(v));
// 模块 B
darra.bus.on('var:MD.Temp', v => updateTrend(v));
// 模块 C 只在需要时订阅, 不需要时 off
const handler = v => updateExport(v);
darra.bus.on('var:MD.Temp', handler);
// ... 关闭面板时
darra.bus.off('var:MD.Temp', handler);
框架内部把 N 个 bind 合并成一次 WebSocket 订阅, 降低服务端开销。
Web Worker 处理趋势数据
10 万点趋势直接在主线程 downsample 会冻结 UI 数百毫秒。迁移到 Worker:
Worker 侧 (trend-worker.js)
// 接收主线程消息
self.onmessage = (e) => {
const { cmd, data } = e.data;
switch (cmd) {
case 'downsample':
// 输入 100000 点 [{t, v}, ...], 输出 1000 点
const result = lttb(data.points, 1000); // Largest-Triangle-Three-Buckets
self.postMessage({ cmd: 'downsampled', result });
break;
case 'stats':
const stats = computeStats(data.points);
self.postMessage({ cmd: 'stats', stats });
break;
}
};
function lttb(points, targetCount) {
if (points.length <= targetCount) return points;
// LTTB 算法: 每个桶保留最能代表形状的点
// ... 省略实现 ...
return sampled;
}
function computeStats(points) {
let min = Infinity, max = -Infinity, sum = 0;
for (const p of points) {
if (p.v < min) min = p.v;
if (p.v > max) max = p.v;
sum += p.v;
}
return { min, max, avg: sum / points.length, count: points.length };
}
主线程调用
const worker = new Worker('/static/js/trend-worker.js');
let pending = null;
worker.onmessage = (e) => {
const { cmd, result } = e.data;
if (cmd === 'downsampled') {
drawTrend(result); // 1000 点, Canvas 很快
pending = null;
}
};
function onPointsArrive(newPoints) {
allPoints.push(...newPoints);
if (allPoints.length > 200_000) {
// 超 20 万点截断前半
allPoints = allPoints.slice(100_000);
}
if (!pending) {
pending = true;
worker.postMessage({ cmd: 'downsample', data: { points: allPoints }});
}
}
Worker 处理期间 UI 完全不冻结, 动画仍 60 FPS.
IndexedDB 缓存历史
离线时让用户仍能看到"最后 N 分钟"数据:
// 打开数据库
const db = await new Promise((res, rej) => {
const req = indexedDB.open('darra-cache', 1);
req.onupgradeneeded = (e) => {
const d = e.target.result;
if (!d.objectStoreNames.contains('points')) {
const os = d.createObjectStore('points', { keyPath: 'id', autoIncrement: true });
os.createIndex('by_var_ts', ['var', 'ts']);
}
};
req.onsuccess = e => res(e.target.result);
req.onerror = e => rej(e.target.error);
});
// 保存一批点
async function saveBatch(records) {
return new Promise((res, rej) => {
const tx = db.transaction('points', 'readwrite');
const os = tx.objectStore('points');
for (const r of records) os.add(r);
tx.oncomplete = res;
tx.onerror = e => rej(e.target.error);
});
}
// 查询过去 N 秒
async function queryRecent(varName, seconds) {
const since = Date.now() - seconds * 1000;
return new Promise((res, rej) => {
const tx = db.transaction('points');
const idx = tx.objectStore('points').index('by_var_ts');
const range = IDBKeyRange.bound([varName, since], [varName, Infinity]);
const out = [];
idx.openCursor(range).onsuccess = (e) => {
const cur = e.target.result;
if (cur) { out.push(cur.value); cur.continue(); }
else res(out);
};
tx.onerror = e => rej(e.target.error);
});
}
结合 WebSocket
let buffer = [];
darra.on('variable', ({ name, value, ts }) => {
buffer.push({ var: name, v: value, ts: ts || Date.now() });
if (buffer.length >= 500) {
saveBatch(buffer);
buffer = [];
}
});
darra.on('offline', async () => {
const lastTemp = await queryRecent('MD.Temp', 300); // 过去 5 分钟
drawTrend(lastTemp);
showBanner('离线模式, 显示最后 5 分钟缓存');
});
去抖与节流
高频变量 HMI 不需要每次变化都重绘:
去抖 (debounce)
function debounce(fn, ms) {
let t = null;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
const saveConfig = debounce(() => {
darra.write('DB_Config.Value', currentValue);
}, 500);
// 用户拖滑块, 停止 500ms 后才写入 PLC
slider.addEventListener('input', e => {
currentValue = e.target.value;
saveConfig();
});
节流 (throttle)
function throttle(fn, ms) {
let last = 0, pending = null;
return (...args) => {
const now = Date.now();
if (now - last >= ms) {
last = now; fn(...args);
} else if (!pending) {
pending = setTimeout(() => {
last = Date.now(); pending = null; fn(...args);
}, ms - (now - last));
}
};
}
const updateGauge = throttle(value => gaugeEl.update(value), 100);
darra.bind('MD.Temp', updateGauge);
虚拟列表
报警列表 10000 条同时渲染会卡死 DOM。虚拟列表只渲染可见的 20 行:
<div class="virtual-list" x-data="virtualList(alarms, 32)"
@scroll="onScroll"
:style="`height: 640px; overflow-y: auto`">
<div :style="`height: ${totalHeight}px; position: relative`">
<template x-for="(a, i) in visibleItems" :key="a.id">
<div class="row"
:style="`position: absolute; top: ${(startIdx + i) * 32}px; height: 32px`">
<span x-text="a.time"></span>
<span x-text="a.message"></span>
</div>
</template>
</div>
</div>
function virtualList(items, rowHeight) {
return {
items,
rowHeight,
scrollTop: 0,
viewHeight: 640,
get totalHeight() { return this.items.length * this.rowHeight; },
get startIdx() { return Math.floor(this.scrollTop / this.rowHeight); },
get visibleItems() {
const count = Math.ceil(this.viewHeight / this.rowHeight) + 2;
return this.items.slice(this.startIdx, this.startIdx + count);
},
onScroll(e) { this.scrollTop = e.target.scrollTop; }
};
}
10000 条 <= 100 行 DOM 活跃, 滚动丝滑。
requestAnimationFrame
所有重绘聚集到下一帧, 避免一次性多次 reflow:
let queued = false;
let pendingData = null;
darra.bind('MD.Temp', v => {
pendingData = v;
if (!queued) {
queued = true;
requestAnimationFrame(() => {
gaugeEl.textContent = pendingData.toFixed(1);
queued = false;
});
}
});
浏览器 16.7 ms 一帧, 期间多次变更只合并成一次 DOM 更新。
OffscreenCanvas
Chrome 支持把 Canvas 渲染也搬到 Worker, 主线程不掉帧:
// 主线程
const canvas = document.getElementById('trend');
const off = canvas.transferControlToOffscreen();
worker.postMessage({ cmd: 'init', canvas: off }, [off]);
// Worker 内
let ctx = null;
self.onmessage = (e) => {
if (e.data.cmd === 'init') ctx = e.data.canvas.getContext('2d');
else if (e.data.cmd === 'points') drawPoints(ctx, e.data.points);
};
内存监控
长时间运行 HMI 要防内存泄漏:
if (performance.memory) {
setInterval(() => {
const mb = performance.memory.usedJSHeapSize / 1024 / 1024;
if (mb > 500) {
console.warn('内存占用', mb.toFixed(0), 'MB');
// 触发清理: 清空旧趋势数据、关闭不活动面板
purgeOldPoints();
}
}, 30_000);
}
性能测试基线
| 场景 | 目标 | 测量方式 |
|---|---|---|
| 订阅 500 个变量 | CPU <20% | Chrome DevTools Performance |
| 10 万点趋势 | 首绘 <200ms, 更新 <50ms | LTTB in Worker |
| 报警列表 10000 条 | 滚动 60 FPS | 虚拟列表 |
| 页面连续运行 24h | 内存增长 <100MB | performance.memory |
| 切换页面 | <500ms | Chrome Performance |