一、先看两份数据
同一张检验记录表,两个型号提交上来的数据是这样的:
// 型号 X 的记录
{
"form": {
"orderNo": "QI-2026-11-11-102",
"model": "X-100",
"params": [
{
"name": "表面质量", "standard": "无可见缺陷", "value": "" },
{
"name": "装配间隙", "standard": "≤0.3mm", "value": "" }
]
}
}
// 型号 Y 的记录
{
"form": {
"orderNo": "QI-2026-11-11-103",
"model": "Y-300",
"params": [
{
"name": "耐压测试", "standard": "1500V / 60s", "value": "" },
{
"name": "绝缘电阻", "standard": "≥100MΩ", "value": "" },
{
"name": "接地连续性", "standard": "≤0.1Ω", "value": "" }
]
}
}
字段集合不同,条目数也不同,但这两份数据来自同一张模板。模板里没有"耐压测试"这一行,也没有"表面质量"这一行——差异区只有一块循环区域,渲染哪些行完全由 params 数组决定。
这就是"一张模板承接多条产品线"的基本形态:模板承接结构,数据承接差异。剩下的全是实现问题——路径怎么定、字段契约怎么维护、渲染时序怎么排、哪些环节容易出错。下面按实现顺序拆。
二、自研这条路,要先发明什么
假设不用现成组件,从零实现上面这套机制,清单大致五项:
- 字段注册表:一份描述业务数据结构的字段定义,供设计期引用;
- 路径解析器:把
form.params#name这类路径翻译成取数、赋值指令; - 绑定协议:模板里的组件与路径如何一一对应;
- 循环渲染器:按数组长度克隆、渲染行区域;
- 宿主通信:宿主系统与表单页面之间的数据通道。
其中最值得先想清楚的是路径解析器,它决定了后面所有环节的语义。规则其实只有两条连接符:. 访问对象属性,# 进入数组元素。示意实现:
// 将路径解析为取数指令
// '.' 链式访问对象属性;'#' 之后的部分作用于数组的每个元素
function parsePath(path) {
return path.split('#').map(function (seg, i) {
return {
intoArray: i > 0, props: seg.split('.') };
});
}
// parsePath('form.params#name') =>
// [
// { intoArray: false, props: ['form', 'params'] }, // 逐层取到 params 数组
// { intoArray: true, props: ['name'] } // 对数组每个元素取 name
// ]
有了这个语义,"哪个组件显示哪个值"就变成了"组件持有哪条路径"。绑定协议、循环渲染、宿主通信都建立在这上面。
每一块单看都不难,真正的成本在协议的长期维护:字段结构随主数据演进、模板随业务改版、宿主系统随版本升级,三者任何一方变动都可能牵动解析与绑定的兼容性。这也是为什么这类能力适合交给成熟的表单组件承接,而不是每个项目自造一套——后文选型部分再回到这一点。
三、字段契约:推送而非手工配置
字段注册表怎么维护?表单组件侧提供的数据源模型推送接口,直接把契约从主系统推过去:
await fetch('http://flash-table:9150/api/datasourcefield/saveAll', {
method: 'POST',
headers: {
'Content-Type': 'application/json' },
body: JSON.stringify({
dataSourceName: 'lims_inspection_record',
description: '检验记录单字段模型',
items: [
{
uniqueId: 'p01', fieldName: 'form.orderNo', fieldComment: '单号', fieldType: 'string', isNull: 1 },
{
uniqueId: 'p02', fieldName: 'form.model', fieldComment: '型号', fieldType: 'string', isNull: 0 },
{
uniqueId: 'p03', fieldName: 'form.params', fieldComment: '参数明细', fieldType: 'string', isNull: 0, isArray: 1 },
{
uniqueId: 'p04', fieldName: 'form.params#name', fieldComment: '参数名', fieldType: 'string', isNull: 0, type: 'text' },
{
uniqueId: 'p05', fieldName: 'form.params#value', fieldComment: '实测值', fieldType: 'string', isNull: 1, type: 'text' }
]
})
});
三个实践细节值得单独说。
fieldName 就是结构描述。 接口收到这组路径后,会反推出完整的 JSON 结构:form.params 标记 isArray: 1,是数组字段;form.params#name 是数组元素内的普通值。不需要额外再传一份 Schema,契约与结构是同一份东西。
控件类型随字段声明。 type 支持 text、textarea、number、select、checkbox、date、radio、qrcode;需要选项的字段(select/checkbox/radio)通过 options 携带 label/value。这意味着"判定"列用下拉还是单选,在字段模型里声明即可,不占模板版式。
覆盖是整体覆盖,不是增量合并。 dataSourceName 唯一,重复推送同名数据源会整体覆盖旧模型。这让字段模型可以随主数据演进——新增参数维度,推一版新模型即可。但也意味着推送前要想清楚 items 是全量集:只推两个字段上去,线上模型就只剩这两个字段。实践中建议把推送脚本纳入版本管理,每次全量执行。
推送完成后,设计器左侧的数据源面板即可引用这批字段(设计器 URL 支持 leftType=dataSource 参数直接初始化该面板)。
四、绑定:组件名就是路径
接下来是实践中最容易踩的一个坑。
表单组件创建时,每个输入组件的名称默认是一段 8 位随机编码(类似 6e430b55)。宿主系统直接按路径传值是无效的:
// 无效:组件名还是随机编码,路径找不到落点
rpc.SET_DATA({
'form.model': 'X-100' });
// 正确:先把组件名绑定到字段路径,再按路径赋值
rpc.SET_DATA({
'form.model': 'X-100' });
两行代码一模一样,区别在设计器里:做模板时必须把组件名称绑定到数据源字段的路径上,SET_DATA / GET_DATA 才能以路径为键读写。"数据源绑定"这个词的实质动作就是这一步——组件名从随机编码换成 form.params#value 这样的语义路径,模板与 JSON 结构从此逐一对齐。
如果发现下发数据后表单纹丝不动,第一个该检查的就是组件名有没有完成路径绑定。
五、渲染时序:TEMPLATE_LOADED 之前什么都别做
宿主侧通过 iframe 嵌入查看器页面,用 RPC 库封装通信。时序上有硬约束:模板加载完成(TEMPLATE_LOADED 事件)之前,任何 SET_DATA 都没有落点。正确的下发顺序:
var rpc = createFlashTableRPC(iframe, 'http://flash-table:9150', {
TEMPLATE_LOADED: function () {
// 模板就绪,此时下发数据才有意义
var params = masterData.getParams(workOrder.model); // [{name, standard}, ...]
rpc.SET_DATA({
'form.orderNo': workOrder.orderNo,
'form.model': workOrder.model,
'form.params': params
});
},
TEMPLATE_VALUE_CHANGED: function (changed) {
// 值级联动:changed 携带本次变更的对象
handleValueChanged(changed);
}
});
差异区在模板里被定义为行循环区域,绑定到 form.params。渲染规则:
- 绑定的数据必须为数组或正整数,传其他类型循环区域不会展开;
- 绑定数组时,渲染
params.length行——下发 3 条渲染 3 行,下发 2 条渲染 2 行; - 填写过程中手动加行,新行继承该区域的校验规则。
第二个常见的坑就藏在第一条里:型号差异如果建模成了字符串或对象(而不是数组),循环区域会静默不渲染,控制台没有任何报错。排查时先 console.log 一下下发的 params 确认类型,比检查模板快得多。
型号之间的差异,在这套机制下就是三份不同的数组。渲染层不关心"这是哪个型号",它只按数组办事。
六、数据的完整旅程
填写完成之后,数据怎么回流:
// 1. 取数:keyParameter 传 true 只取关键参数,false 取全量
rpc.GET_DATA({
keyParameter: false }).then(function (data) {
// data['form.params'] 即填好的明细数组
});
// 2. 提交:返回数据 ID
rpc.SUBMIT_DATA().then(function (dataId) {
// 关键动作:dataId 必须与业务单据绑定保存
workOrder.recordDataId = dataId;
});
dataId 的保存不是可选项。它丢了,这份数据就无法从业务侧关联回看,只能到数据库里翻。回看有两条路:查看器 URL 直接带 dataId 参数加载已保存数据,或宿主侧调用 LOAD_DATA({ id: dataId })。
至此一条完整链路闭合:主数据组装 params 数组 → SET_DATA 下发 → 循环区域渲染 → 填写 → GET_DATA 取数 / SUBMIT_DATA 归档 → dataId 挂到业务单据 → 按需回看。全程只有一张模板。
七、什么时候放弃归一
上面的方案隐含一个前提:各产品线的差异区结构同构——都是"参数名 / 标准 / 实测值"这样的行结构,只是条目不同。这个前提不成立时,就要放弃归一:
- 公共区布局都不同、行列结构完全两样,属于结构级差异,强行塞进一张模板会让字段模型出现大量"仅对某型号有意义"的畸形字段;
- 此时更稳妥的做法是按结构拆模板。模板的创建不必从零开始,复制路由支持传
oldId继承已有模板起稿(/design/copy); - 混合场景——大部分型号同构、个别型号特殊——可以让通用模板与特殊模板并存,运行时按型号调用
LOAD_TEMPLATE({ id, version })切换; - 模板改版后,
version参数可指定历史版本,归档数据回看时按归档时记录的版本号渲染。
取舍的口径可以很简单:参数条目层面的不同,放进数据里解决;连版式骨架都不一样,就另起一张模板。
八、技术选型
回到第二节那份自研清单——字段注册表、路径解析器、绑定协议、循环渲染器、宿主通信。这五项的投入是一次性的,但协议兼容性的维护是持续的,自研方案真正的成本在后者。实施团队在检验记录这类多型号表单场景中,选择在表单层引入 FlashTable 作为表单开发工具,对应关系如下:
- 以文档模板为结构来源,Word/Excel 单据可 1:1 还原成线上表单,线下既有的公共区版式不必重画;
- 原生支持行循环、列循环、块循环,数组条数即渲染行数,手动加行继承校验;
- 字段契约通过数据源模型推送接口维护,
./#两种连接符的路径规则既描述数据结构,也充当组件绑定的键; - 内置 postMessage 通信封装(LOAD_DATA/SET_DATA/GET_DATA/SUBMIT_DATA/LOAD_TEMPLATE 等),并提供 Docker 离线镜像包,可部署在 OpenEuler/OpenKylin 等国产操作系统上。
九、复盘
把整条实现线收拢:差异建模成数组,是整个方案的地基——型号不同即数组内容不同;字段契约靠推送维护,路径规则让契约与结构合为一体;绑定的实质是组件名对齐路径,这是实践中最常见的一个坑;渲染受 TEMPLATE_LOADED 时序约束,循环区域只认数组;数据经 SUBMIT_DATA 归档,dataId 是回看的唯一钥匙;结构级差异出现时,果断拆模板,复制路由与版本参数负责控制拆分成本。
多型号的检验记录、过程记录这类单据,表单层适合用 FlashTable 这类表单开发工具来落地——结构画一次、差异用数据表达、型号扩充靠主数据而非模板——比在每个项目里重写一套路径解析与循环渲染要稳妥得多。