编写自定义组件
自定义组件由一个 React 组件和一小段描述它的纯数据组成。你使用 @devicechain/widget-sdk 编写这两部分,将它们放进 npm 包发布,并在显示仪表板的应用中注册该包。内置组件也通过完全相同的接口声明,因此自定义组件可获得相同的数据绑定、选项校验和错误处理。
你的组件接收的是已解析好的数据。它不负责认证、解析数据源、轮询,也不建立遥测连接。这些都由宿主完成,并把一份快照交给你的组件。
仓库中的 examples/widget-tank-level 是一个可直接复制的完整包。它在单体仓库之外,基于已打包的 SDK tarball 构建。
安装 SDK
npm install @devicechain/widget-sdk @devicechain/dashboards react
在你的组件包中,将 @devicechain/widget-sdk、@devicechain/dashboards 和 react 声明为对等依赖(peer dependencies),不要声明为普通依赖。SDK 定义了组件读取的 React 上下文(选择、时区、主题根),而上下文由创建它的对象标识。因此宿主和每个组件包必须解析到同一份 SDK。
SDK 根入口不含图表或地图代码。只绘制 DOM 的组件从根入口导入,不会向宿主的打包产物加入 Apache ECharts、MapLibre 或内置组件。图表辅助工具(EChart、buildLineOption、buildGaugeOption、useChartTheme)位于 @devicechain/widget-sdk/echarts,而 echarts 是可选的对等依赖,只有导入该入口的包才需要它。
声明组件
import { WidgetFrame, defineWidget, formatValue, pickSample } from '@devicechain/widget-sdk';
export const tankLevelWidget = defineWidget({
descriptor: {
id: 'acme/tank-level',
version: '1.0.0',
sdkApiVersion: 1,
optionsVersion: 1,
display: { title: 'Tank level', category: 'Telemetry' },
channel: 'measurement',
datasource: { mode: 'required', selectors: ['device', 'slot'] },
binding: { window: { option: 'window', min: 1, default: 1 } },
options: {
unit: { kind: 'string', doc: 'Unit shown beside the level.', label: 'Unit' },
window: { kind: 'number', integer: true, min: 1, doc: 'Samples kept.', label: 'Samples kept' },
},
defaults: { unit: '%', window: 1 },
interactions: {},
},
implementation: {
kind: 'trusted-react',
component: ({ data }) => (
<WidgetFrame title="Tank level">{formatValue(pickSample(data.latest, undefined)?.value, 1)}</WidgetFrame>
),
},
});
defineWidget 会根据字面量推断通道和选项模式。组件数据与通道不一致、默认值与模式不一致、绑定引用了不存在的选项,或声明了该通道无法承载的交互,都会成为编译错误。宿主构建注册表时会再次检查同样的规则,以覆盖绕过编译器的调用方。
描述符
描述符是可序列化为 JSON 的纯数据,不含组件、回调、导入 URL 或令牌,因此定义解析器和校验器无需加载或信任你的代码就能读取它。
| 字段 | 含义 |
|---|---|
id | 带命名空间的 id,形如 scope/name,例如 acme/tank-level,只能使用小写字母、数字和连字符。不含斜杠的 id 保留给内置组件。当前版本中命名空间的归属只是运维人员之间的约定,并不证明包的发布者是谁。 |
version | 该组件的发布版本,格式为 MAJOR.MINOR.PATCH。它是来源信息。参见组件版本与兼容性。 |
sdkApiVersion | 组件所针对的 SDK 契约代际,目前为 1。 |
optionsVersion | 已存储选项的结构版本。仅当已存储选项的含义发生变化时才递增。 |
display | title,以及可选的 description 和 category,用于宿主的“添加”菜单。titleId 和 categoryId 是可选的消息 id,见标签与帮助文字。 |
channel | 组件消费哪类数据。见下文。 |
datasource | { mode: 'none' },或 mode: 'optional' / 'required' 加上作者可选的 selectors:device、anchor 或 slot。control 通道的组件只针对一台设备,因此只提供 device 和 slot。 |
binding | 宿主如何订阅,用常量和对你自己选项键的引用来表示。 |
options | 选项模式。 |
constraints | 可选,跨多个选项的规则,例如 greater-than。 |
defaults | 新添加组件的初始选项。 |
interactions | 组件请求的宿主回调。 |
通道与绑定策略
一个组件声明一个通道:
| 通道 | 组件接收 | 绑定策略 |
|---|---|---|
measurement | 按测量名称组织的 latest、滚动的 samples 窗口和 error | window:保留多少个样本 |
alarm | 当前告警行、总数以及加载与错误标志 | pageSize,以及可选的 state、severity、acknowledged 过滤器 |
control | 目标设备的命令历史 | pageSize |
location | 所绑定实体的位置样本 | 无 |
selection | 无数据。组件根据宿主的候选项查询来驱动一个槽位。 | 无 |
none | 无数据。静态组件。 | 无 |
绑定值要么是常量 { const: 25 },要么是对选项键的引用 { option: 'window', min: 1, default: 1 }。引用在编译时会按选项类型检查。你不能提供 GraphQL 字符串或订阅函数:传输由宿主负责。selection 和 none 通道不打开任何数据通道,且必须声明 datasource: { mode: 'none' }。
当槽位没有设备,或所绑定的设备被删除时,宿主会显示其标准的未绑定或不可用状态,不会把之前那个实体的数据交给你的组件。
选项
选项有 kind(string、number、boolean 或 enum)和一句 doc 说明。数字可声明 min、max 和 integer。枚举声明其 values。字符串可声明 format: 'json',要求其值能解析为 JSON。required 标记一个缺失就会让组件无内容可显示的选项;空字符串视为缺失。宿主会依据模式校验已存储的选项,控制台也据此生成设置表单。
使用 SDK 的辅助函数 optString、optNumber 和 optBoolean 以防御方式读取选项。对于手工编辑后类型错误的值,它们返回 undefined,而不是抛出异常。
标签与帮助文字
为每个选项提供可移植的 label,需要时再提供一句 help。从未见过你的组件的宿主会原样显示这些文字,因此你的选项无需宿主提供任何翻译即可被理解。每段文字还可以带一个消息 id,宿主会先在自己的翻译中查找它:
| 文字 | 可移植文字 | 可选的消息 id |
|---|---|---|
| 选项标签 | label | labelId |
| 选项帮助 | help | helpId |
| 枚举选项 | valueLabels,按值索引 | valueLabelIds,按值索引 |
| 枚举的未设置项 | placeholder | placeholderId |
| 组件标题与类别 | display.title、display.category | display.titleId、display.categoryId |
控制台在自己的翻译定义了该消息 id 时使用它,否则使用你的可移植文字。两者都没有的标签会回退为可读化的选项键(maxRows 显示为“Max rows”),两者都没有的枚举选项显示其原始值。宿主未定义的消息 id 不是错误,因此你的包可在从未见过它的宿主中正常工作。
options: {
show: {
kind: 'enum',
values: ['bar', 'number'],
doc: 'Whether the card draws the fill bar under the number.',
label: 'Show',
valueLabels: { bar: 'Number and fill bar', number: 'Number only' },
placeholder: 'Number and fill bar',
},
},
专用编辑控件
选项可以在其 control 字段中指定一个专用的编辑流程。它与 control 数据通道无关。不指定时,选项使用与其类型对应的通用字段:文本框、数字框、复选框或下拉列表。
该词汇表是开放的。任何非空字符串都可以注册,未实现所指定控件的宿主会改为显示该选项的通用字段。因此,即使宿主比包所指定的控件更旧,包仍然可以安装。请在你自己的命名空间中命名你的控件,例如 acme/capacity-presets。不带前缀的名称属于宿主。
控制台实现了 HOST_OPTION_CONTROLS 中列出的控件,该常量由 @devicechain/dashboards 导出:
| 控件 | 流程 |
|---|---|
command-vocabulary | 从所绑定设备的命令中选择一个,并一起写入其键、标签和参数。 |
slot-target | 从仪表板的槽位中选择一个。 |
basemap | 标记栅格地图瓦片源及其署名。 |
控制台未实现的控件(包括所有带命名空间的控件)使用通用字段。若要为你的选项提供专属的编辑体验,请提供一个编辑器。
自定义编辑器
implementation.editor 是一个可选的 React 组件。组件提供了编辑器时,控制台会用它替代生成的选项字段。数据源字段和控制台的提示仍由控制台负责。编辑器接收由 @devicechain/widget-sdk 导出的 WidgetEditorProps:
| 属性 | 含义 |
|---|---|
options | 组件已存储的选项,只读。 |
setOptions(patch) | 修改选项的唯一方式。一次调用中的多个键会一起生效。 |
services | 宿主服务,以显式方式传入。见下文。 |
descriptor | 你所注册的描述符,用于读取其选项的标签、边界和枚举值。 |
setOptions 在生效前会依据你的选项模式进行检查。模式未声明的键或类型错误的值,会在编辑器出错的那一刻抛出异常,而不是被存储下来却无人读取。控制台会捕获该错误并显示在你的编辑器旁边,且不会写入任何内容。undefined 和空字符串会删除一个键,布尔选项的 false 也是如此。超出声明边界的数字会被限制到边界之内。
services(WidgetEditorServices)包含:
| 服务 | 含义 |
|---|---|
slots | 仪表板的槽位,按名称索引。仪表板没有槽位时为空。 |
deviceToken | 组件直接绑定的设备的令牌(如果是直接绑定)。 |
commandChoices(deviceToken) | 设备接受的命令。可选:仅当你的描述符在某个选项上声明了 command-vocabulary 控件时才会提供;没有命令词汇表的宿主也不提供它。 |
编辑器不会获得 hub、客户端或访问令牌,也不得从宿主应用导入任何内容。宿主无法提供的服务会缺失,你的编辑器必须能够应对。如果编辑器在渲染时抛出异常,控制台会用一条错误消息和一个重试按钮替换它,作者在仪表板上的其他更改都会保留。
implementation: {
kind: 'trusted-react',
component: TankLevelCard,
editor: function TankLevelCardEditor({ options, setOptions, descriptor }) {
return (
<label>
{descriptor.options.unit?.label ?? 'unit'}
<input value={options.unit ?? ''} onChange={(e) => setOptions({ unit: e.target.value })} />
</label>
);
},
},
在 defineWidget 中,options 和 setOptions 的类型来自你的选项模式,因此拼错的键也会成为编译错误。示例包中的卡片组件带有一个完整的编辑器。
交互与信任模型
interactions 列出组件使用的宿主回调:alarmActions(确认与清除,仅限告警通道)、commandSend(仅限控制通道)、selection 和 candidates。宿主只提供你声明过的回调,并进一步限制在宿主支持且查看者被允许的范围内。没有回调的组件表现为只读。服务器始终是权威:查看者无权执行的操作会被服务器拒绝,且该拒绝会传达到你的组件。
组件包是受信任的代码。 它在构建时被打包进宿主应用,并在宿主页面中以宿主的权限运行。描述符中的声明用于规范 API,并不是安全边界,无法阻止包中的恶意代码。SDK 不会把访问令牌、客户端或原始 hub 传给你的组件,但同一页面中的代码仍然可能获取它们。只安装你已审查过、来自可信发布者的包。宿主会用错误边界包裹每个组件,因此抛出异常的组件只会在自己的框内显示错误,同级组件继续渲染。但这无法遏制死循环或恶意代码。