解析 Antd Table 组件的错位和性能问题

1,106 阅读8分钟
原文链接: zhuanlan.zhihu.com
陆金所:陈铭嘉

零. 开门见山的 Bug 解决方案

在做活动引擎的过程中,发现 Antd 的 Table 组件会发送各种行错位,和列错位。关于行错位不是本节内容介绍的重点,本文主要介绍在启用固定列的时候(即使用fixed) 时发生的列错位 bug 以及其衍生的一系列性能问题。

该写固定的 width,height 就写固定 width,height 。(修复简单,但这样 table 就不灵活,没法针对动态变化高度) 针对简单场景,可以用 setTimeout 在 mounted 之后触发下文的 syncFixedTableRowHeight。(缺点就是不稳定,可能还需要配套写检查资源加载的监听函数,但减少了出bug的概率) 使用 ResizerObserver 监听高度或宽度等属性变化,同步变化信息,方法见下文。(无缺点)

一. 复现 Bug

下图是使用固定列和 Image 作为列内容时产生的现象,该案例非常容易复现。

代码

import { Table } from 'antd';
const columns = [
 {
   title: 'Full Name',
   width: 100,
   dataIndex: 'name',
   key: 'name',
 fixed: 'left',
 },
 {
   title: 'Age',
   width: 100,
   dataIndex: 'age',
   key: 'age',
 fixed: 'left',
 },
 { title: 'Column 1', dataIndex: 'address', key: '1' },
 { title: 'Column 2', dataIndex: 'address', key: '2' },
 { title: 'Column 3', dataIndex: 'address', key: '3' },
 { title: 'Column 4', dataIndex: 'address', key: '4' },
 { title: 'Column 5', dataIndex: 'address', key: '5' },
 {
   title: 'Avatar',
   width: 200,
   dataIndex: 'img',
   key: 'img',
   render: (a, row ,b) => (
 <img src={row.img}></img>
 )
 },
 { title: 'Column 6', dataIndex: 'address', key: '6' },
 { title: 'Column 7', dataIndex: 'address', key: '7' },
 { title: 'Column 8', dataIndex: 'address', key: '8' },
 {
   title: 'Action',
   key: 'operation',
 fixed: 'right',
   width: 100,
   render: () => <a href="javascript:;">action</a>,
 },
];
const data = [
 {
 key: '1',
 name: 'John Brown',
 age: 32,
 address: 'New York Park',
 img: 'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1566139960708
&di=b71fcbe1841e966f5fd3983197628ff9&imgtype=0&src=http%3A%2F%2Fpic1.16xx8.com%2Fallimg%2F161122%2F1F0035M6-7.jpg'
 },
 {
 key: '2',
 name: 'Jim Green',
 age: 40,
 address: 'London Park',
 img: 'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1566139968891&di=c0b7ecc441817226dabc251d870f6d22
&imgtype=0&src=http%3A%2F%2Fimg.zcool.cn%2Fcommunity%2F01a949581aeb9fa84a0d304fd05eeb.jpg'
 },
];
ReactDOM.render(<Table columns={columns} dataSource={data} scroll={{ x: 1300 }} />, mountNode);

然而同样的情况在以前使用 Element 时几乎不会复现,于是我使用了 Element-React ,果然 Element-React 无论怎么玩都不会出现列错位或者行错位。

二.将固定列渲染为 Html 通常来说,主流的固定列渲染方法无一例外都是将固定列的 column 渲染为单独的 Table 组件。然后使用绝对定位(position:absolute)将其固定在 Table 的左右两侧。

疑问:固定列(Fixed)属性是如何同步主内容 Table Cell 和左右 Fixed Table Cell 的高度?绝对布局会造成 Fixed Table 和 Main Table 之间列元素的 Layout 信息(如 cell height,cell width)割裂。必须使用某种方式同步割裂的信息。这里 Element - React 和 Antd 对于 Table 在 fixed 的实现差距是很大的。

1.Element 同步 Layout 方案:

同一个 Table 会被渲染成三份,里面的 Dom 节点,样式完全一样,不同的是 Fixed 的 Table 部分是不 Visiable 的。

可以说 Element - React 对于的 Table 为了弥补样式错位的问题,巨大地牺牲了性能的问题。这样的性能问题会在列数和列元素复杂度提升时,表现出来。

先看 Element - React 关于 Fixed 属性的渲染后结构:

下面用直观图来表示这种设计:

可以看到 主Table 和 左右Fixed Table 的结构是一模一样的,可以说很浪费了。。。本来只需要渲染对应的 Table 列就可以了,但不得不说 Element 的做法的确解决了这个同步 Layout 信息的问题。

2.Antd 同步 Layout 方案:

Antd 的 Table 内核使用了 Rc-table 组件。Rc-table 不会渲染完全相同的 3 份Table,而是只渲染需要的 column,可以看到这种设计才是合理的。。。

然后虽然设计合理,但是 Antd 却产生非常多错位 bug。可以归结于 Rc-table 与 其余独立 Antd 组件之间,发生了一些配合的失误。这里依然只瞄准列错位的 bug。

直接去 Rc-table 的源码找到了同步固定列 Table 高度的代码段:

syncFixedTableRowHeight = () => {
 //...
 //搜寻主 Table 所有行元素
 const bodyRows = this.bodyTable.querySelectorAll(`.${prefixCls}-row`) || [];
 //...
 const state = this.store.getState();
 //获取主 Table 的行高
 const fixedColumnsBodyRowsHeight = [].reduce.call(
  bodyRows,
 (acc, row) => {
 const rowKey = row.getAttribute('data-row-key');
 const height =
    row.getBoundingClientRect().height || state.fixedColumnsBodyRowsHeight[rowKey] || 'auto';
    acc[rowKey] = height;
 return acc;
 },{},);


 //比较是否发生了,如果没有发生变化,就返回
 if (
    shallowequal(state.fixedColumnsHeadRowsHeight, fixedColumnsHeadRowsHeight) &&
    shallowequal(state.fixedColumnsBodyRowsHeight, fixedColumnsBodyRowsHeight)
 ) {
 return;
 }

 //如果发生了变化,就同步变化
 this.store.setState({
    fixedColumnsHeadRowsHeight,
    fixedColumnsBodyRowsHeight,
 });
};

它会在 mounted 的时候调用,和 document 的 resize 事件调用。

但这里有一个 bug,mounted 的时候还有很多元素没有渲染出来时,如 Image。syncFixedTableRowHeight 同步时就不会计算图片地高度,这样就会产生高度割裂,虽然触发了函数,但没有同步高度的问题。

3.如何修复 rc-table 的列同步 bug

于是了解原理之后,这里自然而然可以想到给 Image 显式地指定 css height。这样一来,在 mounted 地时候就可以 调用 syncFixedTableRowHeight 获取高度,即便图片还没有渲染出来。

下图打印的 51 代表初始化没有获得图片的高度,只是行本身的高度。

完美解决法是:使用 ResizeObserver,Antd 的几乎所有错位问题,几乎都被这个方法解决了,(也许时因为浏览器兼容性,目前这个 PR 躺了大半年了,但我觉得是维护不及时...),即便是兼容性问题,ResizeObserver有基于MutationObserver的polyfill,而主流浏览器对MutationObserv是支持的。

下面是解决方案地代码,以后如果造轮子,可以参考:

createObserver() {
 return new ResizeObserver(entries => {
 const state = this.store.getState();
 const fixedColumnsHeadRowsHeight = { ...state.fixedColumnsHeadRowsHeight };
 const fixedColumnsBodyRowsHeight = { ...state.fixedColumnsBodyRowsHeight };

 const firstRowCellsWidth = { ...state.firstRowCellsWidth };

 for (let i = 0; i < entries.length; i++) {
 const entry = entries[i];
 const { target } = entry;
 const headerRowIndex = target.getAttribute('data-header-row-index')
 const rowKey = target.getAttribute('data-row-key');
 const columnKey = target.getAttribute('data-column-Key')
 const { width, height } = target.getBoundingClientRect();

 if (headerRowIndex !== null) {
 if (fixedColumnsHeadRowsHeight[headerRowIndex] !== height) {
           fixedColumnsHeadRowsHeight[headerRowIndex] = height;
 }
 }
 if (rowKey !== null) {
 if (fixedColumnsBodyRowsHeight[rowKey] !== height) {
          fixedColumnsBodyRowsHeight[rowKey] = height;
 }
 }
 if (columnKey !== null) {
 if (
        firstRowCellsWidth[columnKey] === undefined ||
        width !== firstRowCellsWidth[columnKey]
 ) {
        firstRowCellsWidth[columnKey] = width;
 }
 }
 }
 this.store.setState({
    fixedColumnsHeadRowsHeight,
    fixedColumnsBodyRowsHeight,
    firstRowCellsWidth,
 });
 });
}

既然解决了 Layout 高度的问题,如何解决同步动态属性呢

这种动态属性类似于 scrollTop,scrollLeft,hoverCellIndex 等等。这里 Element 和 Antd 的实现是一模一样的。

比如如何同步三个 Table 的 onScroll 属性,我们可以监听主 Table 的 onScroll 事件。然后将主 Table 的 scrollTop 和 scrollLeft 分发到左右 Fixed table 上。这样的结果就是引发3次重绘。

三.性能比较

Element 部分

这里直接进入性能比较环节,来证明 Element 在固定列上地设计对性能有多么大地损耗。

下面是从初始化到不断滚动,触发 onScroll 重绘的 Performance 截图。(0 - 10ms)

滚动时

先看调用调用栈和整体流程时间占比:

这个截图可以证明 Element Table 在 Fixed 属性上的设计是有很大问题,大部分的时间都花费在 Fiber 调度渲染机制上,这与其设计有非常大的关系。假如我们把 Fixed 属性拿走,再来测试一下:

可以发现渲染上的性能差距非常大:

Antd 部分

下面可以看到 Antd 在 Table 上的表现好的不是一点点。渲染时间非常短,仔细看代码地话,Antd 和 Rc-table 在一些细节上下了功夫,如 debounce 和 throttle 。这里不一一列举了,只大概读了下代码,没有做实验考证具体优化了多少。

不过既然展开就多说两句,Antd 在 Table 上的确满足了普适地后台需求,通过分页可以解决大部分性能问题。但是对于很多大数据场景,Antd 地性能实际也是很一般的,没有对大数据场景作优化。这其中有很多地优化技巧。其中最实用地莫过于虚拟滚动。

Table 设计比较

由于 Fixed 的解决方案不同, 也部分造成了 Table 设计地差异。

React-Element

Table Store 会同时作为3个 Table 的数据源,甚至这三个 Table 除了可显示部分不同以外,其他部分都是几乎相同的。但是可见对 Fixed 的数据特有的属性没有很多,代码整体很简洁一些,可以讲用空间(指代码数量)换时间。

Rc-Table

最后对 Antd 和 Element 的改进建议

严格意义来说是对 Rc-table 的改进建议,希望可以推动 ResizeObserver 的更新。

对 Element, 希望可以早点拿走 Fixed 这种严重损耗性能的设计。理论上会造成3倍的性能损耗,但是实际在更加复杂的环境下,这种性能损耗会被更加放大。

在 Table 上的设计,Antd 优于 Element ,只不过被 Rc-table 坑了,Rc-table 目前对于维护上比较滞后,老实说希望 Antd 自己实现一套 Table-core 组件。无论是哪一个,目前看来都有不小的优化空间。

欢迎大家关注微信公众号:大前端工程师