ThingsBoard HTML 容器组件实战:用 Plain JavaScript 构建可拖拽的设备状态看板(Kanban)
ThingsBoard HTML 容器组件实战:用 Plain JavaScript 构建可拖拽的设备状态看板(Kanban)
本篇技术指南围绕 ThingsBoard HTML 容器组件(HTML Container widget) 的 Plain 模式展开,以官方帮助文档中的 plain_kanban_js.md 示例为核心,完整演示如何编写一个基于纯 JavaScript(Plain JS)的看板(Kanban)函数:通过 CDN 引入 SortableJS 实现卡片拖拽,借助 ctx.subscriptionApi 实时订阅设备属性数据,并在拖拽落点后通过 ctx.attributeService.saveEntityAttributes 将状态写回设备。读完本文,你将掌握 HTML 容器 Plain 模式函数的完整编写范式,包括 DOM 渲染、数据订阅、事件回写与资源清理,可直接在 ThingsBoard 仪表盘中落地一个可交互的设备状态看板。
一、背景:HTML 容器组件的两种运行模式
在 ThingsBoard 前端中,HTML 容器组件位于 html-container-widget.component.ts,其设置由 html-container-widget.models.ts 定义,核心配置项包括:
type:运行模式,PLAIN(纯 JavaScript)或ANGULAR(Angular 组件);html:容器内渲染的 HTML 模板;css:作用域化 CSS 样式;js:驱动整个组件的 JavaScript 函数;resources:外部资源(JS 库或 Angular 模块)。
从源码可见(html-container-widget.component.ts),组件在 ngOnInit 中先加载外部资源,随后根据 type 分派到 initPlain() 或 initAngular()。本文的看板示例走的是 PLAIN 分支:initPlain() 会先把自定义 CSS 注入为带命名空间的样式、将配置的 HTML 写入容器 DOM,再调用 compileAndExecutePlainFunction() 编译执行 JS 函数。
Plain 模式函数的签名定义为 (ctx: WidgetContext, container: HTMLElement) => void(见 html-container-widget.models.ts)。它通过 parseTbFunction(this.ctx.http, this.settings.js, <a href="https://link.gitcode.com/i/f2f7f5ca0b64ce144f2a76a8a8d93a5b" target="_blank">'ctx', 'container']) 编译并传入两个参数([html-container-widget.component.ts):
ctx(WidgetContext):当前 Widget 实例的上下文句柄,提供数据订阅、属性读写、RPC、仪表盘状态切换等全部能力;container(HTMLElement):组件的内容 DOM 节点(<div class="tb-absolute-fill">),模板被渲染在其中,可通过container.querySelector(...)定位内部元素。
一个关键前提来自官方帮助文档 container_plain_fn.md:Plain 模式不会自动绑定组件配置的数据源,ctx.data 不会被填充。要读取实时数据,必须显式调用 ctx.subscriptionApi.createSubscription(...);同时,所有在函数中创建的自定义订阅、定时器、全局事件监听,都应在 ctx.registerDestroyCallback(...) 中释放,以保证组件销毁时正确清理。
二、示例总览:看板的组成文件
官方为"设备状态看板"提供了四份配套帮助文档,同属一个示例,均位于 html_container/examples 目录下:
| 文件 | 作用 |
|---|---|
| plain_kanban_resources.md | 在组件 Resources 配置页添加 SortableJS CDN 地址 |
| plain_kanban_html.md | 容器内静态 HTML:一个空的看板根节点 |
| plain_kanban_css.md | 看板的样式表(列、卡片、状态圆点、拖拽反馈) |
| plain_kanban_js.md | 核心:JavaScript 容器函数(本文主体) |
整个看板的数据流是:JS 函数发起对 machine 类型设备 status 属性的订阅 → 数据到达后渲染为三列看板卡片 → 用户拖拽卡片到新列 → onAdd 回调把新状态通过属性服务写回设备 → 订阅刷新后界面保持一致。
三、Resources 配置:加载 SortableJS
在 HTML 容器组件的设置面板中,通过 Resources 页签添加外部 JavaScript 库。官方示例使用的地址为:
https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js
该库会在函数执行前完成加载,并将 Sortable 暴露为全局对象(window.Sortable),因此 JS 函数内可直接引用。从组件源码的 loadWidgetResources() 可以看到,resources 中非模块类型的 JS 资源会被逐个预加载,加载失败时会把错误信息汇总抛出,并在组件界面显示 Widget Error。这也意味着:如果 CDN 不可达,整个看板函数不会执行,生产环境建议把资源托管到可达的内网地址。
四、HTML 与 CSS:看板的静态骨架与样式
HTML 部分(plain_kanban_html.md)极其简洁,只有一个容器占位节点,列与卡片全部由 JS 动态生成:
<div class="kanban" id="board"></div>
CSS 部分(plain_kanban_css.md)定义了 .kanban 横向 flex 布局、.kanban__col 列、.kanban__card 卡片、状态圆点 .kanban__dot--idle/--active/--maintenance,以及 SortableJS 拖拽过程中的反馈样式:
.kanban {
display: flex;
gap: 12px;
height: 100%;
box-sizing: border-box;
padding: 16px;
overflow-x: auto;
background: #f9fafb;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #111827;
font-size: 13px;
}
.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; }
.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; }
.kanban__title { font-size: 12px; font-weight: 600; color: #374151; }
.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; }
.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; }
.kanban__card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); }
.kanban__name { font-weight: 500; color: #111827; }
.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.kanban__dot--idle { background: #9ca3af; }
.kanban__dot--active { background: #22c55e; }
.kanban__dot--maintenance { background: #f59e0b; }
.kanban__card.sortable-ghost { opacity: 0; }
.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
由于 initPlain() 会调用 cssjs 为配置的 CSS 生成基于内容哈希的命名空间(html-container-<hash>)并创建 <style> 元素(html-container-widget.component.ts),上述类名只会作用于当前 Widget 实例,不会污染仪表盘上其他组件。
五、核心:看板的 JavaScript 容器函数
以下完整代码来自官方文档 plain_kanban_js.md(其中 {:code-style=...}、{:copy-code} 是帮助系统渲染代码块的元标签,不是 JS 语法):
// Plain HTML mode: `container` is the widget's DOM. Build a status board and,
// on drop, persist the card's new column (status) back to the device.
const Sortable = window.Sortable; // loaded from the CDN added under Resources
const statuses = ['idle', 'active', 'maintenance'];
const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' };
const board = container.querySelector('#board');
let devices = []; // { id, entityType, name, status }
// Escape untrusted strings (e.g. device names) before putting them in innerHTML.
const esc = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
function render() {
board.innerHTML = statuses
.map((s) => {
const items = devices.filter((d) => d.status === s);
const cards = items
.map(
(d) =>
`<div class="kanban__card" data-id="${esc(d.id)}" data-type="${esc(d.entityType)}"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__name">${esc(d.name)}</span></div>`,
)
.join('');
return `<section class="kanban__col">
<header class="kanban__head"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__title">${labels[s]}</span><span class="kanban__count">${items.length}</span></header>
<div class="kanban__cards" data-status="${s}">${cards}</div>
</section>`;
})
.join('');
// Make each column a Sortable list; cards share one group so they move between columns.
board.querySelectorAll('.kanban__cards').forEach((listEl) => {
new Sortable(listEl, {
group: 'kanban',
animation: 150,
onAdd: (evt) => {
const id = evt.item.dataset.id;
const entityType = evt.item.dataset.type;
const status = evt.to.dataset.status; // destination column
const dev = devices.find((d) => d.id === id);
if (dev) dev.status = status;
ctx.attributeService
.saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }])
.subscribe();
},
});
});
}
// Live data: devices of type "machine" with their "status" attribute.
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'attribute', name: 'status', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
const status = last ? String(last[1]) : 'idle';
return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' };
});
render();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
// The JS re-runs on every reload — capture the id and remove the subscription on destroy.
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
5.1 静态状态定义与安全渲染
函数顶部先定义看板的三列状态 statuses 及对应显示名称 labels,随后用 container.querySelector('#board') 拿到容器内的看板根节点。
esc() 是一个重要的安全细节:设备名等数据来自外部且不可信,直接拼入 innerHTML 存在 XSS 风险。esc 对 & < > " ' 五个字符做 HTML 实体转义,在渲染卡片时对所有动态字段(d.id、d.entityType、d.name)统一包裹:
`<div class="kanban__card" data-id="${esc(d.id)}" data-type="${esc(d.entityType)}">...`
注意 data-id、data-type 用于在拖拽事件中反查实体,而状态圆点类名 kanban__dot--${s} 中的 s 来自受控的状态数组而非外部输入,因此不需要转义。
5.2 render():数据驱动的 DOM 重建
render() 每次被调用都会用当前 devices 数组重建整个看板:
- 遍历
statuses,用devices.filter((d) => d.status === s)取出该列的设备; - 为每台设备生成一张卡片(含状态圆点、设备名、实体 ID 与类型的数据属性);
- 组装为列结构:
.kanban__col→.kanban__head(圆点 + 列名 + 计数徽章)+.kanban__cards(承载卡片,data-status记录目标状态); - 对每个
.kanban__cards元素实例化一个Sortable列表,设置group: 'kanban'让所有列共享同一拖拽分组(卡片可在列间移动),animation: 150提供 150ms 的平滑过渡动画。
拖拽落点的核心逻辑在 onAdd 回调中:从被拖拽卡片的 dataset.id / dataset.type 取出实体信息,从目标列 evt.to.dataset.status 取出新状态,先更新本地 devices 缓存,再调用属性服务持久化:
ctx.attributeService
.saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }])
.subscribe();
ctx.attributeService 是 WidgetContext 暴露的平台服务之一(定义见 widget-component.models.ts)。saveEntityAttributes 将 { key: 'status', value: status } 以 SERVER_SCOPE(服务端作用域)写入指定实体——设备重启后该状态依然保留在平台端,不会被客户端上报覆盖。
5.3 显式数据订阅:订阅 machine 设备的 status 属性
Plain 模式不会自动绑定数据源,因此这里通过 ctx.subscriptionApi.createSubscription(subOpts, true) 建立显式订阅。subOpts 的关键字段:
type: 'latest':最新数据类型,订阅每个实体当前的最新属性值;datasources[0].type: 'entity':实体型数据源;entityFilter:使用deviceType过滤器,筛选deviceTypes: ['machine']的设备,deviceNameFilter: ''不按名称过滤,resolveMultiple: true表示解析为多个设备(即返回所有匹配实体);dataKeys声明需要status属性;callbacks.onDataUpdated:每次数据更新时被触发。回调把subscription.data映射为本地devices数组——从每个 entry 的datasource取entityId、entityType、entityName,从entry.data的最后一条记录取最新属性值(last[1]),并做健壮性兜底:无数据时默认idle,非法状态值(不在statuses中)也归为idle;callbacks.onDataUpdateError:数据更新异常时输出错误日志。
数据到达后调用 render() 重建界面,从而形成"平台数据 → 看板渲染"的实时闭环。
5.4 生命周期管理:销毁时清理订阅
这是 Plain 模式函数最容易遗漏、也最需要严格遵循的规范(官方在 container_plain_fn.md 中反复强调):
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
- 由于 JS 函数在每次组件加载时都会重新执行,必须先记录
subscription.id; - 再通过
ctx.registerDestroyCallback(...)注册销毁回调(该 API 定义于 widget-component.models.ts),在 Widget 销毁时调用ctx.subscriptionApi.removeSubscription(subscriptionId)移除订阅。
如果不做这一步,仪表盘每次刷新、切页都会残留订阅,导致数据持续推送与内存泄漏。同理,函数中创建的定时器、window 级事件监听器也应在此回调中清理。
六、完整落地步骤与运行前提
在 ThingsBoard 仪表盘中复刻该看板的操作流程如下:
- 添加 HTML 容器组件:编辑仪表盘 → 添加 Widget → 选择 HTML 容器(Html Container)类型;
- 配置 Resources:在设置面板的 Resources 页签中加入
https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js,并确保运行环境能访问该地址(离线/内网环境请替换为内网可达的静态资源 URL); - 粘贴 HTML:将第四节中的
<div class="kanban" id="board"></div>填入 HTML 配置框; - 粘贴 CSS:将第四节样式表填入 CSS 配置框;
- 粘贴 JS:将第五节完整函数填入 JavaScript 配置框;
- 准备数据:平台中需要有
machine类型的设备,并带有status属性(值为idle/active/maintenance之一)。组件本身不需要配置数据源——Plain 模式的订阅完全由函数内部发起。
运行前提与限制总结:
- 组件类型必须为 Plain HTML(
type: PLAIN),Angular 模式下函数签名不同(只有ctx,见 html-container-widget.models.ts); deviceTypes: ['machine']是硬编码过滤条件,如需展示其他设备类型请同步修改entityFilter;- 状态集合与 CSS 圆点类名(
--idle/--active/--maintenance)是成对约定的,新增状态时需同步更新statuses、labels、kanban__dot--*样式三处; - 拖拽写回的
SERVER_SCOPE属性对设备名称等静态信息无影响,仅覆盖服务端属性作用域。
七、小结
通过 plain_kanban_js.md 这个示例,可以提炼出 ThingsBoard HTML 容器 Plain 模式函数的标准四步范式:
- 取容器:从
container参数定位模板 DOM; - 显式订阅:用
ctx.subscriptionApi.createSubscription获取实时数据,绝不依赖自动绑定的ctx.data; - 安全渲染 + 事件回写:
innerHTML渲染时对不可信字段做 HTML 转义,交互事件中用ctx.attributeService等平台服务写回; - 注册销毁回调:用
ctx.registerDestroyCallback释放订阅、定时器与全局监听,防止泄漏。
配合 container_plain_fn.md 的参数说明(ctx 提供 RPC、状态切换、订阅与平台服务能力;container 是组件内容 DOM),这套范式可以进一步推广到图表联动、表单回写、地图标注等任意自绘交互组件——只需将本文的"看板三列"替换为你的业务模型即可。