Skip to content

可合并单元格不定行高虚拟滚动表格

在日常的工作开发中,我们常常会碰到数据表格展示的需求。这次也不例外,业务方要求开发一个能够展示大量数据的表格,并且表格需要支持合并单元格的功能,同时每一行的高度会因为内容的差异而不同。我最初的想法是在网上搜索现成的实现方案,这样既能节省时间,又能保证代码的稳定性。然而,事与愿违,我查阅了大量的资料,尝试了许多开源组件,却始终没有找到一个能够完美满足需求的方案。特别是不定行高和合并单元格这两个关键需求,现有的实现方案要么只能支持其中一个,要么在性能和兼容性上存在问题。

在这种情况下,我意识到必须自己动手实现这个表格组件。经过一番研究,我发现虚拟滚动技术是解决大量数据表格性能问题的关键。于是,我决定深入了解虚拟滚动的实现原理,并结合项目需求,开发一个可合并单元格不定行高的虚拟滚动表格。

先看看效果:点击查看效果

虚拟滚动基础认知

虚拟滚动的核心思想是只渲染可见区域内的行,而不是一次性渲染所有行。这样可以显著减少 DOM 操作的数量,提高页面的性能和滚动的流畅性。在实现虚拟滚动时,需要计算可见行的范围,并根据滚动位置动态更新可见行的数据。

不定行高的实现原理

初始化预估高度

initializePositions 函数中,我们首先为每一行数据初始化一个预估的高度。这个预估高度由 props.estimatedRowHeight 提供,通过循环遍历数据数组,为每一行设置一个初始的 heighttopbottom 属性。

javascript
const initializePositions = () => {
  Object.keys(positions).forEach(key => delete positions[key]);

  let top = 0;
  props.data.forEach(item => {
    const height = Number(props.estimatedRowHeight);
    positions[item.id] = {
      height,
      top,
      bottom: top + height
    };
    top += height;
  });
};

动态更新行高

updateRowHeights 函数中,我们会在滚动时动态更新行高。首先,通过 document.querySelectorAll('.virtual-row') 获取所有可见行的 DOM 元素。然后,遍历这些元素,通过 offsetHeight 属性获取每一行的实际高度。如果实际高度与之前记录的高度不同,则更新 positions 对象中的高度信息,并重新计算每一行的 topbottom 位置。

javascript
const updateRowHeights = () => {
  const rowElements = document.querySelectorAll('.virtual-row');
  if (rowElements.length === 0) return;

  let heightChanged = false;
  const heightUpdates = [];

  rowElements.forEach(rowEl => {
    if (!rowEl) return;

    const rowId = parseInt(rowEl.dataset.id);
    const actualHeight = rowEl.offsetHeight;

    if (positions[rowId] && positions[rowId].height !== actualHeight) {
      heightUpdates.push({id: rowId, height: actualHeight});
      heightChanged = true;
    }
  });

  if (heightChanged) {
    heightUpdates.forEach(update => {
      positions[update.id].height = update.height;
    });

    let top = 0;
    for (let i = 0; i < props.data.length; i++) {
      const item = props.data[i];
      const pos = positions[item.id];
      if (pos) {
        pos.top = top;
        pos.bottom = top + pos.height;
        top = pos.bottom;
      }
    }
  }
};

合并单元格的实现原理

生成合并列表

generateSpanList 函数中,我们会遍历数据数组和列数组,调用 props.spanMethod 函数来计算每个单元格的合并信息。如果某个单元格需要合并(rowspan > 1colspan > 1),则将其合并区域信息添加到 spanList 中,并标记被合并的单元格,避免重复处理。

javascript
function generateSpanList(data, spanMethod) {
  const spanList = [];
  const handledCells = new Set();

  for (let row = 0; row < data.length; row++) {
    for (let col = 0; col < props.columns.length; col++) {
      const cellKey = `${row},${col}`;
      if (handledCells.has(cellKey)) continue;

      const result = spanMethod({rowIndex: row, columnIndex: col, data});

      if (result.rowspan > 1 || result.colspan > 1) {
        const region = {
          startRow: row,
          startCol: col,
          endRow: row + result.rowspan - 1,
          endCol: col + result.colspan - 1,
          regionId: result.regionId
        };
        spanList.push(region);

        // 标记被合并的单元格
        for (let r = row; r <= region.endRow; r++) {
          for (let c = col; c <= region.endCol; c++) {
            if (r !== row || c !== col) {
              handledCells.add(`${r},${c}`);
            }
          }
        }
      }
    }
  }

  return spanList;
}

计算可见合并区域

getMergesInRange 函数中,我们会根据可见行的范围和合并单元格列表,计算出当前可见区域内的合并信息。通过 Math.maxMath.min 函数来确定合并区域的可见部分,并过滤掉不可见的合并区域。

javascript
function getMergesInRange(spanList, startRow, endRow, colCount) {
  return spanList
      .map(region => {
        // 计算可见部分
        const visibleStartRow = Math.max(region.startRow, startRow);
        const visibleEndRow = Math.min(region.endRow, endRow);
        const visibleStartCol = Math.max(region.startCol, 0);
        const visibleEndCol = Math.min(region.endCol, colCount - 1);

        // 检查是否有可见部分
        if (visibleStartRow <= visibleEndRow && visibleStartCol <= visibleEndCol) {
          return {
            ...region,
            startRow: visibleStartRow,
            endRow: visibleEndRow,
            startCol: visibleStartCol,
            endCol: visibleEndCol
          };
        }
        return null;
      })
      .filter(region => region !== null);
}

updateRowHeights 内的合并实现

updateRowHeights 函数的后半部分,我们会根据当前可见的合并区域信息,动态调整合并单元格的样式。首先,获取当前可见行的起始索引,然后遍历可见的合并区域列表。对于每个合并区域,找到起始行和结束行的 DOM 元素,以及起始单元格和结束单元格的 DOM 元素。通过 getBoundingClientRect 方法获取单元格的位置和尺寸信息,计算出合并单元格的宽度和高度。最后,通过设置样式属性,将合并单元格的样式应用到起始单元格上,并调整其 z-index 和边框样式。

javascript
const startIndex = rowElements[0].dataset.index;
currentSpanList.value.forEach(item => {
  const startRowEl = rowElements[item.startRow - startIndex]
  const endRowEl = rowElements[item.endRow - startIndex]
  requestAnimationFrame(() => {
    try {
      const startCellEl = startRowEl.children[item.startCol]
      const endCellEl = endRowEl.children[item.endCol]

      const startCellBinding = startCellEl.getBoundingClientRect()
      const endCellBinding = endCellEl.getBoundingClientRect()
      const spanWidth = endCellBinding.right - startCellBinding.left
      const spanHeight = endCellBinding.bottom - startCellBinding.top
      const startCellContentEl = startCellEl.children[0]

      const style = {
        width: spanWidth + 1 + 'px',
        height: spanHeight + 2 + 'px',
        position: 'absolute',
        top: '-1px',
        left: '-1px',
        background: '#fff',
        border: `1px solid #e1e4e8`,
      }
      const styleText = Object.keys(style).map(key => `${key}:${style[key]}`).join(';')
      startCellContentEl.setAttribute('style', styleText)

      startCellEl.style.zIndex = 10
      startCellEl.style.border = 'none'
    } catch (e) {
      console.log(e)
    }
  })
});

总结

通过深入研究虚拟滚动的实现原理,并结合不定行高和合并单元格的需求,我成功开发了一个可合并单元格不定行高的虚拟滚动表格。在实现过程中,关键在于如何动态更新行高和处理合并单元格的样式。通过在滚动时动态计算行高,并根据合并信息调整单元格样式,我们可以确保表格在大量数据下依然能够保持流畅的滚动性能。

感兴趣的同学可以参考源码:VirtualScrollTable