ThingsBoard HTML Container 小部件实战:在 Resources 标签加载 ECharts 构建访客客流分析看板
ThingsBoard HTML Container 小部件实战:在 Resources 标签加载 ECharts 构建访客客流分析看板
本文以 ThingsBoard 前端源码中 HTML Container(HTML 容器)小部件的官方帮助文档及其「访客分析(Visitor Analytics)」示例为骨架,完整讲解如何在 Resources 标签下加载第三方 JavaScript 库(以 ECharts 5 为例),并利用 Plain 模式容器函数、自定义数据订阅与销毁回调,构建一个具备 KPI 指标、按日/按小时堆叠柱状图和下钻交互的客流分析看板。读完本文,你将掌握 HTML Container 小部件「HTML/CSS/JS/Resources 四件套」的协作方式,以及 ctx 与 container 两个核心参数的真实用法。
一、先理解 HTML Container 小部件的运行模型
HTML Container 是 ThingsBoard 中一个高度自由的 Widget 类型,允许开发者直接提供 HTML 模板、CSS 样式、JavaScript 函数以及外部资源(脚本/CSS),自行渲染整个控件内容。它的两种运行模式定义在 html-container-widget.models.ts:
PLAIN:纯原生 HTML/JS 模式,容器函数签名为function (ctx, container): void,由组件把 HTML 注入容器 DOM 后直接执行函数;ANGULAR:Angular 模板模式,容器函数签名为function (ctx): void,HTML 被编译为动态 Angular 组件(该模式下 Resources 中的脚本可被勾选为模块并动态导入)。
两种模式的默认设置(htmlContainerDefaultSettings)均为空模板、空样式、空函数、空资源列表。小部件实例化时(html-container-widget.component.ts)会先合并默认设置,再进入 loadWidgetResources() 加载资源,资源加载完成之后才会调用 initPlain() 或 initAngular()——这正是官方文档所说「在函数运行前加载第三方库」的实现依据。
二、Resources 标签:加载 ECharts 的唯一入口
2.1 官方帮助文档给出的做法
在 HTML Container 小部件的配置面板中,Resources(资源) 是第一个标签页。官方示例文档 plain_analytics_resources.md 明确指出:在 Resources 标签下添加以下 JavaScript URL,它会在容器函数运行之前加载 ECharts,并把 echarts 暴露为全局变量(通过 window.echarts 访问):
https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js
这段 URL 有两点值得注意:
- 它固定使用 echarts@5 这一主版本,从 jsDelivr CDN 分发;如果你的部署环境是内网或存在 CSP 限制,无法访问公网 CDN,则需要把该文件自托管到可达的静态资源地址,并在 Resources 中换成对应 URL;
- 文档中的
{:code-style="max-height: 400px;"}与{:copy-code}是 ThingsBoard 帮助文档渲染层的标注语法,前者控制帮助弹窗中代码块高度,后者提供「一键复制」按钮,粘贴到小部件配置时不需要保留。
2.2 配置面板中的 Resources 标签
配置面板由 html-container-settings.component.html 实现,包含 Resources / CSS / HTML / JavaScript 四个标签页。Resources 标签的核心交互:
- 每条资源是一个
tb-resource-autocomplete输入框,填写资源 URL,可添加多条、逐条删除(组件右上角有删除按钮); - 当小部件类型切换为 ANGULAR 时,每条资源旁会出现 isModule(作为扩展模块) 复选框;勾选后该资源按 Angular 模块动态加载,普通 URL 资源则按脚本/CSS 静态加载;
- 资源输入框为空时面板显示 "No resources" 提示,并可通过「Add」按钮继续添加。
2.3 源码层面:资源是如何在函数之前加载的
HtmlContainerWidgetComponent 中 loadWidgetResources()(html-container-widget.component.ts)的实现逻辑:
- 遍历
settings.resources,过滤掉标记为isModule的资源; - 对每条资源调用
resources.loadResource(url),并用forkJoin并行等待所有资源加载完成; - 任一资源加载失败会被
catchError捕获,最终汇总为Failed to load widget resource: '<url>'这样的错误信息抛给handleWidgetException,在小部件上直接以「Widget Error」红色面板呈现; - 全部成功后才进入
ngOnInit的成功分支,执行 Plain/Angular 初始化。
因此,Resources 中声明的 ECharts 一定先于你的容器函数执行完毕——函数里直接 const echarts = window.echarts; 取全局即可,无需再关心异步加载时序。这也是「CDN 库 + 容器函数」组合的官方推荐姿势,同一机制还被同目录下的看板(Kanban)示例 plain_kanban_resources.md 用于加载拖拽库。
三、访客分析示例的完整组成
官方为「Visitor analytics」示例提供了 HTML、CSS、JavaScript、Resources 四个文件,构成一个可直接复用的完整小部件模板。其业务场景是:读取所有 peopleCount 类型设备上报的 peopleCount 时序数据,统计营业时段内的客流总量、最忙日期、高峰小时与日均客流,并以按设备堆叠的柱状图展示每日/每小时趋势。
3.1 HTML:控件骨架与控件 ID 约定
plain_analytics_html.md 定义了看板结构:头部标题区 + 日期/营业时间控制区、四个 KPI 卡片、图表卡片(含图表标题与「← Back」返回按钮):
<div class="vc">
<header class="vc__head">
<div>
<h3 class="vc__title">Visitor analytics</h3>
<span class="vc__sub">all peopleCount devices</span>
</div>
<div class="vc__controls">
<label class="vc__field"><span>From</span><input type="date" id="vcStart" /></label>
<label class="vc__field"><span>To</span><input type="date" id="vcEnd" /></label>
<label class="vc__field"><span>Open</span><input type="time" id="vcOpen" value="09:00" /></label>
<label class="vc__field"><span>Close</span><input type="time" id="vcClose" value="18:00" /></label>
</div>
</header>
<div class="vc__kpis">
<div class="vc-kpi"><span class="vc-kpi__label">Total visitors</span><span class="vc-kpi__value" id="vcTotal">—</span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Busiest day</span><span class="vc-kpi__value" id="vcPeakDay">—</span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Peak hour</span><span class="vc-kpi__value" id="vcPeakHour">—</span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Daily average</span><span class="vc-kpi__value" id="vcAvg">—</span></div>
</div>
<div class="vc__card">
<div class="vc__card-head">
<span class="vc__card-title" id="vcChartTitle">Visitors by day</span>
<button class="vc__back" id="vcBack" hidden>← Back</button>
</div>
<div class="vc__chart" id="vcChart"></div>
</div>
</div>
要点:所有可交互/可更新的元素都带独立 id,JavaScript 通过 container.querySelector(sel) 统一取用,这是一套清晰的自定义控件命名约定。
3.2 CSS:弹性布局与 KPI 卡片样式
plain_analytics_css.md 提供完整样式:外层 .vc 使用 flex 纵向布局并铺满高度(height: 100%)、KPI 区使用四列 grid、图表卡片 flex: 1 自动撑满剩余空间,日期/时间输入框带焦点高亮,返回按钮通过 [hidden] 属性控制显隐。其中 box-sizing: border-box 与 height: 100% 的组合保证小部件容器内不产生滚动溢出,是自定义 HTML 小部件样式的通用要点。
3.3 JavaScript:容器函数完整逻辑拆解
plain_analytics_js.md 中的容器函数是整个示例的核心,也是理解 Plain 模式编程模型的最佳范本。下面按职责拆解。
取全局 ECharts 与 DOM 工具:函数第一行 const echarts = window.echarts; 直接消费 Resources 加载的全局变量;随后定义 el(sel) 封装 container.querySelector、pad/isoDate/dayLabel 日期工具、minutes 时间换算与 6 色调色板。
初始化图表与自适应:echarts.init(el('#vcChart')) 创建实例并设置 tooltip(axis 触发 + 阴影指针)、legend、grid 与空轴;关键一步是用 window.ResizeObserver 监听图表容器尺寸变化并调用 chart.resize(),保证小部件拉伸、仪表板网格变化时图表跟随自适应——在自由编辑的仪表板中,这比固定监听窗口 resize 更可靠。
KPI 统计与图表重算(recompute):recompute() 读取营业时间(开/闭店时刻),将 lastEntries 中每条设备数据的 [ts, value] 时间戳换算为日期索引并累加,一次性算出四个 KPI:
- 总客流 = 营业时段内所有值求和(写入
#vcTotal); - 最忙日 =
dayTotals最大值对应的日期标签(#vcPeakDay); - 高峰小时 =
perHour中累计值最大的小时(#vcPeakHour,显示为HH:00); - 日均客流 = 总量除以有数据的日期数(
#vcAvg)。
同时,buildSeries(labelCount, bucketOf) 为每台设备生成一个 type: 'bar'、stack: 'v' 的堆叠序列:先为每个桶位初始化全零数组,再把该设备数据按 bucketOf 映射的桶索引累加,使所有设备叠加成一根总客流柱。非营业时段(inHours 之外)的数据被排除。
下钻交互(drill-down):图表默认展示「每日」粒度;点击某日柱状条时(chart.on('click')),从 dailyDates 找到对应 ISO 日期,切换到「每小时」粒度、显示返回按钮并重算;点击 #vcBack 则回到日粒度。这是 ECharts 事件监听与自定义 DOM 控件状态机结合的典型做法。
自定义数据订阅(subscribe):Plain 模式小部件不会自动绑定数据源(帮助文档 container_plain_fn.md 明确提示「没有来自小部件配置的 ctx.data」),必须显式创建订阅:
const subOpts = {
type: 'timeseries',
useDashboardTimewindow: false,
timeWindowConfig: {
selectedTab: 1,
history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } },
aggregation: { type: 'NONE', limit: 50000 },
},
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => { lastEntries = subscription.data || []; recompute(); },
onDataUpdateError: (subscription, e) => console.error(e),
},
};
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
这段配置展示了 ctx.subscriptionApi 的核心能力:useDashboardTimewindow: false 表示忽略仪表板顶部时间窗,改用 fixedTimewindow 固定历史区间(起始/结束毫秒时间戳由日期输入框换算);aggregation.type: 'NONE' 关闭聚合、limit: 50000 限制最大点数;数据源用 deviceType 实体过滤器圈定所有 peopleCount 类型设备、resolveMultiple: true 解析为多条实体数据,键名取时序遥测 peopleCount。数据更新回调把原始条目存入 lastEntries 并触发 recompute()。
默认值与事件绑定:加载时默认把日期范围设为「本月 1 号 → 今天」;From/To 输入框变更时重置下钻状态并重新 subscribe()(因为换了数据区间),Open/Close 变更时只重算不过滤(数据已全量拉取)。
销毁清理(registerDestroyCallback):示例在末尾通过 ctx.registerDestroyCallback(() => {...}) 注册清理回调,移除订阅、断开 ResizeObserver、chart.dispose() 释放 ECharts 实例。这与 widget-component.models.ts 中 WidgetContext 维护 destroyCallbacks 数组、小部件销毁时统一执行的机制对应——帮助文档也反复强调:你创建的每个订阅、定时器和全局事件监听都必须从这里释放,否则仪表板反复加载小部件会造成资源泄漏。
四、Plain 模式容器函数的签名与 ctx 能力
官方帮助文档 container_plain_fn.md 给出了 Plain 模式函数的正式契约:
function (ctx, container): void
- ctx:
WidgetContext实例,提供完整的小部件 API——发送 RPC、切换仪表板状态、创建数据订阅、读取小部件设置、访问平台服务等;subscriptionApi字段定义在 widget-component.models.ts; - container:小部件内容元素,即渲染模板所包裹的原生 DOM 节点
<div class="tb-absolute-fill">,直接用container.querySelector(...)查询; - 返回值:无,所有工作通过操作 DOM 和调用
ctxAPI 完成。
源码实现上,Plain 函数的编译与执行位于 html-container-widget.component.ts:小部件用 parseTbFunction(this.ctx.http, this.settings.js, <a href="https://link.gitcode.com/i/2dae4fb7c3b5e3318980c6dea5d3f4ba" target="_blank">'ctx', 'container']) 解析函数体,随后 containerFunction.execute(this.ctx, this.containerElmRef.nativeElement) 把 WidgetContext 与容器 DOM 作为参数传入。注意函数签名与传入参数顺序严格对应 ['ctx', 'container'],这也是编辑器里 ctx、container 两个自动补全参数的来源(见 [html-container-widget.models.ts)。
initPlain()(html-container-widget.component.ts)的执行顺序是:先用 cssjs 解析 CSS 并以样式内容 hash 作为命名空间注入 <style> 元素、再通过 $(...).html(settings.html) 注入 HTML 模板、最后编译执行 JS 函数——CSS 与 HTML 同样先于函数生效。
五、从示例到实战的要点清单
- 资源先行:所有第三方库(ECharts、拖拽库等)统一放在 Resources 标签,容器函数内直接消费
window上的全局变量,不要在函数内再动态插入<script>; - 固定时间窗:客流分析这类场景用
useDashboardTimewindow: false+fixedTimewindow屏蔽仪表板全局时间窗,才能保证图表与日期输入框语义一致; - 数据上限:
aggregation.limit决定了单键最大数据点数,与日期跨度、上报频率相关,跨月数据应适当调大或开启聚合; - 内存安全:任何
createSubscription都必须配对removeSubscription,并统一收口在ctx.registerDestroyCallback中;ECharts 实例记得dispose(); - 设备过滤:
deviceType过滤器加resolveMultiple: true是「按设备类型批量取数」的标准写法,后续新增同类型设备会自动纳入统计,无需改小部件。
这套「Resources 加载 CDN 库 + Plain 模式容器函数 + 自定义订阅 + 销毁回调」的组合,是 ThingsBoard HTML Container 小部件构建高度定制化分析控件的官方样板,上述四个示例文件(HTML/CSS/JS/Resources)均可直接在对应小部件配置面板的四个标签中粘贴复用以快速验证效果。