ElTable实现空单元格自动填充占位符‘–’
根据前端开发规范及UE建议,考虑给表单的空单元格插入占位符‘–’,一开始的想法很简单,在el-table-column
中渲染时对传入的data
进行判断即可,相关代码如下:
<el-table-column label="按键" width="100px">
<span class="c-one-line did-desc-text">
{{scope.row.didDesc || $nullValue}}
</span>
</el-table-column>
但是问题来了,每一个表格都要加上这么一个 ||
进行判断,工作量比较大,需要思考一个统一的解决方案。
通过DOM操作实现
考虑CSS
选择器:empty
,参考MDN的介绍:
:empty
CSS 伪类 代表没有子元素的元素。子元素只可以是元素节点或文本(包括空格)。注释或处理指令都不会产生影响。
于是,可以这样选择为数据为空的单元格
.el-table__body td > div *:empty
使用通配符的原因是,我们并不清楚待插入的dom
节点到底位于div
下的哪一个子节点,因此只能通过通配符进行全量查找,配合部分标签和class
白名单的方式来插入占位符。
部分标签白名单解释:(都不需要插入占位符)
i
标签,一般用于生成图标input
,一般用于按钮,下拉框等表单元素use
,svg
图片中子标签
部分class白名单:
el-scrollbar__thumb
,悬浮框或者其他组件中的滚动条实现,对于此空标签不需要处理
通过:empty
进行节点选择存在一个问题:会忽略掉全是空白字符的标签,对于这些标签理论上对于用户而言也相当于空单元格。查询有没有解决这一问题的css选择器,发现确实有:blank
,参考MDN的介绍:
没有子节点;
仅有空的文本节点;
仅有空白符的文本节点.
非常匹配我们的需求,(可人生总是充满了但是)但是这个选择器目前没有被任何浏览器支持。
所以,只能通过Javascript进行文本节点内容判空处理了,以下为相关源码:
import {Table} from 'element-ui';
import {NULL_VALUE} from '~/common/constant';
// 给空单元格加上'--'占位
(Table as any).mixins = ((Table as any).mixins || []).concat({
updated(this: any) {
if (this.$el) {
const table = this.$el.querySelector('.el-table__body');
table.querySelectorAll('td > div *:empty')
.forEach((it: HTMLElement) => {
// 对表格中悬浮框的滚动条不做处理
if (!it.getAttribute('class')?.includes('el-scrollbar__thumb')) {
if (!['i', 'input', 'use'].includes(it.tagName.toLocaleLowerCase())) {
it.innerText = NULL_VALUE;
}
}
});
// 兼容el-table-column中template没子元素且跨行处理时textContent为空格的情况
table.querySelectorAll('td > div')
.forEach((it: HTMLElement) => {
if (!it.innerText.trim() && !it.children.length) {
it.innerText = NULL_VALUE;
}
});
}
}
});
其实文章写到这差不多也该结束了,至少在2020-04-01
之前是的。
可是却在动笔前,在2020-04-01
这天发现了这种实现方式的一个非常严重的bug
,通过js
修改过dom
的innerText
属性之后,尽管这个dom
节点之前被Vue
数据绑定过,可是节点被手动修改之后,当Vue
数据再发生变化时这个dom
节点却依然失去了响应式效果,也就是说Vue
的dom
更新机制被破坏了。
冥思苦想
为了了解Vue
的dom
更新机制被破坏的原因,需要深入了解Vue
相关源码,带着“Vue是如何更新dom”这个问题开始寻找Vue的相关处理逻辑。
通过调试Vue源码(src/core/vdom/patch.js patchVnode L548)发现在进行文本节点赋值的时候,使用了setTextContent方法:(src/platforms/web/runtime/node-ops.js L53),对于传入的node节点,设置文本属性:
export function setTextContent (node: Node, text: string) {
node.textContent = text
}
// var elm = vnode.elm = oldVnode.elm;
而当我们执行了dom.innerText为文本节点赋值之后
可以发现dom节点中的text节点发生变化,也就是说Vue中oldVnode中保存的elm对象已经不是最新的dom引用了,所以在正常逻辑下执行到nodeOps.setTextContent的时候理论上会更新到正常的dom节点,而手动修改dom节点之后Vue并不知道这个节点已经不存在了,所以看起来这个被修改后的节点失去了‘响应式’。
怎么办
手动修改dom节点之后如何通知Vue更新内部维护的Vnode节点对象呢?这边没有想到比较好的方法,不过为了实现空单元格自动填充占位符的功能,也许可以直接修改下ElTable的相关源码。
看到packages/table/src/table-column.js L146 setColumnRenders方法(L170),这里我们可以看到默认使用了defaultRenderCell方法:
export function defaultRenderCell(h, { row, column, $index }) {
const property = column.property;
const value = property && getPropByPath(row, property).v;
if (column && column.formatter) {
return column.formatter(row, column, value, $index);
}
return value;
}
这里返回单元格展示的值,可以在这里侵入修改:
return value || '--';
而对于scopedSlot这种情况呢?
看到setColumnRenders方法中的这一段:
column.renderCell = (h, data) => {
let children = null;
if (this.$scopedSlots.default) {
children = this.$scopedSlots.default(data);
} else {
children = originRenderCell(h, data);
}
const prefix = treeCellPrefix(h, data);
const props = {
class: 'cell',
style: {}
};
if (column.showOverflowTooltip) {
props.class += ' el-tooltip';
props.style = {width: (data.column.realWidth || data.column.width) - 1 + 'px'};
}
return (<div { ...props }>
{ prefix }
{ children }
</div>);
};
children对象即为被Vue解析过的Vnode对象数组,也许可以直接通过修改Vnode对象的属性来达到我们想要的效果,分析一下Vnode的属性列表:
Vnode属性说明:
* children 是当前 vnode 的子节点(VNodes)数组
* data 是当前 vnode 代表的节点的各种属性,是 createElement() 方法的第二个参数
* elm 是根据 vnode 生成 HTML 元素挂载到页面中后对应的 DOM 节点
* tag 是当前 vnode 对应的 html 标签
* text 是当前 vnode 对应的文本或者注释
修改下text属性即可:
function setText(children: any) {
children.forEach((child: any) => {
if (child.children && child.children.length) {
setText(child.children);
}
// 不处理Vue组件的text属性
else if (!(child.tag && child.tag.includes('vue-component'))) {
child.text = child.text || NULL_VALUE;
}
});
}
全部修改源码实现如下,注意此代码文件应当位于Vue.use(ElementUI)之前
/**
* @file element-ui部分组件逻辑补充
* @author zoubo01<zoubo01@baidu.com>
*/
import {TableColumn} from 'element-ui';
import {NULL_VALUE} from '~/common/constant';
// *** element-ui 源码start src/utils/util ***
function getPropByPath(obj: any, path: any, strict?: any) {
let tempObj = obj;
path = path.replace(/\[(\w+)\]/g, '.$1');
path = path.replace(/^\./, '');
let keyArr = path.split('.');
let i = 0;
for (let len = keyArr.length; i < len - 1; ++i) {
if (!tempObj && !strict) {
break;
}
let key = keyArr[i];
if (key in tempObj) {
tempObj = tempObj[key];
}
else {
if (strict) {
throw new Error('please transfer a valid prop path to form item!');
}
break;
}
}
return {
o: tempObj,
k: keyArr[i],
v: tempObj ? tempObj[keyArr[i]] : null
};
}
// *** element-ui 源码end src/utils/util ***
// *** element-ui 源码start packages/table/src/config.js ***
function defaultRenderCell(h: Function, {row, column, $index}: any) {
const property = column.property;
const value = property && getPropByPath(row, property).v;
if (column && column.formatter) {
return column.formatter(row, column, value, $index);
}
// 插入占位符 **** 源码修改点 ****
return value || NULL_VALUE;
}
function treeCellPrefix(h: Function, {row, treeNode, store}: any) {
if (!treeNode) {
return null;
}
const ele = [];
const callback = function (e: any) {
e.stopPropagation();
store.loadOrToggle(row);
};
if (treeNode.indent) {
ele.push(<span class="el-table__indent" style={{'padding-left': treeNode.indent + 'px'}}></span>);
}
if (typeof treeNode.expanded === 'boolean' && !treeNode.noLazyChildren) {
const expandClasses = ['el-table__expand-icon', treeNode.expanded ? 'el-table__expand-icon--expanded' : ''];
let iconClasses = ['el-icon-arrow-right'];
if (treeNode.loading) {
iconClasses = ['el-icon-loading'];
}
ele.push(<div class={ expandClasses } on-click={ callback }>
<i class={ iconClasses }></i>
</div>);
}
else {
ele.push(<span class="el-table__placeholder"></span>);
}
return ele;
}
// *** element-ui 源码end packages/table/src/config.js ***
/**
* 为生成的Vnode插入文本占位符
* Vnode属性说明:
* children 是当前 vnode 的子节点(VNodes)数组
* data 是当前 vnode 代表的节点的各种属性,是 createElement() 方法的第二个参数
* elm 是根据 vnode 生成 HTML 元素挂载到页面中后对应的 DOM 节点
* tag 是当前 vnode 对应的 html 标签
* text 是当前 vnode 对应的文本或者注释
* @param children
*/
function setText(children: any) {
children.forEach((child: any) => {
if (child.children && child.children.length) {
setText(child.children);
}
// 不处理Vue组件的text属性
else if (!(child.tag && child.tag.includes('vue-component'))) {
child.text = child.text || NULL_VALUE;
}
});
}
(TableColumn as any).methods.setColumnRenders = function (column: any) {
// *** element-ui 源码start packages/table/src/table-column.js ***
// renderHeader 属性不推荐使用。
if (this.renderHeader) {
console.warn('[Element Warn][TableColumn]Comparing to render-header, scoped-slot header is easier to use. We recommend users to use scoped-slot header.');
}
else if (column.type !== 'selection') {
column.renderHeader = (h: Function, scope: any) => {
const renderHeader = this.$scopedSlots.header;
return renderHeader ? renderHeader(scope) : column.label;
};
}
let originRenderCell = column.renderCell;
if (column.type === 'expand') {
// 对于展开行,renderCell 不允许配置的。在上一步中已经设置过,这里需要简单封装一下。
column.renderCell = (h: Function, data: any) => {
console.log('render cell expand', data);
return (<div class='cell'>
{ originRenderCell(h, data) }
</div>);
};
this.owner.renderExpanded = (h: Function, data: any) => {
return this.$scopedSlots.default
? this.$scopedSlots.default(data)
: this.$slots.default;
};
}
else {
originRenderCell = originRenderCell || defaultRenderCell;
// 对 renderCell 进行包装
column.renderCell = (h: Function, data: any) => {
let children = null;
if (this.$scopedSlots.default) {
children = this.$scopedSlots.default(data);
}
else {
children = originRenderCell(h, data);
}
const prefix = treeCellPrefix(h, data);
const props = {
class: 'cell',
style: {}
};
if (column.showOverflowTooltip) {
props.class += ' el-tooltip';
props.style = {width: (data.column.realWidth || data.column.width) - 1 + 'px'};
}
// 插入空占位符 **** 源码修改点 ****
setText(children);
return (<div { ...props }>
{prefix}
{children}
</div>);
};
}
return column;
// *** element-ui 源码end packages/table/src/table-column.js ***
};