场景#
E10 EB 表单里,有些页面元素的显示文本需要跟着浏览框选择结果变化。例如浏览框选择“施工许可证”后,把页面上的默认标题改成“施工许可证编号”。
配置方式是让目标元素自己带映射关系:
<span class="666" data-施工许可证="施工许可证编号" data-营业执照="统一社会信用代码">默认名称</span>代码读取浏览框显示名称,再找目标元素上的 data-浏览框显示名称。找到后,用该属性值替换元素显示文本。
关键点#
BROWSER_FIELD_DATA_KEY填浏览框字段的数据 key,不是 CSS id。TARGET_CLASS填需要动态改文案的元素 class。- 浏览框支持多选时,先匹配完整显示名,再匹配逗号拆分后的单项显示名。
- E10 保存后可能只刷新表单 DOM 或表单实例,不一定整页刷新。
- 每次刷新都尽量重新获取最新
formSdk,不要长期依赖旧实例。 - 用字段变化、保存动作、
MutationObserver和定时轮询一起兜底,解决保存后元素回到默认名称的问题。 - 脚本再次加载时先销毁旧实例,避免多个定时器和监听器重复运行。
可复用代码#
/**
* E10 EB 表单 ecode:根据浏览框显示名称动态修改页面元素显示文本。
*/
(function () {
var DEBUG = false;
var BROWSER_FIELD_DATA_KEY = "这里改成浏览框的数据key";
var TARGET_CLASS = "666";
var POLL_INTERVAL = 800;
var APPLY_DELAY = 120;
var CHANGE_DELAY = 500;
var GLOBAL_STATE_KEY = "__dynamic_title_666_state__";
if (window[GLOBAL_STATE_KEY] && window[GLOBAL_STATE_KEY].destroy) {
window[GLOBAL_STATE_KEY].destroy();
}
var state = {
destroyed: false,
formSdk: null,
moduleKey: "",
formId: "",
dataId: "",
timerId: null,
pollId: null,
observer: null,
listener: null,
lastBoundSdk: null,
lastBoundField: "",
lastSaveActionSdk: null
};
window[GLOBAL_STATE_KEY] = state;
function trim(value) {
return String(value || "").replace(/^\s+|\s+$/g, "");
}
function debugLog(message, data) {
if (DEBUG && window.console && console.log) {
console.log("[dynamic-title]", message, data || "");
}
}
function getLatestFormSdk() {
var sdk = null;
try {
if (window.WeFormSDK && window.WeFormSDK.getWeFormInstance) {
if (state.moduleKey && state.formId && state.dataId) {
sdk = window.WeFormSDK.getWeFormInstance(state.moduleKey, state.formId, state.dataId);
}
if (!sdk && state.moduleKey && state.formId) {
sdk = window.WeFormSDK.getWeFormInstance(state.moduleKey, state.formId);
}
if (!sdk && state.moduleKey) {
sdk = window.WeFormSDK.getWeFormInstance(state.moduleKey);
}
if (!sdk) {
sdk = window.WeFormSDK.getWeFormInstance();
}
}
} catch (e) {
debugLog("获取最新 formSdk 失败", e.message);
}
if (sdk) {
state.formSdk = sdk;
}
return sdk || state.formSdk;
}
function getFormSdkFromEvent(event) {
var detail = event && event.detail ? event.detail : {};
state.moduleKey = detail.module || detail.moduleKey || state.moduleKey;
state.formId = detail.formId || state.formId;
state.dataId = detail.dataId || state.dataId;
if (detail.formSdk) {
state.formSdk = detail.formSdk;
}
return state.formSdk || getLatestFormSdk();
}
function resolveBrowserField(formSdk) {
try {
return formSdk.convertFieldNameToId(BROWSER_FIELD_DATA_KEY, "main", true);
} catch (e) {
debugLog("浏览框 dataKey 转 fieldMark 失败", e.message);
return "";
}
}
function getBrowserShowName(formSdk, browserField) {
var showName = "";
try {
if (formSdk.getBrowserShowName) {
showName = trim(formSdk.getBrowserShowName(browserField, ","));
}
if (!showName && formSdk.getFieldValue) {
showName = trim(formSdk.getFieldValue(browserField));
}
} catch (e) {
debugLog("读取浏览框显示名失败", e.message);
}
return showName;
}
function addAttrName(list, attrName) {
if (attrName && list.indexOf(attrName) === -1) {
list.push(attrName);
}
}
function buildAttrNames(showName) {
var list = [];
var cleanName = trim(showName);
var parts = cleanName.split(",");
addAttrName(list, "data-" + cleanName);
for (var i = 0; i < parts.length; i++) {
addAttrName(list, "data-" + trim(parts[i]));
}
return list;
}
function findNewTitle(node, attrNames) {
for (var i = 0; i < attrNames.length; i++) {
var value = node.getAttribute(attrNames[i]);
if (value !== null && typeof value !== "undefined") {
return value;
}
}
return null;
}
function setNodeText(node, text) {
if (typeof node.innerText !== "undefined") {
if (node.innerText !== text) {
node.innerText = text;
}
return;
}
if (node.textContent !== text) {
node.textContent = text;
}
}
function applyNames(reason) {
if (state.destroyed) {
return;
}
var formSdk = getLatestFormSdk();
if (!formSdk) {
debugLog("未获取到 formSdk,跳过", reason);
return;
}
var browserField = resolveBrowserField(formSdk);
if (!browserField) {
return;
}
var showName = getBrowserShowName(formSdk, browserField);
if (!showName) {
debugLog("浏览框显示名为空,跳过", reason);
return;
}
var attrNames = buildAttrNames(showName);
var nodes = document.getElementsByClassName(TARGET_CLASS);
debugLog("刷新标题", {
reason: reason,
showName: showName,
attrNames: attrNames,
count: nodes.length
});
for (var i = 0; i < nodes.length; i++) {
var newTitle = findNewTitle(nodes[i], attrNames);
if (newTitle !== null) {
setNodeText(nodes[i], newTitle);
}
}
}
function scheduleApply(reason) {
if (state.destroyed) {
return;
}
if (state.timerId) {
clearTimeout(state.timerId);
}
state.timerId = setTimeout(function () {
applyNames(reason);
}, APPLY_DELAY);
}
function bindFieldChange(formSdk, browserField) {
if (!formSdk.bindFieldChangeEvent) {
return;
}
if (state.lastBoundSdk === formSdk && state.lastBoundField === browserField) {
return;
}
state.lastBoundSdk = formSdk;
state.lastBoundField = browserField;
formSdk.bindFieldChangeEvent(browserField, function () {
scheduleApply("字段变化");
setTimeout(function () {
applyNames("字段变化延迟补刷");
}, CHANGE_DELAY);
});
}
function bindSaveAction(formSdk) {
if (!formSdk.registerAction || !window.WeFormSDK || !window.WeFormSDK.ACTION_FORM_SAVE) {
return;
}
if (state.lastSaveActionSdk === formSdk) {
return;
}
state.lastSaveActionSdk = formSdk;
formSdk.registerAction(window.WeFormSDK.ACTION_FORM_SAVE, function () {
scheduleApply("保存完成");
setTimeout(function () { applyNames("保存完成 500ms 补刷"); }, 500);
setTimeout(function () { applyNames("保存完成 1500ms 补刷"); }, 1500);
setTimeout(function () { applyNames("保存完成 3000ms 补刷"); }, 3000);
});
}
function ensureObserver() {
if (state.observer || !window.MutationObserver || !document.body) {
return;
}
state.observer = new MutationObserver(function () {
scheduleApply("DOM 变化");
});
state.observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true
});
}
function ensurePoll() {
if (state.pollId) {
clearInterval(state.pollId);
}
state.pollId = setInterval(function () {
applyNames("轮询");
}, POLL_INTERVAL);
}
function start(formSdk) {
if (state.destroyed) {
return;
}
state.formSdk = formSdk || getLatestFormSdk();
if (!state.formSdk) {
return;
}
var browserField = resolveBrowserField(state.formSdk);
if (!browserField) {
return;
}
bindFieldChange(state.formSdk, browserField);
bindSaveAction(state.formSdk);
ensureObserver();
ensurePoll();
applyNames("启动立即刷新");
setTimeout(function () { applyNames("启动 500ms 补刷"); }, 500);
setTimeout(function () { applyNames("启动 1500ms 补刷"); }, 1500);
}
state.destroy = function () {
state.destroyed = true;
if (state.timerId) clearTimeout(state.timerId);
if (state.pollId) clearInterval(state.pollId);
if (state.observer) state.observer.disconnect();
if (state.listener) window.removeEventListener("onFormReady", state.listener);
};
state.listener = function (event) {
var formSdk = getFormSdkFromEvent(event);
if (formSdk) {
start(formSdk);
}
};
window.addEventListener("onFormReady", state.listener);
setTimeout(function () { start(getLatestFormSdk()); }, 300);
setTimeout(function () { applyNames("加载后 1200ms 补刷"); }, 1200);
setTimeout(function () { applyNames("加载后 3000ms 补刷"); }, 3000);
})();使用步骤#
- 把
BROWSER_FIELD_DATA_KEY改成浏览框字段的数据 key。 - 把
TARGET_CLASS改成页面元素使用的 class。 - 在目标元素上配置
data-浏览框显示名称="要显示的新文本"。 - 如果是多选浏览框,同时配置完整显示名或单项显示名。
- 保存后测试字段变化、保存、刷新 DOM 三种场景。
注意点#
- class 名
666能用,但不够语义化,后续项目建议改成类似dynamic-title。 MutationObserver观察范围是整个document.body,复杂页面可能触发频繁;如果页面很重,可以把观察范围缩小到表单容器。- 轮询是兜底方案,主要处理保存后 DOM 被泛微重置但事件没完整触发的情况。
GLOBAL_STATE_KEY要保持唯一,避免同一页面多个 ecode 片段互相销毁。- 如果只想在一个表单实例内生效,优先保留
moduleKey/formId/dataId的精确实例获取逻辑。
续集:ESB Mapping 驱动版本#
前面的版本靠浏览框显示名称去匹配元素上的 data-* 属性,适合快速验证,但配置会分散在页面元素里。后续更稳定的方向是把映射关系放到 ESB 动作流里维护,前端只负责拉取 mapping、读取证照类型 ID、修改标题文本。
这版先只做动态改名,不合并隐藏行、空白行清理或布局重排。隐藏和清理布局风险更高,应该拆成单独 ecode 调试。
Mapping 接口#
POST /api/esb/server/event/triggerActionFlow补充理解:这个接口本质上是在页面内调用 E10 内部接口,去触发指定的动作流。动作流可以用“自定义触发”组件作为开始,用默认结束组件作为返回;这两个组件基本对应一次调用里的请求 body 和响应 body。其他字段结构不要硬猜,可以通过动作流触发调试观察实际入参和出参。
前端 ecode 只负责传入 customParams 和 esbFlowId,mapping 的组装、查询和返回建议放在动作流里处理。内部参考文档需要登录查看:E10 动作流触发参考。
请求体保留最小参数:
{
customParams: {
mainTable: {}
},
moduleSource: "#optional",
esbFlowId: "ESB_FLOW_ID"
}实际读取路径:
response.actionData.responseData.customData.mainTable.detail3返回行示例:
[
{
"fieldname": ["varcharField1"],
"showname": "新的字段标题",
"certtype": "CERT_TYPE_OPTION_ID"
}
]字段含义:
| 字段 | 作用 |
|---|---|
certtype | 证照类型浏览框选项 ID。 |
fieldname | 页面标题原始文本或字段 dataKey,可为数组。 |
showname | 当前证照类型下要显示的新标题。 |
关键经验#
- 证照类型字段固定按
certType取值。 - 浏览框优先用
getBrowserOptionId获取 optionId;不要再依赖显示名称匹配。 - 真正要改的是
.weapp-form-widget-internal-title--text span里的标题文本,不是输入框placeholder。 - 每次改名前先恢复上一轮改过的标题,避免切换证照类型后旧标题残留。
getFieldInfo更适合定位业务字段输入框位置,不适合直接定位标题文本。- E10 保存后可能局部刷新 DOM 或 form 实例,所以需要
formReady、延迟补刷和轻量 watcher。 - 多卡片场景里
getWeFormInstance()可能拿到当前活动表单;当前单表单主流程可先用简化方式。
最小稳定代码#
(function () {
var FLOW_ID = "ESB_FLOW_ID";
var CERT_TYPE_DATA_KEY = "certType";
var STATE_KEY = "__hz_cert_mapping_rename_only__";
if (window[STATE_KEY] && window[STATE_KEY].destroy) {
window[STATE_KEY].destroy();
}
var state = {
destroyed: false,
formSdk: null,
mappings: [],
mappingLoaded: false,
timer: null,
watcher: null,
lastCertId: ""
};
window[STATE_KEY] = state;
function trim(value) {
return String(value || "").replace(/\u00a0/g, " ").replace(/^\s+|\s+$/g, "");
}
function sdk() {
var formSdk = null;
try {
if (window.WeFormSDK && window.WeFormSDK.getWeFormInstance) {
formSdk = window.WeFormSDK.getWeFormInstance();
}
} catch (e) {}
if (formSdk) {
state.formSdk = formSdk;
}
return formSdk || state.formSdk;
}
function fieldMark(dataKey) {
var formSdk = sdk();
if (!formSdk || !dataKey) {
return "";
}
try {
return formSdk.convertFieldNameToId(dataKey, "main", true);
} catch (e) {
return "";
}
}
function certId() {
var formSdk = sdk();
var mark = fieldMark(CERT_TYPE_DATA_KEY);
if (!formSdk || !mark) {
return "";
}
try {
return trim(formSdk.getBrowserOptionId(mark, ","));
} catch (e1) {
try {
return trim(formSdk.getFieldValue(mark));
} catch (e2) {
return "";
}
}
}
function normalizeMappings(rows) {
var list = [];
var i;
var j;
var row;
var names;
for (i = 0; rows && i < rows.length; i++) {
row = rows[i] || {};
names = row.fieldname;
if (typeof names === "string") {
names = [names];
}
for (j = 0; names && j < names.length; j++) {
if (trim(row.certtype) && trim(names[j])) {
list.push({
certtype: trim(row.certtype),
fieldname: trim(names[j]),
showname: trim(row.showname) || trim(names[j])
});
}
}
}
return list;
}
function loadMappings(callback) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/esb/server/event/triggerActionFlow", true);
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
xhr.onreadystatechange = function () {
var json;
var rows;
if (xhr.readyState !== 4) {
return;
}
try {
json = JSON.parse(xhr.responseText || "{}");
} catch (e) {
json = {};
}
rows =
json &&
json.actionData &&
json.actionData.responseData &&
json.actionData.responseData.customData &&
json.actionData.responseData.customData.mainTable &&
json.actionData.responseData.customData.mainTable.detail3;
state.mappings = normalizeMappings(rows || []);
state.mappingLoaded = true;
if (callback) {
callback();
}
};
xhr.send(JSON.stringify({
customParams: {
mainTable: {}
},
moduleSource: "#optional",
esbFlowId: FLOW_ID
}));
}
function originTitle(node) {
var text;
if (!node) {
return "";
}
text = trim(node.getAttribute("data-hz-origin-title"));
if (!text) {
text = trim(node.innerText || node.textContent);
node.setAttribute("data-hz-origin-title", text);
}
return text;
}
function restoreTitle() {
var nodes = document.querySelectorAll("[data-hz-renamed-title='1']");
var i;
var old;
for (i = 0; i < nodes.length; i++) {
old = nodes[i].getAttribute("data-hz-origin-title");
if (old) {
nodes[i].innerText = old;
}
nodes[i].removeAttribute("data-hz-renamed-title");
}
}
function setTitle(node, text) {
if (!node) {
return;
}
if (!node.getAttribute("data-hz-origin-title")) {
node.setAttribute("data-hz-origin-title", trim(node.innerText || node.textContent));
}
node.innerText = text;
node.setAttribute("data-hz-renamed-title", "1");
}
function applyRename() {
var id = certId();
var nodes = document.querySelectorAll(".weapp-form-widget-internal-title--text span");
var i;
var j;
var key;
var mapping;
restoreTitle();
if (!state.mappingLoaded || !id) {
return;
}
for (i = 0; i < nodes.length; i++) {
key = originTitle(nodes[i]);
for (j = 0; j < state.mappings.length; j++) {
mapping = state.mappings[j];
if (mapping.certtype === id && mapping.fieldname === key) {
setTitle(nodes[i], mapping.showname);
}
}
}
}
function schedule(delay) {
if (state.destroyed) {
return;
}
clearTimeout(state.timer);
state.timer = setTimeout(function () {
applyRename();
}, delay || 100);
}
function startWatcher() {
state.watcher = setInterval(function () {
var id;
if (state.destroyed) {
return;
}
id = certId();
if (id !== state.lastCertId) {
state.lastCertId = id;
schedule(80);
}
}, 300);
}
function boot() {
loadMappings(function () {
state.lastCertId = certId();
schedule(100);
setTimeout(function () {
schedule(0);
}, 600);
});
startWatcher();
}
state.destroy = function () {
state.destroyed = true;
clearTimeout(state.timer);
clearInterval(state.watcher);
restoreTitle();
};
try {
if (window.ebuilderSDK && window.ebuilderSDK.getPageSDK) {
window.ebuilderSDK.getPageSDK().on("formReady", function () {
boot();
});
} else {
boot();
}
} catch (e) {
boot();
}
})();排查顺序#
- ESB 接口是否返回数据。
- 返回路径是否仍然是
actionData.responseData.customData.mainTable.detail3。 certType是否拿到了浏览框 optionId。- mapping 的
certtype是否等于当前 optionId。 - 标题节点原始文本是否等于 mapping 的
fieldname。 - 是否错误修改到了输入框,而不是标题文本节点。
当前结论#
- 推荐保留 ESB mapping 驱动动态改名。
- 推荐使用证照类型 optionId 匹配。
- 推荐用标题 DOM 原始文本匹配 mapping 的
fieldname。 - 推荐用
data-hz-origin-title保存原始标题,切换前先恢复再重命名。 - 暂不把隐藏行、删除空白、重排布局混入这个脚本。
通用调试与排障补充#
E10 ecode 最容易卡在执行时机、SDK 实例、字段取值、接口返回和 DOM 定位这几层。排障时不要一口气改完最终功能,先按层验证。
分层验证顺序#
- 控制台是否能看到脚本启动日志。
- 是否触发
formReady或onFormReady。 - 是否能拿到
window.WeFormSDK.getWeFormInstance()。 convertFieldNameToId("certType", "main", true)是否返回 fieldMark。- 浏览框是否能取到
getBrowserOptionId。 - ESB 接口是否返回 mapping,返回路径是否正确。
.weapp-form-widget-internal-title--text span是否能找到标题节点。- 标题原始文本是否等于 mapping 的
fieldname。 - 保存或局部刷新后是否需要重新绑定和补刷。
语法与入口#
E10 的 JavaScript 校验偏保守,正式片段默认使用 ES5 写法:var、普通 function、try/catch。避免可选链、箭头函数、const / let、JSX、class 和复杂语法糖。
最小执行测试:
(function () {
try {
console.log("[ecode-test] script start", new Date().toLocaleString());
} catch (e) {
console.log("[ecode-test] script error", e);
}
})();表单 ready 调试:
(function () {
try {
if (window.ebuilderSDK && window.ebuilderSDK.getPageSDK) {
window.ebuilderSDK.getPageSDK().on("formReady", function (args) {
console.log("[ecode-test] pageSdk formReady", args);
});
}
window.addEventListener("onFormReady", function (event) {
console.log("[ecode-test] window onFormReady", event && event.detail);
});
} catch (e) {
console.log("[ecode-test] formReady error", e);
}
})();SDK、字段与浏览框#
单表单场景先用简化方式取实例:
var formSdk = window.WeFormSDK.getWeFormInstance();
var mark = formSdk.convertFieldNameToId("certType", "main", true);
console.log("[ecode-test] fieldMark", mark);
console.log("[ecode-test] getFieldValue", formSdk.getFieldValue(mark));
console.log("[ecode-test] getBrowserOptionId", formSdk.getBrowserOptionId(mark, ","));
console.log("[ecode-test] getBrowserShowName", formSdk.getBrowserShowName(mark, ","));经验:
dataKey不是字段 ID,主表字段一般要先convertFieldNameToId(dataKey, "main", true)。- 浏览框稳定匹配优先用
getBrowserOptionId。 - 字段变化事件触发时,浏览框值可能还没完全更新,建议延迟几十到几百毫秒再读。
- 保存、联动、局部刷新后,事件绑定可能失效,
formReady后要能重新绑定。
接口与 DOM#
接口先单独调通,再接入改名逻辑。不要接口、DOM、隐藏布局一起调。
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/esb/server/event/triggerActionFlow", true);
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) {
return;
}
console.log("[ecode-test] status", xhr.status);
console.log("[ecode-test] responseText", xhr.responseText);
};标题节点调试:
var nodes = document.querySelectorAll(".weapp-form-widget-internal-title--text span");
var i;
console.log("[ecode-test] title node count", nodes.length);
for (i = 0; i < nodes.length; i++) {
console.log("[ecode-test] title", i, nodes[i].innerText || nodes[i].textContent, nodes[i]);
}经验:
- 本需求改的是标题文本节点,不是输入框,不是
placeholder。 getFieldInfo更适合找业务字段输入框位置,不适合直接找标题文本。- 如果值正确但页面没变化,优先检查 DOM 选择器和目标节点。
保存后失效与多卡片#
保存后标题恢复默认,通常是 E10 局部刷新了表单 DOM。处理方式是:formReady 后执行一次,字段变化后执行一次,保存或局部刷新后延迟补刷,必要时用轻量 watcher 只比较证照类型 ID。
var lastId = "";
setInterval(function () {
var id = certId();
if (id !== lastId) {
lastId = id;
applyRename();
}
}, 300);多卡片 / 多表单场景要谨慎:getWeFormInstance() 可能拿到当前活动表单;全局 STATE_KEY 复用也可能让新实例销毁旧实例。没有明确表单隔离参数时,先保持动态改名简单,不要把多卡片锁定、隐藏行、布局重排混在一起。
隐藏与布局不要混进来#
隐藏布局是高风险逻辑。不要全页面扫空 td 后直接隐藏,也不要一开始就重排。若后续要做,单独开发:先用 mapping 判断保留字段,再用 getFieldInfo 和标题 DOM 定位业务区域,只处理目标表格的 td.cell_Sheet1_x_x,并确认行列边界。
日志模板#
function log(message, data) {
if (window.console && console.log) {
console.log("[hz-ecode-debug] " + message, data || "");
}
}常用日志点:脚本启动、formSdk、fieldMark、当前 optionId、接口返回、mapping 数量、标题节点数量、命中字段、准备修改节点。
上线前检查#
- 代码能保存,没有 JavaScript 格式错误。
- 首次进入页面能正确改名。
- 切换证照类型后能恢复旧标题并应用新标题。
- 保存后、局部刷新后仍能补刷。
- ESB 接口异常时页面不报错,只是跳过改名。
- mapping 为空时页面不报错。
- 同页面打开其他卡片时,没有明显误改或互相恢复。
- 没有把隐藏行、空白行清理和动态改名混在同一个稳定版里。
