对于简单的表格场景,开发不太需要关注列宽,使用table标签来构建,可设置table-layout CSS指定表格布局算法,享受浏览器实现的智能列宽效果,也可设置width来指定个大概的列宽,大部分场景都能满足,Antd的表格就是这样做的。但支持的场景变得复杂后,列宽就变得举足轻重了...
一、列宽为何重要
比如固定表头、固定列 这种效果,虽然在BI领域是很基础的要求,但table标签没有默认支持,需要动些脑筋。业界一般会用两个table标签来实现,一个table展示表头,一个可滚动容器包个table展示主体内容,它们共同组成表格的UI。此时列宽就重要了起来,我们需要让这2个table标签保持一致的列宽,才能不错位。 错位效果示例:
此时纯靠table-layout已经搞不定了,Antd表格把问题抛给了使用者,让开发者必须指定列宽
如果我们的表格底座是Antd表格的话,上层四大业务表格就头大了。在表格如此密集的信息下,杂乱的样式会明显降低用户看数的效率,想象高管在看数的时候,突然某个单元格里的数字超出了或换行了,他会不会截个图发给我们老板?
因此我们无法指定个大概的width,列宽必须精准,最好是精准到每一列的数据,离两侧边框线的间距都要相等,整整齐齐的,看数效率up:
怎么得到每列精准的列宽?很有挑战...
二、如何获得列宽
大概有下面这些办法:
1、根据数据和配置提前计算
很容易想到这个方法,取数完成后,我们已经知道了该列每一个单元格要展示的数据,取其**「最长的内容」**,使用Canvas的measureText量一下,列宽不就出来了?等等,还有一些关卡要闯:
-
字体 :等宽和非等宽字体,相同字号下,同一个数字和字母的长度会不一样,比如非等宽字体下,88,888其实比111,111更宽:,等宽字体下则111,111更宽: 。因此获取「最长的内容」时,需要考虑字体问题,当然它很好解决,主动设置字体为等宽字体即可,并且为了方便比对数据,我们也会这样做
-
格式化 :后端返回的数据,是未经格式化的,比如12.34567和123.45,虽然是12.34567更长,但若配置了保留2位小数的格式化规则,则最终展示时123.45才是更宽的。这个问题好解也不好解,说好解是因为先格式化,再去对比即可,说不好解在于那我们要对整列全量数据做格式化,数据量大时,性能明显下降,可达几百毫秒级别
-
自定义渲染 :这个几乎是所有表格必备的能力,使用很灵活,但它让列宽计算的维护成本大幅提升。自定义渲染本来是只在column.render里写逻辑,现在计算列宽却要关心column.render的逻辑,并和它保持对齐,很容易漏改逻辑导致bug:
- 自定义渲染各类图标:如弹出趋势、排序、说明、超链接、隐藏小眼睛等图标,此时这个单元格的实际宽度,就要考虑图标宽度和padding,之前获取的「最长的内容」,可能就不准
- 树状节点:要考虑+、-图标,要考虑非根节点设置的缩进间距
- 自定义样式:比如总计列头可能加粗,环比数据会被设置为斜体,这些都会影响宽度...
- 渲染html:我们的表格支持直接渲染html,怎么根据html字符串得到渲染宽度?可能必须要拿个隐藏span渲染一次了...
-
主题样式覆盖 :如果说上面的问题还能硬抗,这个就真顶不住了,我们的报表支持使用自定义主题,用户可任意写CSS来覆盖表格的样式,用户若给吸底的总计行,字号调大2px,我们算的宽度,还有意义吗?
因此,在我们的场景下,要想稳定获得精准的列宽,提前计算这条路几乎走不通
2、先渲染再采集列宽
提前计算不行的话,那只能先渲染一次,来得到真实的精准宽度了。一般的大致思路是:
- 不可见的渲染:先让表格不可见,给个默认列宽,渲染一次
- 采集列宽:访问dom,读取每一列所有单元格的内容宽度,取max
- 可见的渲染:给每一列的单元格设置上采集计算好的列宽,重新渲染表格,并让表格可见
这样的方案,还有一个很大的好处,这些代码可以全部写到底层表格里,业务表格完全无需关心列宽。目前的底表MatrixTable,也确实是这样设计的,是其一大核心能力 - 自适应列宽
三、自适应列宽
通过「不可见的渲染 - 采集列宽 - 可见的渲染」这种方案,MatrixTable实现了自适应列宽的布局机制,业务表格无需关心列宽,MatrixTable能保证「始终」不会出现错位对不齐的现象
听起来比较完美,但和很多方案一样,随着规模的上升,就可能会出现新的问题,自适应列宽也不例外
BI表格可能会遇到很大的单元格数量,10W甚至100W,这么多单元格,若全量渲染,目前的设计下一个单元格起码会产生3个真实dom节点,表格的dom节点起码会达到30W和300W,而Chrome觉得1400个dom节点就已经过高了,会严重拖累首次渲染、重绘重排的性能。根据之前的测算,全量渲染20W单元格,耗时就轻松达到20秒级别,根本不可接受
所以表格渲染必须做虚拟滚动,只渲染可视窗口里的单元格,这也是MatrixTable的另一大核心能力。
「不可见的渲染」也同样需要虚拟滚动,这样导致单次的列宽采集是临时的,随着表格滚动,页面上渲染的单元格内容会变化,实际列宽也会变化。滚动时,MatrixTable会去更新列宽,这样导致了,滚动时列宽可能会抖动,忽宽忽窄:
体验不太好,有点难受
四、隐藏行 - buildHideRow
根据MatrixTable的设计,上层业务表格要传入rows和columns来驱动MatrixTable渲染,若有合并单元格,则再传入mergedCells
1、为什么需要它?
为了解决上述的列宽抖动问题,表格前辈想了个办法:隐藏行 - hideRow,MatrixTable新增了buildHideRow入参:
javascript
// 构建当前表格的隐藏行,它由每一列的「最宽单元格」组成
// 渲染时,会应用特殊的样式让其不可见,它的目的,只是为了撑开表格的列宽
buildHideRow?: (props: MatrixTableProps) => MatrixTableRow;
在首次渲染、rows/columns/mergedCells变化这两个时机,MatrixTable会调用buildHideRow,得到hideRow,它会被跟着可视窗口的rows一起去渲染,来撑开列宽。等等,怎么得到「最宽单元格」?那不是又要走「根据数据和配置提前计算」这条走不通的路了吗?
是的,提前计算很难做到精准,但没有列宽,抖动现象不好避免,权衡之下,于是hideRow主打一个「快速估算 」,目前业务表格的buildHideRow,主要考虑了格式化、拓展列和部分自定义渲染场景,得到的「最宽单元格」,可能只在95%场景下才是「最宽」
为了尽量保障hideRow的「最宽」,也有一些无奈之举,比如通过CSS特意给hideRow加了Buff,让它赢在起跑线,比别的单元格天生宽13px(3px + 5px + 5px) 现在绝大部分情况下,hideRow已经是最宽的了,用户难以再遇到列宽抖动了
大家若偶尔遇到列宽轻微抖动的现象,都不用分析,基本是buildHideRow没算对
2、它带来了什么新问题?
和很多方案一样,随着规模的上升,就可能会出现新的问题,隐藏行也是。大规模数据量下,它大幅增加了渲染耗时。 比如中特表目前21W单元格下,一次buildHideRow耗时1秒多,尝试做过一些优化,也还要800毫秒,成为了大表格渲染性能的主要瓶颈。并且像树状的展开/折叠、列的隐藏/显示这种常规交互,也会触发buildHideRow,导致交互体验较差
由于是对全量数据取「最宽单元格」,它不可避免成为一个O(行数M * 列数N) 时间复杂度的算法,耗时随单元格的数量呈线性增长
五、可以怎么优化?
性能优化的主要策略,无非是这灵魂三问:
1、能不能不做?
2、能不能提前/延后/异步做?
3、有没有更快的做法?
一番思索,我主要想了这两个办法:
1、异步 - Web Worker
buildHideRow这种耗时计算操作,天然命中Web Worker适合的场景,耗时任务放到后台线程执行,期间主线程可以做优先级更高的事情,响应用户交互等,听起来很美好,我们试了一把,经历一些改造,最后是成功了,效果如下:表格会先渲染出来,异步构建完hideRow后,用户触发滑动,会触发一次列宽更新,随后列宽会保持稳定。也就是说比起同步构建hideRow,异步的方式列宽会多抖一次 ,我觉得效果还行,对于大表格可以开启,比列宽忽宽忽窄体验好,比同步构建性能好
不过这个方案也有天生的缺陷,以至于最后没有采用:
1.1 数据深拷贝耗时大
向Worker线程传输数据时,我对rows、columns都做了裁剪,只保留必要的数据:
但序列化后数据量仍高达30+M甚至100+M,这么大的数据量在主线程和Worker线程间的深拷贝耗时不容小视。选了2张中特表,性能情况如下:
buildHideRow耗时 | 数据量大小 | postMessage耗时(序列化) | postMessage耗时(结构化克隆) | |
---|---|---|---|---|
22W单元格-无对比 | 1100ms | 30+M | 260ms | 170ms |
21W单元格-有对比 | 660ms | 100+M | 1200ms | 800ms(负优化) |
可以看到,即使是用更高性能的JS引擎原生实现的结构化克隆,数据量较大时,也存在不可忽视的传输耗时,甚至可能带来负优化
可转移对象能实现线程间的数据所有权转移,可以避免深拷贝,性能媲美引用传递,性能很好。但支持度有限,主要是类型化数组和二进制数据,我们这里的js对象不合适
1.2 难以长期稳定
Web Worker和主线程在API上存在较多差异,比如在Web Worker中:
- 没有window、document、navigator等DOM对象
- 无法访问和操作DOM
- 没有requestAnimationFrame
- 无法直接访问localStorage和sessionStorage
而目前FBI的许多基础util,都会访问直接window等DOM对象,为了在Web Worker中运行,必须加上保护:
否则在Web Worker中执行时会遇到xxx is not defind的错误: 虽然可以改一波,但这些基础util会持续迭代,保不齐哪天谁就加了一个if (window) 导致这个功能挂掉。需要针对上述差异,引入或开发一波eslint规则,同时只有被这些规则覆盖的代码,才能在Web Worker入口文件里依赖。然后这些基础util的维护成本也会增加一些
因此,这个方案不太好
2、不做 - 列宽防下滑
上面Web Worker的方案,主打一个异步,做到的是列宽会多抖一次,但副作用很大。这个buildHideRow,同步做不行,异步做不行,那就只能不做了?
我想起了之前大屏的GMV防下滑功能,列宽是不是也可以防下滑?
表格从顶部滚动到底部,总会遇见每列的「最宽单元格」,遇见的时候,列宽会自适应变到最宽,分开之后,若让列宽保持不减,不也就拥有了类似异步buildHideRow的效果? 只是遇见「最宽单元格」前,可能会遇见「第三宽」、「第二宽」,列宽可能会经历x次变宽的过程(如下图x等于2)。但比起列宽来回横跳,效果还是好许多: 当然体验还是不如同步得到hideRow,这里做了个权衡,单元格数量小于10W时,走同步buildHideRow,大于10W时,走列宽防下滑(渲染耗时大约降低60%) 。让大家都拥有美好的未来
然后,列宽防下滑,本身是大表格下hideRow的替代方案,和hideRow保持了相似的生命周期,columns变化时,列宽会回到默认值,会重新开始一次遇见「最宽单元格」的过程
六、总结
本文从复杂表格场景下列宽为何重要讲起,然后分析了为什么提前计算列宽几乎不可行。因此我们的表格底座MatrixTable是先渲染再采集列宽,打造了自适应列宽的机制
但是又因为大数量下必须做虚拟滚动,会让自适应列宽机制存在列宽抖动的缺陷,随之引出了隐藏行 - buildHideRow的解决方案,由于列宽很难提前算精准,所以它是个权衡方案,并不完美
三年多以后,我们借助中特表优化可交互时间的契机,尝试去优化buildHideRow的耗时,最终采用了「列宽防下滑」策略,其实它也是个权衡方案,为了让大表格能在性能和体验上达到平衡
可以感受到,大数据场景下,同时提升BI表格的性能和体验是个很有挑战的事情,期待未来能有更骚的技术,能把列宽这件事,彻底做到又快又准
参考阅读: