Appearance
可合并单元格不定行高虚拟滚动表格
在日常的工作开发中,我们常常会碰到数据表格展示的需求。这次也不例外,业务方要求开发一个能够展示大量数据的表格,并且表格需要支持合并单元格的功能,同时每一行的高度会因为内容的差异而不同。我最初的想法是在网上搜索现成的实现方案,这样既能节省时间,又能保证代码的稳定性。然而,事与愿违,我查阅了大量的资料,尝试了许多开源组件,却始终没有找到一个能够完美满足需求的方案。特别是不定行高和合并单元格这两个关键需求,现有的实现方案要么只能支持其中一个,要么在性能和兼容性上存在问题。
在这种情况下,我意识到必须自己动手实现这个表格组件。经过一番研究,我发现虚拟滚动技术是解决大量数据表格性能问题的关键。于是,我决定深入了解虚拟滚动的实现原理,并结合项目需求,开发一个可合并单元格不定行高的虚拟滚动表格。
先看看效果:点击查看效果
虚拟滚动基础认知
虚拟滚动的核心思想是只渲染可见区域内的行,而不是一次性渲染所有行。这样可以显著减少 DOM 操作的数量,提高页面的性能和滚动的流畅性。在实现虚拟滚动时,需要计算可见行的范围,并根据滚动位置动态更新可见行的数据。
不定行高的实现原理
初始化预估高度
在 initializePositions
函数中,我们首先为每一行数据初始化一个预估的高度。这个预估高度由 props.estimatedRowHeight
提供,通过循环遍历数据数组,为每一行设置一个初始的 height
、top
和 bottom
属性。
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
对象中的高度信息,并重新计算每一行的 top
和 bottom
位置。
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 > 1
或 colspan > 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.max
和 Math.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