ThingsBoard HTML 容器部件 Angular 模式实战:用 Container Function 驱动可交互 Angular 模板
ThingsBoard HTML 容器部件 Angular 模式实战:用 Container Function 驱动可交互 Angular 模板
本篇技术指南聚焦 ThingsBoard 前端(ui-ngx)中 HTML 容器(HTML Container)部件的 Angular 模式:讲解容器函数(Container Function)的签名、WidgetContext 参数能力、this 上下文暴露机制,并结合仓库源码解析其动态编译运行原理。读者读完将掌握如何用一份 JavaScript 函数 + Angular 模板 + CSS 构建主从分栏、抽屉式设备详情、标签页复合看板等可直接运行的高交互部件,以及如何通过 ctx.subscriptionApi 手动订阅数据并正确释放资源。
一、容器函数是什么:Angular 模式与 Plain 模式的差异
HTML 容器部件允许开发者完全自定义一个部件的 HTML 模板、CSS 与 JavaScript 逻辑。部件类型由设置中的 type 字段决定,定义在 html-container-widget.models.ts:
export enum HtmlContainerWidgetType {
PLAIN = 'PLAIN',
ANGULAR = 'ANGULAR'
}
export interface HtmlContainerWidgetSettings {
type: HtmlContainerWidgetType;
html: string;
css: string;
js: TbFunction;
resources: WidgetResource[];
}
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
- Plain 模式:
html是普通 HTML,容器函数签名为function (ctx, container): void,逻辑通过container.querySelector(...)手动操作原生 DOM。 - Angular 模式:
html是一段完整的 Angular 模板,容器函数签名为function (ctx): void,通过把数据和处理器挂到this上供模板直接引用。模板里可以使用*ngFor、*ngIf、(click)、[style.width.px]等全部 Angular 语法,还可以使用mat-drawer、mat-table等 Material 组件与tb-dashboard-state这类 ThingsBoard 内置组件。
两种模式共用同一套设置面板;设置组件 html-container-settings.component.ts 会根据当前 type 值切换不同的代码补全器:Angular 模式下使用 AngularContainerFunctionEditorCompleter(只提示 ctx 一个参数),Plain 模式下使用 HTMLContainerFunctionEditorCompleter(额外提示 container)。这正是"Angular 模式没有 container 参数"在编辑器层面的直观体现。
二、函数签名、返回值与 ctx 参数
2.1 签名
function (ctx): void
一个 JavaScript 函数,通过把这些值赋值给 this 来把数据和处理函数暴露给 Angular 模板;在模板中引用它们时不需要 this 前缀。
返回值为空:本函数不返回任何值,模板中需要用到的所有内容都必须显式挂载到 this 上。
2.2 ctx:WidgetContext 参数
ctx 是 WidgetContext 类的实例,持有部件实例所需的全部 API 与数据,可用来发送 RPC、切换看板状态、创建数据订阅、读取设置以及访问平台服务。从 widget-component.models.ts 源码可以看到其核心能力:
| 能力 | ctx 成员 | 说明 |
|---|---|---|
| 数据订阅 | ctx.subscriptionApi |
createSubscription(options, subscribe) / createSubscriptionFromInfo(...) / removeSubscription(id),接口定义见 widget-api.models.ts |
| RPC 控制 | ctx.controlApi |
sendOneWayCommand / sendTwoWayCommand / completedCommand |
| 看板状态 | ctx.stateController |
updateState(id, params) / getStateParams() / openState(...) / stateChanged() 等,见 state-controller.models.ts |
| 通知 | ctx.showSuccessToast(...) / showWarnToast(...) / showErrorToast(...) 等 |
便捷通知 |
| 平台服务 | ctx.http / ctx.deviceService / ctx.attributeService / ctx.entityRelationService 等 |
直接调用后端 API |
| 工具 | ctx.utils / ctx.httpUtils / ctx.rxjs |
格式化、HTTP 选项、rxjs 全套操作符 |
| DOM 访问 | ctx.$container |
jQuery 包装的容器元素,ctx.$container[0] 即原生根元素 |
此外 ctx 还暴露 settings(当前部件设置)、widgetConfig、currentUser、width / height(部件尺寸)、isEdit / isPreview / isMobile 等运行时信息,以及 date、imagePipe、translate 等 Angular 管道/服务。
三、核心机制:this 即模板上下文
容器函数执行时,它的 this 就是动态创建的 Angular 组件实例本身。函数内所有 this.xxx = ... 的赋值都会成为模板可直接读取的成员;this.handler = () => {} 定义的方法可以直接绑定到模板事件上。
3.1 源码级运行原理
在 html-container-widget.component.ts 中,Angular 模式的完整初始化链路如下:
ngOnInit()合并默认设置后,先执行loadWidgetResources()加载非模块资源(普通 JS/CSS 文件),再执行loadAngularModules()加载标记为模块(isModule: true)的资源——这是 Angular 模式下可以引入自定义 Angular 模块的关键。compileAngularFunction()调用parseTbFunction(this.ctx.http, this.settings.js, ['ctx'])编译容器函数,注意参数列表只有['ctx'],与 Plain 模式的['ctx', 'container']形成对比,从代码层面印证了"Angular 模式没有 container 参数"。initAngularComponent()通过dynamicComponentFactoryService.createDynamicComponent(...)以settings.html为模板、合并sharedModule、widgetComponentsModule、homeComponentsModule与自定义模块动态创建组件类型,然后viewContainerRef.createComponent(...)实例化。- 动态组件实例的
ngOnInit()中执行containerFunction.apply(this, [instance.ctx])——apply把组件实例作为this,因此你在函数里写的this.devices = ...就落在了组件实例上,模板自然能拿到。 - 组件还实现了
ngDoCheck(),每轮变更检测都会执行this.cdr.detectChanges(),保证容器函数引起的绑定更新能被渲染。
3.2 三个黄金法则
- 赋值给
this:this.devices = []; this.selected = null;模板中直接写{{ devices }}、*ngFor="let d of devices"。 - 处理器也用
this挂载:this.select = (d) => {...},模板中(click)="select(d)"。 - 模板中引用不要
this前缀:{{ selected.name }},而不是{{ this.selected.name }}。
3.3 辅助函数必须使用箭头函数
文档明确要求:Define helper functions as arrow functions to preserve this(辅助函数定义为箭头函数以保留 this)。因为普通 function 声明拥有自己的 this,在事件回调或订阅回调中会丢失外层组件实例;箭头函数不绑定自己的 this,会从定义处继承。示例代码中 this.select = (d) => {...}、this.startDrag = (event) => {...} 全部使用箭头形式即出于此因。
四、四个关键注意点
4.1 Angular 模式没有 container 参数
与 Plain 模式不同,Angular 模式函数体内拿不到 container 元素。需要访问根元素时,用 ctx.$container[0](jQuery 包装下的原生节点);或在事件处理器中使用 event.currentTarget.closest(...) 从触发元素向上查找祖先节点,例如分栏拖拽示例中的 event.currentTarget.closest('.split')。
4.2 修改模板绑定值后调用 ctx.detectChanges()
Angular 的变更检测不会感知你在异步回调(订阅 onDataUpdated、RPC 完成、鼠标移动事件)中对 this 成员的非 @Input 赋值。每次修改模板绑定值后都应调用 ctx.detectChanges() 强制触发检测。该方法的实现在 widget-component.models.ts 中:
detectChanges(updateWidgetParams: boolean = false) {
if (!this.destroyed) {
if (updateWidgetParams) { this.dashboardWidget.updateWidgetParams(); }
try {
if (this.ngZone) {
this.ngZone.run(() => { this.changeDetectorValue?.detectChanges(); });
} else {
this.changeDetectorValue?.detectChanges();
}
} catch (e) { /* ignored */ }
}
}
可以看到它在 ngZone.run 内调用 detectChanges(),确保即使回调运行在 Angular Zone 之外也能安全刷新视图。
4.3 部件不会自动绑定数据源
HTML 容器部件不像普通图表部件那样自动把数据源结果填进 ctx.data——从配置层面根本没有创建默认订阅。因此 ctx.data 始终为空,不要指望它自动携带实时数据。
4.4 手动订阅与释放
要读取实时数据,必须显式调用 ctx.subscriptionApi.createSubscription(options, subscribe) 创建订阅,并通过 ctx.registerDestroyCallback(...) 注册销毁回调,在部件销毁时用 ctx.subscriptionApi.removeSubscription(id) 释放。这是文档强调的规范动作,也是避免内存泄漏的关键。
五、实战示例一:Resizable split master–detail(可拖拽分栏主从视图)
该示例在左侧渲染设备列表(实时订阅 peopleCount 时序数据),中间拖拽手柄可调整列表宽度,右侧通过 tb-dashboard-state 嵌入看板状态 deviceCharts 展示选中设备的图表,并用 ctx.stateController.updateState(...) 把选中设备同步到父级状态参数。
HTML 模板
<div class="split">
<aside class="split__list" [style.width.px]="listWidth">
<h3 class="split__title">Devices</h3>
<ul class="split__items">
<li *ngFor="let d of devices"
class="split__item"
[class.is-active]="d.entityId === selectedId"
(click)="select(d)">
<span class="split__name">{{ d.name }}</span>
<span class="split__value">{{ d.lastValue }}</span>
</li>
</ul>
</aside>
<div class="split__divider" (mousedown)="startDrag($event)"></div>
<section class="split__detail">
<ng-container *ngIf="selected; else empty">
<header>
<div class="split__eyebrow">Device</div>
<h3 class="split__detail-title">{{ selected.name }}</h3>
</header>
<div class="split__state">
<tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state>
</div>
</ng-container>
<ng-template #empty><div class="split__empty">Select a device</div></ng-template>
</section>
</div>
CSS
.split {
display: flex;
height: 100%;
box-sizing: border-box;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.split__list {
flex: 0 0 auto;
min-width: 160px;
overflow: auto;
border-right: 1px solid #eef1f6;
}
.split__title {
margin: 0;
padding: 16px;
font-family: 'Roboto', sans-serif;
font-size: 20px;
font-weight: 500;
}
.split__items {
list-style: none;
margin: 0;
padding: 0;
}
.split__item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
transition: background 0.15s ease;
}
.split__item:hover {
background: #f8fafc;
}
.split__item.is-active {
background: rgba(47, 107, 255, 0.08);
box-shadow: inset 3px 0 0 #2f6bff;
}
.split__value {
font-weight: 700;
color: #475569;
}
.split__divider {
flex: 0 0 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
background: #eef1f6;
transition: background 0.15s ease;
}
.split__divider:hover {
background: #e2e8f0;
}
.split__divider::before {
content: '';
width: 4px;
height: 32px;
background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px);
background-position: center;
background-size: 4px 6px;
background-repeat: repeat-y;
}
.split--dragging {
user-select: none;
}
.split--dragging .split__list,
.split--dragging .split__detail {
pointer-events: none;
}
.split__detail {
flex: 1 1 0;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
overflow: auto;
}
.split__eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
}
.split__detail-title {
margin: 2px 0 0;
font-size: 20px;
font-weight: 800;
}
.split__state {
flex: 1 1 auto;
min-height: 260px;
border: 1px solid #eef1f6;
border-radius: 14px;
overflow: hidden;
}
.split__state tb-dashboard-state {
display: block;
width: 100%;
height: 100%;
}
.split__empty {
margin: auto;
color: #94a3b8;
font-size: 14px;
}
JavaScript 容器函数
this.ctx = ctx;
this.devices = [];
this.selected = null;
this.selectedId = null;
this.listWidth = 280;
this.select = (d) => {
this.selected = d;
this.selectedId = d.entityId;
ctx.stateController.updateState(null, {
entityId: { entityType: d.entityType, id: d.entityId },
entityName: d.name,
});
ctx.detectChanges();
};
this.startDrag = (event) => {
event.preventDefault();
const root = event.currentTarget.closest('.split');
const left = root.getBoundingClientRect().left;
root.classList.add('split--dragging');
const onMove = (e) => {
this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220));
ctx.detectChanges();
};
const onUp = () => {
root.classList.remove('split--dragging');
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
this.devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' };
});
ctx.detectChanges();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
完整示例文件见 angular_split_html.md、angular_split_css.md、angular_split_js.md。
要点拆解:
subOpts.type = 'latest'表示最新值订阅;entityFilter用设备类型过滤peopleCount设备并resolveMultiple: true解析为多个实体;dataKeys订阅peopleCount时序键。onDataUpdated回调里把subscription.data映射为列表行数据(含last[0]时间戳与last[1]最新值),每次更新后ctx.detectChanges()。ctx.registerDestroyCallback中调用removeSubscription完成清理,防止看板切换后泄漏。
六、实战示例二:Slide-over device detail with two-way RPC(抽屉式设备详情 + 双向 RPC)
该示例用 Material 的 mat-drawer-container 实现右侧滑出抽屉:主区域是 mat-table 设备表,点击行打开抽屉显示设备详情卡片、Alarms 与 Charts 两个内嵌看板状态,并提供 Reboot 与 Reset counter 两个按钮,通过 ctx.http.post('/api/rpc/twoway/' + entityId, ...) 调用设备端双向 RPC 方法,mat-progress-bar 展示请求进行中状态。
HTML 模板
<mat-drawer-container class="dd">
<mat-drawer-content class="dd__main">
<h3 class="dd__title">Devices</h3>
<div class="dd__card">
<mat-table [dataSource]="devices" class="dd__table">
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Device</mat-header-cell>
<mat-cell *matCellDef="let d">{{ d.name }}</mat-cell>
</ng-container>
<ng-container matColumnDef="people">
<mat-header-cell *matHeaderCellDef>People</mat-header-cell>
<mat-cell *matCellDef="let d">{{ d.lastValue }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columns"></mat-header-row>
<mat-row *matRowDef="let d; columns: columns" [class.is-active]="d.entityId === selectedId" (click)="openDevice(d)"></mat-row>
</mat-table>
</div>
</mat-drawer-content>
<mat-drawer class="dd__drawer" mode="over" position="end" [opened]="drawerOpen" (openedChange)="drawerOpen = $event">
<div class="dd__panel" *ngIf="selected">
<header class="dd__panel-bar">
<div>
<div class="dd__panel-eyebrow">Device</div>
<h3 class="dd__panel-title">{{ selected.name }}</h3>
</div>
<button class="dd__close" (click)="drawerOpen = false">✕</button>
</header>
<div class="dd__cards">
<div class="card"><span class="card__label">People</span><span class="card__value">{{ selected.lastValue }}</span></div>
<div class="card"><span class="card__label">Entity type</span><span class="card__value">{{ selected.entityType }}</span></div>
<div class="card"><span class="card__label">Last update</span><span class="card__value">{{ selected.lastSeen }}</span></div>
</div>
<div class="dd__actions">
<div class="dd__action">
<button class="dd__btn" [disabled]="rebooting" (click)="reboot()">Reboot</button>
<mat-progress-bar *ngIf="rebooting" mode="indeterminate"></mat-progress-bar>
</div>
<div class="dd__action">
<button class="dd__btn dd__btn--warn" [disabled]="resetting" (click)="resetCounter()">Reset counter</button>
<mat-progress-bar *ngIf="resetting" mode="indeterminate"></mat-progress-bar>
</div>
</div>
<section class="dd__section">
<h4 class="dd__section-title">Alarms</h4>
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceAlarms" [syncParentStateParams]="true"></tb-dashboard-state></div>
</section>
<section class="dd__section">
<h4 class="dd__section-title">Charts</h4>
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state></div>
</section>
</div>
</mat-drawer>
</mat-drawer-container>
CSS
.dd {
height: 100%;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
}
.dd__main {
padding: 20px;
box-sizing: border-box;
}
.dd__title {
margin: 0 0 16px;
font-family: 'Roboto', sans-serif;
font-size: 20px;
font-weight: 500;
}
.dd__card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.12);
}
.dd__table {
width: 100%;
}
.dd__table .mat-column-name {
flex: 1;
}
.dd__table .mat-column-people {
flex: 0 0 120px;
justify-content: flex-end;
}
.dd__table .mat-mdc-row {
cursor: pointer;
}
.dd__table .mat-mdc-row:hover {
background: rgba(0, 0, 0, 0.04);
}
.dd__table .mat-mdc-row.is-active {
background: rgba(47, 107, 255, 0.08);
}
.dd__drawer {
width: 70%;
box-sizing: border-box;
}
.dd__panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.dd__panel-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dd__panel-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: #94a3b8;
}
.dd__panel-title {
margin: 2px 0 0;
font-size: 22px;
font-weight: 800;
}
.dd__close {
border: 0;
background: #f1f5f9;
border-radius: 10px;
width: 36px;
height: 36px;
font-size: 16px;
cursor: pointer;
color: #475569;
}
.dd__close:hover {
background: #e2e8f0;
}
.dd__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(180deg, #f8fafc, #eef2f7);
border: 1px solid #e6ebf2;
}
.card__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: #94a3b8;
}
.card__value {
font-size: 18px;
font-weight: 800;
}
.dd__actions {
display: flex;
gap: 10px;
}
.dd__action {
display: inline-flex;
flex-direction: column;
gap: 6px;
}
.dd__btn {
padding: 9px 16px;
border: 1px solid #d8dee9;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.dd__btn:hover {
background: #f8fafc;
}
.dd__btn:disabled {
opacity: 0.6;
cursor: default;
}
.dd__btn--warn {
color: #b91c1c;
border-color: #fecaca;
}
.dd__btn--warn:hover {
background: #fef2f2;
}
.dd__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.dd__section-title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #334155;
}
.dd__state {
height: 280px;
border: 1px solid #eef1f6;
border-radius: 14px;
overflow: hidden;
}
.dd__state tb-dashboard-state {
display: block;
width: 100%;
height: 100%;
}
JavaScript 容器函数
this.ctx = ctx;
this.devices = [];
this.columns = ['name', 'people'];
this.selected = null;
this.selectedId = null;
this.drawerOpen = false;
this.openDevice = (d) => {
this.selected = d;
this.selectedId = d.entityId;
this.drawerOpen = true;
ctx.stateController.updateState(null, {
entityId: { entityType: d.entityType, id: d.entityId },
entityName: d.name,
});
ctx.detectChanges();
};
this.rebooting = false;
this.resetting = false;
this.reboot = () => {
if (!this.selected || this.rebooting) return;
this.rebooting = true;
ctx.detectChanges();
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({
error: (e) => {
console.error(e);
this.rebooting = false;
ctx.detectChanges();
},
complete: () => {
this.rebooting = false;
ctx.detectChanges();
},
});
};
this.resetCounter = () => {
if (!this.selected || this.resetting) return;
this.resetting = true;
ctx.detectChanges();
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({
error: (e) => {
console.error(e);
this.resetting = false;
ctx.detectChanges();
},
complete: () => {
this.resetting = false;
ctx.detectChanges();
},
});
};
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
this.devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
return {
entityId: ds.entityId,
entityType: ds.entityType,
name: ds.entityName,
lastValue: last ? last[1] : '—',
lastSeen: last ? new Date(last[0]).toLocaleString() : '—',
};
});
ctx.detectChanges();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
完整示例见 angular_drawer_html.md、angular_drawer_css.md、angular_drawer_js.md。
要点拆解:
- RPC 不通过
ctx.controlApi.sendTwoWayCommand,而是直接调用平台 REST API/api/rpc/twoway/{entityId},POST body 携带{ method, params },这也是一种官方支持的调用方式(ctx.controlApi内部同样基于此实现)。 - 请求期间用
rebooting/resetting布尔值驱动按钮disabled与mat-progress-bar,完成后在complete/error回调复位并ctx.detectChanges()。 mat-drawer的[opened]="drawerOpen"与(openedChange)双向同步抽屉开关状态。tb-dashboard-state通过stateId内嵌deviceAlarms/deviceCharts两个看板状态,[syncParentStateParams]="true"让子状态自动同步父级entityId参数。
七、实战示例三:Composite dashboard with tab navigation(标签页复合看板)
该示例用一个顶层标签导航切换三个看板状态(Assets / Devices / Customers),通过 ctx.stateController.getStateParams().subState 与 ctx.stateController.updateState(null, { subState }) 实现状态参数驱动的"单容器多状态"切换,并订阅 stateChanged 事件让标签高亮与外部导航保持同步。
HTML 模板
<div class="composite">
<nav class="tabs">
<button *ngFor="let tab of tabs"
class="tab"
[class.is-active]="tab.id === activeTab"
(click)="selectTab(tab.id)">
{{ tab.label }}
</button>
</nav>
<div tb-toast toastTarget="layout" class="container">
<tb-dashboard-state class="state" [ctx]="ctx" [stateId]="stateId" [syncParentStateParams]="true"></tb-dashboard-state>
</div>
</div>
CSS
.composite {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
box-sizing: border-box;
padding: 12px;
font-family: 'Roboto', system-ui, sans-serif;
}
.tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
align-self: flex-start;
background: #eef1f6;
border-radius: 10px;
}
.tab {
border: 0;
background: transparent;
padding: 8px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: #5b6472;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab:hover {
color: #1f2733;
}
.tab.is-active {
background: #fff;
color: #2f6bff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}
.container {
flex-grow: 1;
width: 100%;
display: flex;
padding: 0;
border: 1px solid #e6eaf0;
border-radius: 12px;
overflow: hidden;
}
.state {
flex-grow: 1;
width: 100%;
height: 100%;
padding: 0;
}
JavaScript 容器函数
this.ctx = ctx;
this.tabs = [
{ id: 'assets', label: 'Assets', stateId: 'assetsState' },
{ id: 'devices', label: 'Devices', stateId: 'devicesState' },
{ id: 'customers', label: 'Customers', stateId: 'customersState' },
];
const apply = (subState) => {
const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0];
this.activeTab = tab.id;
this.stateId = tab.stateId;
};
apply(ctx.stateController.getStateParams().subState);
this.selectTab = (subState) => ctx.stateController.updateState(null, { subState });
const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => {
apply(ctx.stateController.getStateParams().subState);
ctx.detectChanges();
});
ctx.registerDestroyCallback(() => {
stateSub.unsubscribe();
});
完整示例见 angular_tabs_html.md、angular_tabs_css.md、angular_tabs_js.md。
要点拆解:
stateId是一个动态绑定的模板属性,由容器函数决定当前显示哪个看板状态,实现"一个部件宿主 + 多个可切换子状态"的复合看板。- 初始化时通过
apply(ctx.stateController.getStateParams().subState)读取 URL/状态参数,使标签页可直接通过外部导航(如从其他看板传入subState参数)打开。 stateChanged订阅让内部标签高亮与外部状态切换(如浏览器前进/后退)保持同步,并在销毁回调中unsubscribe()。- 该示例演示了状态订阅与数据订阅之外另一类必须手动清理的"监听器"资源:凡是
subscribe得到的流,都应在registerDestroyCallback中释放。
八、配置入口:部件设置面板
HTML 容器部件的设置保存在 settings 中,由 html-container-widget-settings.component.ts 与 html-container-settings.component.ts 渲染。核心配置字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
type |
PLAIN / ANGULAR |
切换渲染模式,Angular 模式下 HTML 按 Angular 模板编译 |
html |
string | Angular 模板源码(Angular 模式)或普通 HTML(Plain 模式) |
css |
string | 作用域样式,运行时通过 cssjs 解析并按内容 hash 命名空间注入 |
js |
TbFunction |
容器函数源码,Angular 模式按 ['ctx'] 编译 |
resources |
WidgetResource[] |
外部资源列表:url + isModule 标记。非模块资源(JS/CSS)在 loadWidgetResources() 中加载;isModule: true 的 JS 资源在 Angular 模式下作为 Angular 模块(NgModule)通过 loadAngularModules() 加载并合并进动态组件的编译模块列表 |
从 html-container-widget.component.ts 可以看到默认设置:
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
type: HtmlContainerWidgetType.PLAIN,
html: '',
css: '',
js: '',
resources: [],
};
即新建部件默认是 Plain 模式,需要手动切换到 Angular 模式并填入模板与函数。值得注意的是,设置组件在 Plain 模式下会自动过滤掉 JS 资源并强制 isModule = false(见 updateResources()),因为 Plain 模式不支持模块加载——模块资源仅对 Angular 模式有意义。
九、调试与常见问题
- 模板渲染空白:检查是否忘记调用
ctx.detectChanges()。异步回调中修改this数据后若视图不更新,第一嫌疑就是缺少检测触发。 this丢失(Cannot read properties of undefined):确认所有处理器与回调都使用箭头函数。普通function会绑定自己的this。- 没有数据:HTML 容器部件不会自动填充
ctx.data,必须显式createSubscription;同时确认订阅已subscribe(true)开始拉取。 - 切换看板后资源泄漏:所有订阅、事件监听、定时器都应放入
ctx.registerDestroyCallback(...);数据订阅用ctx.subscriptionApi.removeSubscription(id),rxjs 流用unsubscribe()。 - 运行时报错:容器函数或模板编译抛出的异常会被组件捕获并显示在部件错误层(
widgetErrorData),同时handleWidgetException会在控制台输出堆栈,见 html-container-widget.component.ts 的handleWidgetException与processWidgetException。 - 组件不可用:如果模板中使用了自定义组件/指令,需要把它所在的 Angular 模块通过
resources中以isModule: true加载,否则模板编译阶段会报未知元素错误。
十、总结
ThingsBoard HTML 容器部件的 Angular 模式把"Angular 模板引擎 + 容器函数"组合成一个完整的自定义部件开发环境:function (ctx): void 通过 this 挂载数据与处理器,模板无前缀引用;ctx 提供订阅、RPC、状态、服务与通知等全套能力;ctx.detectChanges() 负责在异步路径中刷新视图;数据源需手动订阅并由 registerDestroyCallback 兜底清理。仓库内的三个官方示例(可拖拽主从分栏、抽屉式设备详情 + 双向 RPC、标签页复合看板)覆盖了模板渲染、内嵌看板状态、RPC 调用与状态参数导航等核心玩法,可直接复制到部件设置的 HTML/CSS/JS 三个编辑器中运行,是学习 Angular 模式容器函数的最佳起点。相关实现与示例的源码位置:部件运行逻辑 html-container-widget.component.ts、类型定义 html-container-widget.models.ts、API 定义 widget-api.models.ts 与 widget-component.models.ts。