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

7,477 阅读8分钟

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

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

一. 前言 & 复现 Bug

在做活动引擎的过程中,发现 Antd 的 Table 组件会发送各种行错位,和列错位。关于行错位不是本节内容介绍的重点,本文主要介绍在启用固定列的时候(即使用fixed) 时发生的列错位 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 获取高度,即便图片还没有渲染出来。

完美解决方法是:使用 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次重绘。

syncScroll() {
    const { headerWrapper, footerWrapper, bodyWrapper, fixedBodyWrapper, rightFixedBodyWrapper } = this;
    if (headerWrapper) {
      headerWrapper.scrollLeft = bodyWrapper.scrollLeft;
    }
    if (footerWrapper) {
      footerWrapper.scrollLeft = bodyWrapper.scrollLeft;
    }

    if (fixedBodyWrapper) {
      fixedBodyWrapper.scrollTop = bodyWrapper.scrollTop;
    }
    if (rightFixedBodyWrapper) {
      rightFixedBodyWrapper.scrollTop = bodyWrapper.scrollTop;
    }
  }

// 主 Table

<div
  style={this.bodyWrapperHeight}
  className="el-table__body-wrapper"
  ref={this.bindRef('bodyWrapper')}
  onScroll={this.syncScroll}
>
  <TableBody
    {...this.props}
    style={{ width: this.bodyWidth }}
  />
</div>

性能比较

Element 部分

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

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

滚动时

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

这个截图可以证明 Element Table 在 Fixed 属性上的设计是有很大问题,大部分的时间都花费在渲染上,这与其设计有非常大的关系。 假如我们把 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

Rc-Table && Antd Table 的关系

最后对 Antd 和 Element 的改进建议

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

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

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