table组件表头分离如何同步列宽

298 阅读3分钟

在开发复杂表格组件时,实现固定表头、固定列和表格内滚动等功能通常需要将表头和内容区域分离。然而这种分离会带来一个常见问题:表头和内容区域的单元格宽度不一致导致对不齐。本文将分析两种主流组件库(Element和Ant Design)的解决方案。

问题背景

当表头和内容区域分离后,无论使用table-layout: fixed还是table-layout: auto,都会面临以下挑战:

  1. 内容区域和表头单元格宽度不一致,导致视觉上无法对齐
  2. 需要实现表头吸顶效果
  3. 需要处理列宽自适应和固定宽度的混合情况
  4. 固定列需要知道具体列宽

Element的实现方式

核心实现原理

Element采用JavaScript动态计算列宽的方案,主要流程如下:

  1. 计算当前总宽度:遍历所有列,累加已设置宽度(width)的列值
  2. 处理未设置宽度的列:为它们赋予最小列宽(minWidth)并累加到总宽度
  3. 剩余空间分配:如果存在剩余空间,按比例分配给未设置固定宽度的列
  4. 误差修正:通过Math.floor和差值补偿确保总宽度严格等于表格宽度

关键代码分析

image.png

源码

updateColumnsWidth() {
  if (!isClient) return
  const fit = this.fit
  const bodyWidth = this.table.vnode.el.clientWidth
  let bodyMinWidth = 0

  const flattenColumns = this.getFlattenColumns()
  const flexColumns = flattenColumns.filter(
    (column) => !isNumber(column.width)
  )
  flattenColumns.forEach((column) => {
    // Clean those columns whose width changed from flex to unflex
    if (isNumber(column.width) && column.realWidth) column.realWidth = null
  })
  if (flexColumns.length > 0 && fit) {
    flattenColumns.forEach((column) => {
      bodyMinWidth += Number(column.width || column.minWidth || 80)
    })
    if (bodyMinWidth <= bodyWidth) {
      // DON'T HAVE SCROLL BAR
      this.scrollX.value = false

      const totalFlexWidth = bodyWidth - bodyMinWidth

      if (flexColumns.length === 1) {
        flexColumns[0].realWidth =
          Number(flexColumns[0].minWidth || 80) + totalFlexWidth
      } else {
        const allColumnsWidth = flexColumns.reduce(
          (prev, column) => prev + Number(column.minWidth || 80),
          0
        )
        const flexWidthPerPixel = totalFlexWidth / allColumnsWidth
        let noneFirstWidth = 0

        flexColumns.forEach((column, index) => {
          if (index === 0) return
          const flexWidth = Math.floor(
            Number(column.minWidth || 80) * flexWidthPerPixel
          )
          noneFirstWidth += flexWidth
          column.realWidth = Number(column.minWidth || 80) + flexWidth
        })

        flexColumns[0].realWidth =
          Number(flexColumns[0].minWidth || 80) +
          totalFlexWidth -
          noneFirstWidth
      }
    } else {
      // HAVE HORIZONTAL SCROLL BAR
      this.scrollX.value = true
      flexColumns.forEach((column) => {
        column.realWidth = Number(column.minWidth)
      })
    }

    this.bodyWidth.value = Math.max(bodyMinWidth, bodyWidth)
    this.table.state.resizeState.value.width = this.bodyWidth.value
  } else {
    flattenColumns.forEach((column) => {
      if (!column.width && !column.minWidth) {
        column.realWidth = 80
      } else {
        column.realWidth = Number(column.width || column.minWidth)
      }
      bodyMinWidth += column.realWidth
    })
    this.scrollX.value = bodyMinWidth > bodyWidth

    this.bodyWidth.value = bodyMinWidth
  }

  const fixedColumns = this.store.states.fixedColumns.value

  if (fixedColumns.length > 0) {
    let fixedWidth = 0
    fixedColumns.forEach((column) => {
      fixedWidth += Number(column.realWidth || column.width)
    })

    this.fixedWidth.value = fixedWidth
  }

  const rightFixedColumns = this.store.states.rightFixedColumns.value
  if (rightFixedColumns.length > 0) {
    let rightFixedWidth = 0
    rightFixedColumns.forEach((column) => {
      rightFixedWidth += Number(column.realWidth || column.width)
    })

    this.rightFixedWidth.value = rightFixedWidth
  }
  this.notifyObservers('columns')
}

方案优势

  1. 通过JavaScript精确控制列宽分配
  2. 可以灵活配置minWidth等参数
  3. 行为表现容易控制,适应性强
  4. 能够处理固定列和弹性列的混合情况

缺点

  1. 实现复杂,宽度计算会导致CSS回流。
  2. 使用原生滚动条时,横向滚动条的显示隐藏会影响内容区域的高度,会影响纵向滚动条。纵向滚动条会影响内容区域的宽度。也许这也是element-plus采用虚拟滚动条的原因吧。

Ant Design

核心实现原理

Ant Design采用了一种更直观的DOM测量方案:

  1. 在表格中插入一个隐藏的测量行
  2. 使用ResizeObserver监听测量行中单元格的实际宽度
  3. 将测量到的宽度同步到表头对应列

源码

export default defineComponent<MeasureCellProps>({
  name: 'MeasureCell',
  props: ['columnKey'] as any,
  setup(props, { emit }) {
    const tdRef = ref<HTMLTableCellElement>();
    onMounted(() => {
      if (tdRef.value) {
        emit('columnResize', props.columnKey, tdRef.value.offsetWidth);
      }
    });
    return () => {
      return (
        <VCResizeObserver
          onResize={({ offsetWidth }) => {
            emit('columnResize', props.columnKey, offsetWidth);
          }}
        >
          <td ref={tdRef} style={{ padding: 0, border: 0, height: 0 }}>
            <div style={{ height: 0, overflow: 'hidden' }}>&nbsp;</div>
          </td>
        </VCResizeObserver>
      );
    };
  },
});

image.png

方案优势

  1. 实现简单直接,依赖浏览器原生布局计算
  2. 自动响应内容变化,无需复杂计算逻辑
  3. 能够精确匹配实际渲染宽度
  4. 对动态内容适应性强

缺点

  1. 依赖实际渲染出来的列宽,不支持 minWidth
  2. 表格内滚动的场景,纵向滚动条会影响内容宽度,所以纵向滚动条会一直展示。
  3. width 比表格宽度小时,实际 width 会比设置的大。

方案对比

特性Element方案Ant Design方案
实现复杂度较高,需要复杂计算逻辑较低,依赖DOM测量
性能影响需要主动计算,可能引起重排被动监听,性能开销较小
精确度依赖计算逻辑的准确性完全匹配实际渲染结果
适应性需要处理各种边界情况自动适应内容变化
维护成本较高较低