ARTICLE · 1355872
FUXA 源码解读 ⑤|HMI 视图渲染
01
一句话结论
FUXA 的 HMI 视图不是一棵 Angular 组件树,而是一张 SVG 字符串 + 一张以元素 id 为键的绑定字典;Angular 控件只在 SVG 中带 type="svg-ext-..." 的节点上「外挂」,值刷新不走变更检测,而是由一条 EventEmitter 链直接改 SVG DOM。
读完这篇你应该能回答:SVG 怎么变成画面、绑定在哪一步建立、刷新为什么不经过 detectChanges。

双态渲染
▲ 编辑器与运行时共用同一份 svgcontent 与 items,差别只在 isview 一个参数。
02
入口与调用链
运行时入口是首页组件,不是 fuxa-view 自己。home.component.ts:109 订阅 projectService.onLoadHmi,拿到 HMI 后调 loadHmi();切页时走 onGoToPage(viewId)(home.component.ts:211-235),先 await projectService.ensureViewLoaded(viewId)(:216)解析懒加载视图,再把视图对象交给 this.fuxaview.loadHmi(this.homeView)(:228)。卡片视图走 cards-view,弹窗走 fuxa-view-dialog,它们都复用同一个 FuxaViewComponent。
进入组件后是三级跳:
- loadHmi(view)
(fuxa-view.component.ts:194-208)——重置 viewLoaded,判断 projectService.isViewLazy(view),懒视图走 resolveView()(:844-857)异步取回内容,否则直接 normalizeView() 补默认 profile/items/variables(:859-867)。 - loadResolvedHmi(view)
(:210-272)——先把旧映射拆掉:unbindGauge(this.id)、viewContainerRef.clear()、清空 dataContainer.innerHTML(:221-226);然后 this.dataContainer.nativeElement.innerHTML = view.svgcontent(:241),整页 SVG 一次性进 DOM;detectChanges() 后用 onResize() 处理自适应缩放(:275-281)。 - loadWatch(view)
(:288-399)——真正建立绑定的地方,下文详述。
链路终点在具体控件里。以数值控件为例:ValueComponent.processValue(value.component.ts:58-112)取 svgele.node.children[0],把格式化后的值写进 textContent(:89-98),必要时拼单位。
编辑器入口是另一条:editor.component.ts:344 的 loadHmi() 选中当前视图,onSelectView 里把 svgcontent 塞给 SVG 编辑器 svgEditor.setSvgString(svgcontent)(:566);新增控件时 checkGaugeAdded()(:1036-1048)调 initElementAdded(..., isview=false),只创建 Angular 控件并记到 gaugesRef,不订阅、不绑鼠标事件。
03
核心数据结构
视图本体定义在 _models/hmi.ts:
| Hmi | hmi.ts:6-11 | layout | |
| View | hmi.ts:13-36 | id | |
| GaugeSettings | hmi.ts:206-214 | id | |
| GaugeProperty | hmi.ts:221-236 | variableId | |
| GaugeStatus | hmi.ts:400-405 | variablesValue |
三个要点:
- items 的键是元素 id
,不是控件名。GaugeSettings.name 只是给人看的标签,gaugesManager.getGaugeSettings(domViewId, sigid) 走的是 viewSignalGaugeMap,最终索引仍是 ga 对象本身。 - 类型靠 type 前缀识别
。GaugesManager.Gauges(gauges.component.ts:81-84)列了 22 个控件类,每个类有静态 TypeTag(如 ValueComponent.TypeTag = 'svg-ext-value',value.component.ts:18)。createSettings(:118-130)和 processValue(:561-637)都靠 type.startsWith(TypeTag) 做分派,所以 SVG 里删掉 type 属性,控件就退化成普通图形。 - GaugeStatus.onlyChange 默认开
。createGaugeStatus(:132-143)对图表、图形、表格、排程、配方之外的控件全部置 true,checkStatusValue(fuxa-view.component.ts:515-528)用它比较上一帧值,相同就跳过重绘。
GaugeProperty 里真正驱动画面的不是 variableId 一项:ranges 承担「按区间变色/换文本」,actions 承担「隐藏、显示、闪烁」,两者都各自带 variableId 或 textId,所以一个数值控件可以同时被三个信号影响。ValueComponent.getSignals(value.component.ts:27-48)把这些 id 全收进绑定表;processAction(:114-130)只做区间比较,再调 GaugeBaseComponent 的 runActionHide/Show/checkActionBlink,动画句柄存回 GaugeStatus.actionRef,这也是切页时要 clearGaugeStatus 清定时器的原因(fuxa-view.component.ts:168-188)。
图里的 viewSignalGaugeMap(hmi.service.ts:754-796)是二级索引:views[domViewId][signalId] = [GaugeSettings...]。它决定了「一个值来了,这一页有哪几个控件要动」。
04
关键流程

绑定与刷新
▲ 订阅只在加载时建立一次,之后每个值都走「socket → 变量表 → handleSignal → 控件」这条短链。
第一步,遍历绑定字典。loadWatch 先按 sourceDeviceId 取源设备标签(fuxa-view.component.ts:291-292),用 applyVariableMapping 把模板占位符替换成真实标签(:293、:442-507),然后 for (let key in items)(:309)逐个处理。
第二步,创建控件。initElementAdded(items[key], resolver, viewContainerRef, true, this, textTranslated, sourceTags)(:317)。isview=true 时它会先 hmiService.addSignal() 把标签登记进变量表(gauges.component.ts:850-861),再按类型分支:图表在 SVG 的 <div> 位置上动态创建 uPlot(:868-882),面板(Panel)直接再实例化一个 FuxaViewComponent(:945-948),普通控件只是取 document.getElementById(ga.id) 设 data-name(:966-971)。
第三步,建立映射。bindGauge(gauge, this.id, items[key], sourceTags, ...)(:322)。它做四件事:getBindSignals 算出该控件涉及的全部标签(gauges.component.ts:458-489,数值控件的信号来自 actions、ranges.textId 和 property.variableId,见 value.component.ts:27-48);addSignalGaugeToMap(domViewId, sigId, ga) 写二级索引,同时把控件实例存进 memorySigGauges[sigId][ga.id](:298-307);有鼠标事件就 bindMouseEvent(:310-327);最后 bindGaugeEventToSignal 处理滑块/开关这类回写控件(:333)。
第四步,补首帧值。 先拿配置里的 variableValue 当初始值(:344-349),再 getBindSignalsValue 从内存变量表取上次值(:351),有值就立刻 processValue 一遍(:352-359),所以切页后不必等下一轮轮询。ngAfterViewInit 还会调 emitBindedSignals(:120)把整页信号重放一次(gauges.component.ts:282-284 → hmi.service.ts:437-444)。
第五步,订阅。 整个视图只订一次 gaugesManager.onchange(:373-375),回调 handleSignal;最后 viewsTagsSubscribe(getBindedSignalsId(), true)(:397)把本页绑定过的标签 id 一次推给服务端(hmi.service.ts:491-494)。
第六步,刷新。 socket 收到 device-values 后逐条写 variables[id].value 并 setSignalValue(hmi.service.ts:269-277)→ 触发 onVariableChanged(:94)→ GaugesManager 构造时的订阅转手 onchange.emit(gauges.component.ts:90-96)→ handleSignal 用 getGaugeSettings(this.id, sig.id) 取本页控件(:405)→ 逐个 getSvgElements(ga.id) 再 processValue(:411-413)。
第七步,交互回写。 绑定同时为写入铺路:有鼠标事件的控件在 bindGauge 时被登记进 eventGauge / mapGaugeView(gauges.component.ts:310-327),onBindMouseEvents 把 click/dblclick 等挂到 SVG 节点(fuxa-view.component.ts:534-597),runEvents 按 action 分派(:606-638),onSetValue 最终走 gaugesManager.putSignalValue → hmiService.putSignalValue → socket(hmi.service.ts:103-127)。输入框走另一条:onBindHtmlEvent(:662-785)监听 Enter,校验后 putEvent 回写。
注意 getSvgElements(fuxa-view.component.ts:869-879)是全文档扫描:遍历 document.getElementsByTagName('svg') 找同 id 元素。它换来了「同一控件在多个 SVG 里都能刷」的便利,代价是每个信号每次更新都要扫一遍页面。
05
设计取舍与坑
1. 为什么把整页存成 SVG 字符串。 好处很直接:Inkscape 画完导出就能用,画面与工程文件一一对应,保存就是字符串快照(editor.component.ts:407)。代价是绑定完全依赖元素 id。在编辑器里改 id、或重新导入一份 SVG,items 里的旧条目就变成孤儿;此时 loadWatch 仍会执行,但 getSvgElements 返回空数组,processValue 一次都不跑,界面不报错、也不提示。排查方法:在视图加载后打印 Object.keys(view.items),逐个 document.getElementById 看是否命中。这是「沉默失败」,现场最容易误判成通讯断了。
2. isview 双态是同一套代码。 编辑器里值不动不是缺陷:isview=false 时不 addSignalGaugeToMap、不绑事件、不建 GaugeStatus,所以没有任何刷新入口。想预览只能点「启动当前页」开 lab 窗口(editor.component.ts:1071-1077),那才是真正的运行时。
3. 刷新绕过变更检测。 走 EventEmitter → 直接改 DOM,省掉了整页脏检查,对 60 帧以下的工业画面是划算的;但也意味着控件样式不归 Angular 管,detectChanges 只在加载和隐藏/显示时调(fuxa-view.component.ts:250、:269)。写自定义控件时若只改组件字段、不改 DOM,画面不会变。
容器用 visibility 而不是 display(fuxa-view.component.html:1),配合 viewRenderDelay(fuxa-view.component.ts:261-270)先把整页藏起来再放行,避免慢视图出现「控件一半、旧值一半」的闪烁;代价是大页面的首屏等待被显式拉长,profile.viewRenderDelay 配大了会像卡死。
4. 空 catch 会吞掉整批更新。handleSignal 的 try 覆盖整个 for (let i = 0; i < gas.length; i++),catch 体为空(fuxa-view.component.ts:403-419)。只要第一个控件抛错(典型是 SVG 结构不符合控件预期,value.component.ts:60 要求该节点 children.length <= 1),同一信号后面的控件就全部跳过,且控制台没有任何输出。现象是「个别控件先停、随后一片停」,重启页面又好。此条已记入疑似缺陷清单(D-06)。
5. 同视图多实例会互相拆台。 弹窗与卡片里的 <app-fuxa-view> 都没传 [id](fuxa-view.component.html:21-28、fuxa-view-dialog.component.html:9-15),组件在 :232 用 this.id = view.id 兜底,于是同一视图的两个实例共用同一个 domViewId。关闭任一个实例时 unbindGauge(this.id)(:130、:221)→ removeSignalGaugeFromMap → ViewSignalGaugeMap.remove(domViewId) 直接 delete this.views[domViewId](hmi.service.ts:567-578、:768-771),把还开着的那份映射一起删掉。之后 signalsGauges(domViewId, sigid) 对 undefined 取属性抛 TypeError,又被上面的空 catch 吞掉,表现为整页停更。此条记为 D-05。
6. 扫描式查找的性能上限。 每次信号更新,handleSignal 对每个控件调一次 getSvgElements(全文档 getElementsByTagName('svg')),复杂度是「信号数 × 控件数 × 页面 SVG 数」。本篇未做压测,只从代码路径判断:几十个控件无感,几百个控件叠加高频刷新时需要关注。验证方法:在 getSvgElements 入口加计数器,观察一轮 100 个值变化的调用次数。
7. onlyChange 的边界。 值相等就跳过,比较用的是 ===。同一标签在 variables 里可能是字符串 "1"、也可能被驱动写成数字 1,类型不一致时会多刷一次(结果无害,只是白跑一遍 DOM 写入)。
06
小结 + 下一篇
这页的骨架就三样东西:svgcontent 是画,items 是按元素 id 排的绑定表,type="svg-ext-..." 是「这里要挂控件」的标记。加载时 loadWatch 把三者对齐,并用 viewSignalGaugeMap 建好「值 → 控件」的索引;运行时就只剩一条 handleSignal → processValue 的直达路径。理解了这一点,再去看「为什么改 id 后绑定失效」「为什么弹窗一关主画面就不动」,都能直接指回具体行。如果要在这层上加功能,入口只有三个:GaugesManager 的三张表(mapGauges / mapChart / mapTable)、bindGauge 的映射写入、processValue 的类型分派。
下一篇进入报警系统:值已经到前端了,服务端那条 tag-value:changed 分支如何判定越限、确认动作落到哪个存储、checkdelay 与 timedelay 到底差在哪。















































































