feat(table): Vue 模板钩子支持

This commit is contained in:
augushong
2026-09-11 12:03:34 +00:00
parent 9544bedd4b
commit 2f8417d3b4

View File

@@ -181,7 +181,8 @@
// (b) 模板引擎优先级解析:tplFunction > tplLay > tplVue > tplLayId > tplVueId,
// 提供一种即生效;五种全无且非 table 条目 → console.warn 并回退 table。
// lay/layId 引擎挂载于 renderLayoutForm(任务 16,layui.laytpl);vue/vueId 挂载于任务 17。
// lay/layId(任务 16,layui.laytpl)与 vue/vueId(任务 17,全局 Vue 2.6.10
// 运行时编译)均挂载于 renderLayoutForm。
var resolveLayoutEngine = function (entry) {
if (entry.name === 'table') {
return {kind: 'table', entry: entry};
@@ -231,22 +232,76 @@
return resolveLayoutEngine(entry);
};
// 模板管线失败回退(tplLayId 节点缺失 / laytpl 渲染错误 / Vue 引擎未挂载):
// 当前形态归位 'table'(与"无钩子条目回退"同语义,幂等、不写 localStorage
// 记忆,用户重新切换可再次触发),容器清空隐藏。调用方据 false 返回值做
// 引擎纯渲染,随后的 syncLayoutVisibility 按 table kind 复显引擎数据区,
// 避免"容器残留上一形态旧内容 + 引擎数据被隐藏"的视觉错位。
// 模板管线失败回退(tplLayId/tplVueId 节点缺失 / laytpl 渲染错误 / Vue 未引入
// 或挂载渲染失败):当前形态归位 'table'(与"无钩子条目回退"同语义,幂等、
// 不写 localStorage 记忆,用户重新切换可再次触发),容器销毁 Vue 实例后清空
// 隐藏。调用方据 false 返回值做引擎纯渲染,随后的 syncLayoutVisibility 按
// table kind 复显引擎数据区,避免"容器残留上一形态旧内容 + 引擎数据被隐藏"
// 的视觉错位。
var fallbackLayoutToTable = function (tableId, $container) {
var layout = controlledLayouts[tableId];
if (layout) {
layout.current = 'table';
}
destroyLayoutVueInstances(tableId);
if ($container && $container.length) {
$container.addClass('layui-hide').empty();
}
return false;
};
// ========== vue/vueId 引擎(任务 17,全局 Vue 2.6.10 运行时编译)==========
// 渲染模式为列表级单实例:模板内 v-for="d in rows" 循环行,一次渲染只挂载
// 一个实例(逐行 new Vue 的成本随行数线性膨胀,销毁纪律也难以收口)。
// 实例存活表:tableId → 当前实例数组。
var layoutVueInstances = {};
// 销毁本表全部存活实例(渲染入口 / fallbackLayoutToTable /
// syncLayoutVisibility 三处统一调用,幂等)。$destroy 只拆 watcher 与事件,
// 不动 DOM,容器清空由调用方完成。任何重渲路径(store done 管线/搜索/
// 切换/回退)都必须先销毁旧实例再挂新实例——销毁重建式是"实例存活期禁止
// 响应式数据变更"约束(约束 2)的落地方式:form.render 美化插入的 DOM
// (div.layui-form-switch 等)不受 Vue 虚拟 DOM 管理,存活期 re-patch 会
// 清除它们。
var destroyLayoutVueInstances = function (tableId) {
var list = layoutVueInstances[tableId] || [];
$.each(list, function (i, vm) {
try {
vm.$destroy();
} catch (e) {}
});
layoutVueInstances[tableId] = [];
};
// Vue 生产构建中模板编译错误不抛异常也不打日志(dev 告警被裁剪),坏模板
// 只会在挂载渲染时经全局 errorHandler 暴露——链式接管 Vue.config.errorHandler:
// 本框架实例(vm.__uaLayout 标记)按形态名 console.error 并记录
// lastVueLayoutError,由 renderLayoutForm 探测后回退;其余实例
// (propertyInput/tagInput 等页面自有 Vue)透传既有处理器或复刻默认行为。
var lastVueLayoutError = null;
var vueErrorHandlerInstalled = false;
var installVueErrorHandler = function () {
if (vueErrorHandlerInstalled) {
return;
}
vueErrorHandlerInstalled = true;
var previous = window.Vue.config.errorHandler;
window.Vue.config.errorHandler = function (err, vm, info) {
if (vm && vm.__uaLayout) {
var meta = vm.__uaLayout;
lastVueLayoutError = {tableId: meta.tableId, name: meta.name};
console.error('[ua.table] Vue 模板渲染失败(形态 "' + meta.name + '"):'
+ ((err && err.message) || err) + ',本次回退 table 形态');
return;
}
if (typeof previous === 'function') {
previous(err, vm, info);
return;
}
console.error(err);
};
};
// 形态可见性同步:引擎数据区(表头+表体+合计行)与模板容器互斥显隐,
// 引擎视图的工具栏(刷新/新增/批量删除等)两形态共用保持可见,搜索区
// 本就挂在表格元素之前不受影响。非 table 形态下引擎根本不渲染行数据
@@ -261,6 +316,7 @@
var $total = $view.find('.layui-table-total').first();
var $container = $('#ua-layout-' + tableId);
if (resolveCurrentLayout(tableId).kind === 'table') {
destroyLayoutVueInstances(tableId);
$box.add($total).removeClass('layui-hide');
$container.addClass('layui-hide').empty();
} else {
@@ -279,8 +335,9 @@
$layoutContainer.addClass('ua-layout-loading');
opts.done = function (res) {
// 受控渲染分发:table 形态走引擎纯渲染(现状),非 table 形态走
// 模板管线(渲染上下文 + 行级循环);模板管线不可用(引擎未挂载,
// Vue 待任务 17;tplLayId 节点缺失/laytpl 渲染错误)时回退引擎纯渲染兜底
// 模板管线(渲染上下文 + 行级循环);模板管线不可用(Vue 未引入/
// 挂载渲染失败;tplLayId/tplVueId 节点缺失/laytpl 渲染错误)时
// 回退引擎纯渲染兜底
var engineRender = function () {
originalTableReloadData.call(table, tableId, {
data: res.data,
@@ -416,6 +473,25 @@
// - laytpl 编译/运行错误不抛异常,render() 返回 "Laytpl Error: ..." 字符串
// (laytpl 内部已 console.error 细节),框架探测到后按条目名 console.error
// 并返回 false,调用方回退引擎纯渲染
// 【Vue 调用约定(tplVue / tplVueId,任务 17)】
// 列表级单实例:框架把模板包进单一根节点 <div class="ua-layout-vue">
// (Vue 2 实例模板根元素禁用 v-for 且生产构建该错误静默,包裹层规避
// 全部根约束问题,多根模板也因此可用),行循环由模板作者用
// v-for="d in rows" 承接:
// - rows = 行上下文数组,d 与 tplFunction/tplLay 的行上下文同构
// (d._cells.xxx / d._operatHtml / d._checked / d._disabled);
// - list = 列表上下文 {tableId, rows, page, count, checkedAll}
// (laytpl 的 d._list 同物;Vue 实例对 _/$ 前缀 data 键不做代理,
// 裸标识符 _list 会 ReferenceError,故 Vue 形态改名为 list);
// - _cells/_operatHtml 含 HTML,必须用 v-html 输出,纯文本用 {{ }};
// - tplVueId 是页面 <script type="text/x-template"> 节点选择器,节点
// 必须位于渲染容器之外(下方硬约束 2),.html() 读取原始文本;
// - 挂载成功后一次性 form.render(约束 1);实例存活期禁止响应式数据
// 变更(约束 2):渲染上下文经 Object.freeze 冻结,数据不可变即不会
// re-patch,form.render 美化 DOM 不被清除,更新一律销毁重建;
// - 模板编译错误在 Vue 生产构建不抛异常,经链式全局 errorHandler 记录
// (installVueErrorHandler),框架探测后按条目名 console.error 并
// 返回 false,调用方回退引擎纯渲染。
// 【两条硬约束】
// 1. 模板容器恒带 class="layui-form"(form 控件渲染与事件受 .layui-form
// 祖先约束,缺失则模板里的 switch/checkbox 全部失效);
@@ -428,6 +504,10 @@
if (controlledLayouts[tableId] === undefined) {
return false;
}
// 销毁重建式(约束 2):任何重渲路径进入本函数先销毁上一轮 Vue 实例,
// 容器被清空的其余路径由 fallbackLayoutToTable / syncLayoutVisibility
// 兜底同规则销毁
destroyLayoutVueInstances(tableId);
var resolved = resolveCurrentLayout(tableId);
if (resolved.kind === 'table') {
return false;
@@ -454,13 +534,95 @@
template = resolved.entry.tplLay;
}
laytplCompiled = laytpl(template);
} else if (resolved.kind !== 'function') {
// vue/vueId 分发点:引擎挂载于任务 17(Vue)
console.warn('[ua.table] 模板引擎未挂载:' + resolved.kind + '(任务 17 实现),本次回退 table 形态');
return fallbackLayoutToTable(tableId, $container);
}
// vue/vueId 引擎(任务 17):tplVue = 模板字符串;tplVueId =
// <script type="text/x-template"> 节点选择器(节点须在渲染容器之外)
var vueTemplate = null;
if (resolved.kind === 'vue' || resolved.kind === 'vueId') {
if (typeof window.Vue === 'undefined') {
console.error('[ua.table] 全局 Vue 未引入(形态 "' + resolved.entry.name + '"),本次回退 table 形态');
return fallbackLayoutToTable(tableId, $container);
}
installVueErrorHandler();
if (resolved.kind === 'vueId') {
var $vueTplNode = $(resolved.entry.tplVueId);
if ($vueTplNode.length === 0) {
console.error('[ua.table] tplVueId 节点不存在:' + resolved.entry.tplVueId
+ '(形态 "' + resolved.entry.name + '"),本次回退 table 形态');
return fallbackLayoutToTable(tableId, $container);
}
vueTemplate = $vueTplNode.html();
} else {
vueTemplate = resolved.entry.tplVue;
}
}
var context = ua.table.buildLayoutContext(tableId);
if (vueTemplate !== null) {
// 自定义模板形态移除内置 card 网格类(与 laytpl 同规则)
$container.toggleClass('ua-layout-cards', resolved.entry.builtinCard === true);
if (context.rows.length === 0) {
$container.html('<div class="ua-layout-empty">无数据</div>');
form.render(null, 'ua-layout-' + tableId);
return true;
}
// 冻结渲染上下文(约束 2 的强制保证):顶层数据不可扩展即不会被
// observe,模板表达式改写失效、不触发 re-patch,form.render 插入
// 的美化 DOM 安全;数据更新只经销毁重建(本函数开头已销毁)
Object.freeze(context.rows);
$.each(context.rows, function (i, rowCtx) {
Object.freeze(rowCtx);
});
Object.freeze(context);
var vm = null;
try {
vm = new window.Vue({
template: '<div class="ua-layout-vue">' + vueTemplate + '</div>',
data: {rows: context.rows, list: context}
});
vm.__uaLayout = {tableId: tableId, name: resolved.entry.name};
vm.$mount();
} catch (e) {
// 挂载期同步异常(CSP Function 构造失败等);渲染期错误已由
// 全局 errorHandler 记录并告警,此处不再重复;此时实例尚未
// 注册进存活表,需就地销毁
if (lastVueLayoutError === null) {
console.error('[ua.table] Vue 模板挂载失败(形态 "' + resolved.entry.name + '"):'
+ ((e && e.message) || e) + ',本次回退 table 形态');
}
if (vm) {
try {
vm.$destroy();
} catch (ignored) {}
}
return fallbackLayoutToTable(tableId, $container);
}
// 编译/渲染错误探测(errorHandler 已按形态名 console.error);
// 同上,实例未注册,先就地销毁再归位
if (lastVueLayoutError && lastVueLayoutError.tableId === tableId) {
lastVueLayoutError = null;
try {
vm.$destroy();
} catch (ignored) {}
return fallbackLayoutToTable(tableId, $container);
}
lastVueLayoutError = null;
if (!vm || !vm.$el) {
console.error('[ua.table] Vue 模板渲染为空(形态 "' + resolved.entry.name + '"),本次回退 table 形态');
return fallbackLayoutToTable(tableId, $container);
}
layoutVueInstances[tableId] = [vm];
// $destroy 只拆实例不动 DOM(见 destroyLayoutVueInstances 注释),
// 上一轮实例的 DOM 残留必须在此显式清空,否则每次重渲都会累积
$container.empty().append(vm.$el);
// 约束 1:容器恒为 .layui-form(lay-filter 与容器 id 同名),
// 挂载后一次性 scoped form.render 美化模板产出的 switch/checkbox
form.render(null, 'ua-layout-' + tableId);
return true;
}
var html = '';
if (context.rows.length === 0) {
html = '<div class="ua-layout-empty">无数据</div>';
@@ -502,8 +664,9 @@
},
// 切换当前形态:仅改写运行期状态并重渲染,不发起网络请求(数据取自
// store 当页状态)。目标条目不存在返回 false;模板管线不可用(无钩子
// 条目 / tplLayId 节点缺失 / laytpl 渲染错误,均已在 renderLayoutForm
// 内归位 current='table')时走引擎纯渲染兜底。
// 条目 / tplLayId/tplVueId 节点缺失 / laytpl 渲染错误 / Vue 未引入或
// 挂载渲染失败,均已在 renderLayoutForm 内归位 current='table')时走
// 引擎纯渲染兜底。
setLayoutForm: function (tableId, name) {
var layout = controlledLayouts[tableId];
if (!layout || !layout.byName[name]) {