ThingsBoard HTML Container 组件实战:用纯 HTML/CSS/JS 打造访客分析仪表板
ThingsBoard HTML Container 组件实战:用纯 HTML/CSS/JS 打造访客分析仪表板
本篇技术指南围绕 ThingsBoard 的 HTML Container 组件(Plain 纯模式) 展开,以官方帮助文档中的 “Visitor analytics(访客分析)” 完整示例为骨架,逐步拆解其 HTML 结构、CSS 样式、JavaScript 驱动函数与 ECharts 资源配置,并结合仓库源码讲解 ctx/container 函数签名、subscriptionApi 数据订阅与销毁回调等底层机制。读完本文,你将能够独立在 ThingsBoard 仪表板中搭建一个支持时间范围筛选、营业时段过滤、按设备堆叠统计与日/小时下钻的纯前端分析型 HTML 组件。
一、什么是 HTML Container 组件
在 ThingsBoard 中,html_container 是一种特殊类型的自定义 widget。它不再像普通 widget 那样由系统自动绑定数据源,而是把 HTML 模板 + CSS + JavaScript 函数 + 外部资源 四者完全交给你来控制,适合构建高度定制的可视化容器。
从源码看,该组件位于 html-container-widget.component.ts,它的模板非常简单:
<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>
即一个占满 widget 内容的 <div class="tb-absolute-fill"> 容器节点。它支持两种模式(定义于 html-container-widget.models.ts,由 html-container-settings.component.html 中的 tb-toggle-select 切换):
- Plain(纯)模式:JS 函数签名为
function (ctx, container),直接操纵原生 DOM,适合引入 CDN 的第三方库(如 ECharts); - Angular 模式:JS 函数签名为
function (ctx),可以使用 Angular 模板语法与组件。
本文的 Visitor analytics 示例采用 Plain 模式,通过 CDN 加载 ECharts,是这一模式最典型的应用。
二、示例整体结构与配套文件
官方帮助文档把“访客分析”示例拆成了四个部分(均位于 examples 目录):
| 文件 | 作用 |
|---|---|
| plain_analytics_html.md | 组件 DOM 结构(标题、时间/时段筛选控件、KPI 卡片、图表容器) |
| plain_analytics_css.md | 全部样式,完成布局、配色与交互态 |
| plain_analytics_js.md | 驱动函数:ECharts 初始化、数据订阅、KPI 计算、图表渲染与下钻 |
| plain_analytics_resources.md | 引入的 ECharts CDN 资源地址 |
示例的业务场景是:对一类名为 peopleCount 的设备做访客统计——统计指定日期范围内(可自定义起止日期与每日营业/打烊时段)的总访客数、最忙的一天、高峰时段与日均值,并按设备堆叠展示每日/每小时的访客柱状图,点击某天还能下钻到小时粒度。
三、HTML 模板详解
以下是该示例的完整 HTML 代码(原文位于 plain_analytics_html.md):
<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>
结构上分为三层,每个元素都有明确职责:
- 头部(
.vc__head):左侧标题 + 副标题,右侧四个表单控件——#vcStart/#vcEnd:日期范围(type="date"),决定订阅哪段历史数据;#vcOpen/#vcClose:营业时段(type="time",默认 09:00–18:00),只对数据做过滤,不重新订阅。
- KPI 区(
.vc__kpis):四张卡片,初始值均为—,等待 JS 计算后填充:#vcTotal总访客数、#vcPeakDay最忙日期、#vcPeakHour高峰时段、#vcAvg日均访客。
- 图表卡片(
.vc__card):#vcChartTitle动态标题、#vcBack返回按钮(默认hidden,仅在小时下钻时显示)、#vcChart作为 ECharts 挂载点。
注意一个细节:图表标题和返回按钮也被 id 化了,因为 JS 需要在下钻/返回时动态改写标题文本并切换按钮可见性,说明 Plain 模式下 DOM 完全是 JS 的“数据通道”。
四、CSS 样式详解
完整样式(原文位于 plain_analytics_css.md):
.vc {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
box-sizing: border-box;
padding: 20px;
background: #fff;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
}
.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; }
.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; }
.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; }
.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; }
.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; }
.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); }
.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; }
.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; }
.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; }
.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; }
.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; }
.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; }
.vc__back[hidden] { display: none; }
.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; }
.vc__chart { flex: 1; min-height: 200px; }
几点实战说明:
.vc根节点使用height: 100%配合flex-direction: column,保证 widget 内容区被完整撑满,这是 Plain 模式下自绘组件必做的“全屏适配”;- KPI 区用
grid-template-columns: repeat(4, 1fr)四等分,在窄屏下仍可保持等宽; :focus状态与.vc__back的 hover 态定义了统一的品牌色#2f6bff,让控件与仪表板主题保持一致;.vc__back[hidden] { display: none; }是必须的——原生hidden属性在部分场景下会被 CSS 覆盖,这里显式兜底,确保返回按钮默认不可见。
五、JavaScript 驱动函数详解
这是整个示例的灵魂。完整代码(原文位于 plain_analytics_js.md):
// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN.
const echarts = window.echarts;
const el = (sel) => container.querySelector(sel);
const pad = (n) => String(n).padStart(2, '0');
const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const minutes = (v) => {
const [h, m] = (v || '0:0').split(':').map(Number);
return h * 60 + m;
};
const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9'];
const chart = echarts.init(el('#vcChart'));
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { top: 0 },
grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true },
xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } },
yAxis: { type: 'value' },
series: [],
});
const resizeObserver = new window.ResizeObserver(() => chart.resize());
resizeObserver.observe(el('#vcChart'));
let lastEntries = [];
let mode = 'daily';
let selectedDate = null;
let dailyDates = []; // iso date per daily x-index, for drill-down
// Click a day → drill into its hourly breakdown.
chart.on('click', (p) => {
if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return;
selectedDate = dailyDates[p.dataIndex];
mode = 'hourly';
el('#vcBack').hidden = false;
recompute();
});
el('#vcBack').addEventListener('click', () => {
mode = 'daily';
selectedDate = null;
el('#vcBack').hidden = true;
recompute();
});
// One stacked series per device, summing values into the given bucket index.
function buildSeries(labelCount, bucketOf) {
return lastEntries.map((entry, k) => {
const arr = new Array(labelCount).fill(0);
(entry.data || []).forEach(([ts, value]) => {
const i = bucketOf(new Date(Number(ts)));
if (i != null && i >= 0) arr[i] += Number(value);
});
return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } };
});
}
function recompute() {
const open = minutes(el('#vcOpen').value);
const close = minutes(el('#vcClose').value);
const inHours = (d) => {
const m = d.getHours() * 60 + d.getMinutes();
return m >= open && m < close;
};
// KPIs over the whole range (per day, summed across devices).
const start = new Date(el('#vcStart').value + 'T00:00:00');
const end = new Date(el('#vcEnd').value + 'T00:00:00');
const dayIndex = {};
const dayLabels = [];
dailyDates = [];
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
dayIndex[isoDate(t)] = dayLabels.length;
dailyDates.push(isoDate(t));
dayLabels.push(dayLabel(t));
}
const dayTotals = dayLabels.map(() => 0);
const perHour = {};
let total = 0;
lastEntries.forEach((entry) =>
(entry.data || []).forEach(([ts, value]) => {
const d = new Date(Number(ts));
if (!inHours(d)) return;
const i = dayIndex[isoDate(d)];
if (i === undefined) return;
const v = Number(value);
dayTotals[i] += v;
perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v;
total += v;
}),
);
el('#vcTotal').textContent = total;
el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—';
const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0];
el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—';
el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1));
// Chart: daily (stacked by device) or the hourly breakdown of the selected day.
if (mode === 'daily') {
el('#vcChartTitle').textContent = 'Visitors by day';
const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null));
chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] });
} else {
const h0 = Math.floor(open / 60);
const h1 = Math.ceil(close / 60);
const labels = [];
for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00');
el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00'));
const series = buildSeries(labels.length, (d) =>
isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null,
);
chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] });
}
}
// (Re)subscribe for the chosen date range (a fixed history window).
let subscriptionId = null;
function subscribe() {
if (subscriptionId != null) {
ctx.subscriptionApi.removeSubscription(subscriptionId);
subscriptionId = null;
}
const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime();
const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime();
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;
});
}
// Changing the range resets the drill-down and re-subscribes; hours just re-filter.
function onRangeChange() {
mode = 'daily';
selectedDate = null;
el('#vcBack').hidden = true;
subscribe();
}
// Defaults: the current month (1st → today).
const now = new Date();
el('#vcEnd').value = isoDate(now);
el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1));
el('#vcStart').addEventListener('change', onRangeChange);
el('#vcEnd').addEventListener('change', onRangeChange);
el('#vcOpen').addEventListener('change', recompute);
el('#vcClose').addEventListener('change', recompute);
subscribe();
// The JS re-runs on every reload — release the subscription, observer and chart.
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
resizeObserver.disconnect();
chart.dispose();
});
下面按逻辑分层解读。
5.1 工具函数与图表初始化
echarts取自window.echarts——因为资源是通过 CDN 以普通<script>方式加载的,会挂到window上(详见第六节);el(sel)是对container.querySelector的封装,之后所有 DOM 操作都通过它完成;isoDate/dayLabel/minutes分别用于格式化日期、生成短标签、把"HH:mm"转成分钟数;palette定义了 6 色序列,用于按设备区分堆叠柱状图的颜色;- 图表初始化后即注册
ResizeObserver监听#vcChart,保证 widget 尺寸变化(仪表板拉伸、全屏等)时自动chart.resize()。
5.2 KPI 计算与营业时段过滤
recompute() 是核心计算函数:
- 先读入
#vcOpen/#vcClose,构造inHours(d)判断时间戳是否落在营业时段内(闭区间[open, close),即打烊那一刻不计入); - 遍历
lastEntries(即订阅回调里收到的所有设备数据),把每条[ts, value]数据点按日期归桶:- 累加出
dayTotals(按天、跨设备汇总)、perHour(按小时汇总)和total(总访客); - 营业时段之外的记录直接
return跳过;
- 累加出
- 四个 KPI 依次写入 DOM:
#vcTotal直接显示总数;#vcPeakDay取dayTotals最大值的索引对应日期标签;#vcPeakHour取perHour中计数最高的整点;#vcAvg用总访客 / 有数据的天数并四舍五入。当total为 0 时峰值显示—,避免Math.max(...[])出现-Infinity。
5.3 堆叠柱状图与日→小时下钻
buildSeries(labelCount, bucketOf) 为每个设备生成一个 type: 'bar', stack: 'v' 的堆叠系列:
entry对应订阅结果中的一条数据源,entry.datasource.entityName即设备名,用作图例与系列名;- 每个系列先初始化为全 0 数组,再逐点调用
bucketOf(时间)得到目标桶下标并累加; - 颜色按
palette[k % palette.length]循环取色。
图表交互分为两种模式(变量 mode 控制):
daily(默认):X 轴为日期,柱按设备堆叠;点击任一柱子(chart.on('click'))后记录selectedDate、切换到hourly、显示#vcBack按钮并重算;hourly:X 轴为#vcOpen到#vcClose覆盖的小时区间(h0 = floor(open/60)到h1 = ceil(close/60),例如 09:00–18:00 即 09:00…18:00),bucketOf只接受isoDate === selectedDate且处于营业时段的点,用d.getHours() - h0作为桶下标;标题变为 “Visitors by hour — <日期>”。
返回按钮则把 mode 重置回 daily、隐藏自身并重算。setOption(..., { replaceMerge: ['series'] }) 是关键技巧——只替换系列数组而不重建坐标系,避免图例闪烁。
5.4 数据订阅:不依赖 widget 自动绑定
这是 Plain 模式最需要注意的地方。参考 container_plain_fn.md 的明确说明:此类组件不会自动绑定数据源,ctx.data 不会自动填充,必须通过 ctx.subscriptionApi.createSubscription(...) 手动创建订阅。
示例中的 subscribe() 做了几件事:
- 若已有
subscriptionId,先ctx.subscriptionApi.removeSubscription(subscriptionId)释放旧订阅; - 构造订阅选项
subOpts:type: 'timeseries'时序数据;useDashboardTimewindow: false——忽略仪表板右上角的时间窗口,改用自己定义的历史窗口(这正是日期选择控件能独立生效的原因);timeWindowConfig.history.fixedTimewindow指定startTimeMs/endTimeMs(#vcEnd那天取到T23:59:59,保证当天完整);aggregation: { type: 'NONE', limit: 50000 }——不聚合、最多取 50000 条原始数据点,确保 KPI 计算准确;datasources中entityFilter使用deviceType: 'peopleCount'+resolveMultiple: true,即按设备类型筛选出所有peopleCount设备,dataKeys订阅peopleCount时序键;
callbacks.onDataUpdated把subscription.data存到lastEntries并触发recompute();onDataUpdateError打印错误;createSubscription(...).subscribe(...)异步返回订阅对象,回调里记住subscriptionId以便后续释放。
5.5 事件绑定、默认值与销毁
- 默认范围是本月 1 号到今天,页面打开即回填两个日期输入框;
#vcStart/#vcEnd的change触发onRangeChange()——重置下钻状态并重新订阅;#vcOpen/#vcClose的change只触发recompute()——只过滤不重新订阅,这是对“历史数据固定、展示口径可变”场景的极佳优化;- 由于 JS 函数在每次 widget 重载时都会重新执行,最后通过
ctx.registerDestroyCallback释放三样东西:数据订阅、ResizeObserver、ECharts 实例。凡是自行创建的订阅、定时器、全局事件监听器,都必须在销毁回调里清理,否则会造成内存泄漏或重复订阅。
六、Resources 配置:加载 ECharts
由于 Plain 模式不能使用 Angular 模块,第三方库通过 CDN 脚本引入(原文见 plain_analytics_resources.md),在 widget 编辑界面的 Resources(资源) 标签页添加以下 JavaScript URL:
https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js
添加后,组件会在你的 JS 函数执行之前加载该脚本并暴露 echarts 全局变量(即代码中的 window.echarts)。
从源码看,这一机制由 html-container-widget.component.ts 的 loadWidgetResources() 实现:组件在 ngOnInit 里先通过 ResourcesService.loadResource(url) 加载所有 isModule = false 的资源,全部成功后才进入 initPlain() 初始化流程,保证第三方库一定先于业务 JS 就绪。
七、容器函数签名与 WidgetContext
Plain 模式的容器函数签名为:
function (ctx, container): void
官方帮助文档(container_plain_fn.md)对两个参数的说明如下:
ctx:WidgetContext类型,包含 widget 实例所需的全部 API 与数据,可发送 RPC、切换仪表板状态、创建数据订阅、读取设置并访问平台服务。类型定义可查看 widget-component.models.ts;container:HTMLElement,即 widget 内容元素(源码模板中的<div class="tb-absolute-fill">),需要用container.querySelector(...)查询内部节点。
函数不返回任何值。这也是它在编辑器里的形态——settings 面板中 JavaScript 标签页的 <a href="https://link.gitcode.com/i/a5d81a6fba07243ede4126d806f29aef" target="_blank">functionArgs] 就是 ['ctx', 'container'](见 [html-container-settings.component.html)。
八、底层执行流程(源码视角)
把整条链路串起来,Plain 模式组件从配置到渲染的实际流程如下(对应 html-container-widget.component.ts):
ngOnInit合并默认设置与ctx.settings,先调用loadWidgetResources()加载 CDN 资源;- 加载成功后进入
initPlain():- 若配置了 CSS,用
cssjs解析器对 CSS 做 hash 命名空间化(html-container-<hash>),创建独立<style>元素并挂到容器上,避免样式污染仪表板其他部分; - 若配置了 HTML,通过
$(containerElmRef.nativeElement).html(settings.html)注入模板;
- 若配置了 CSS,用
compileAndExecutePlainFunction()用parseTbFunction(ctx.http, settings.js, ['ctx', 'container'])编译 JS 函数并执行——也就是本文第五节那段代码真正开始运行的地方;- 执行过程中任何异常都会进入
handleWidgetException(),在 widget 区域显示错误信息而不是让整个仪表板崩溃。
九、实战要点总结
把官方示例与源码结合,可以得到几条可直接复用的经验:
- 显式订阅:Plain 模式不会自动注入
ctx.data,必须用ctx.subscriptionApi.createSubscription()手动订阅;想让日期控件独立于仪表板时间窗口,记得设useDashboardTimewindow: false。 - 区分“数据口径”与“展示口径”:日期变化需要重新订阅(因为要拉不同区间的历史数据),营业时段变化只需重算图表——这种拆分能显著减少不必要的网络请求。
- 聚合与上限:示例对原始数据用
aggregation: { type: 'NONE', limit: 50000 },保证 KPI 精确;数据量大时可换用AVG/SUM等聚合类型以提升性能。 - 完整清理:订阅、
ResizeObserver、图表实例以及任何自定义事件都要在ctx.registerDestroyCallback中释放。 - 样式隔离:Plain 模式 CSS 会被自动命名空间化,但仍建议使用
vc__这类带前缀的 BEM 式类名,避免与仪表板既有样式冲突。 - 资源先行:CDN 脚本务必填在 Resources 标签页,组件保证它们在业务 JS 之前加载完成。
这套 “HTML + CSS + JS + CDN 资源” 的组合,是 ThingsBoard 中构建定制分析面板、数据可视化看板与自助报表最灵活的路径之一。你可以在此基础上把 peopleCount 换成任意设备类型与数据键,把 ECharts 换成 D3、Three.js 等任何可通过 CDN 引入的库,快速扩展出属于自己的 HTML Container 组件。