千万行表格展示优化
项目场景
- 项目中需要展示一个大量行的排班表表格,表格中每一行都有多个单元格,同时每个单元格中都是一个下拉选择框。
- 排班表中行是人员,列是时间(每行有90列,代表一个月内30天每天的早上、下午、晚上的排班班次)。
- 当班组内的人员数量超过100人时,表格的性能会下降,导致页面卡顿。
出现性能瓶颈的核心原因是一次性渲染大量 DOM 元素(9000 个下拉框)会导致浏览器 DOM 树构建、重排重绘压力剧增,直接表现为页面加载卡顿、操作(如滚动、点击)延迟。
DOM 数量过载:90列×100行=9000个单元格,每个单元格包含下拉框(通常由
<select>+多个<option>组成),实际 DOM 数量远超9000,浏览器解析和渲染这些元素会占用大量内存和 CPU。重排重绘频繁:大量元素同时存在时,任何微小操作(如下拉框展开/收起、滚动页面)都可能触发浏览器重排(计算元素位置)和重绘(像素渲染),导致界面响应变慢。
事件绑定与内存占用:每个下拉框可能需要绑定点击、change 等事件,9000个元素的事件绑定会占用额外内存,且事件触发时的处理也会增加性能开销。
所以需要优化表格的展示,在不影响基础功能的前提下,提升页面性能。
需求分析
实现一个能够高效展示千万行数据的表格组件,并支持点击下拉修改功能。
有三种不同的技术方案:
- 使用原生table元素
- 使用虚拟滚动技术
- 使用Canvas渲染
方案设计与实现
1. 原生Table元素方案
设计思路
直接使用HTML的table元素渲染所有数据,简单但性能较差,不适合大数据量。
核心代码
vue
<template>
<div class="table-container">
<table>
<thead>
<tr>
<!-- 遍历列配置,渲染表头 -->
<th v-for="col in columns" :key="col.key">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<!-- 遍历数据行 -->
<tr v-for="(row, index) in data" :key="index">
<!-- 遍历列配置,渲染单元格 -->
<td v-for="col in columns" :key="col.key">
<!-- 非可编辑列显示文本 -->
<span v-if="!col.editable">{{ row[col.key] }}</span>
<!-- 可编辑列显示下拉选择框 -->
<select v-else v-model="row[col.key]" @change="updateCell(row, col.key)">
<option v-for="option in col.options" :value="option.value">
{{ option.label }}
</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 接收列配置和数据
const props = defineProps({
columns: Array,
data: Array
})
// 单元格更新处理函数
const updateCell = (row, key) => {
console.log('Updated:', row, key)
// 在实际应用中,这里可以发送更新请求等操作
}
</script>2. 虚拟表格方案
设计思路
只渲染可视区域内的行,通过计算滚动位置动态更新显示内容,极大提升性能。
核心代码
vue
<template>
<div class="virtual-table" @scroll="handleScroll">
<!-- 表头部分 -->
<div class="table-header">
<div v-for="col in columns" :key="col.key" class="header-cell">
{{ col.title }}
</div>
</div>
<!-- 表格主体,高度为所有行的总高度 -->
<div class="table-body" :style="{ height: totalHeight + 'px' }">
<!-- 只渲染可见区域的行 -->
<div
v-for="index in visibleData"
:key="index"
class="table-row"
:style="{ top: (index * rowHeight) + 'px' }"
>
<!-- 渲染每个单元格 -->
<div v-for="col in columns" :key="col.key" class="table-cell">
<!-- 非可编辑列显示文本 -->
<span v-if="!col.editable">{{ data[index][col.key] }}</span>
<!-- 可编辑列显示下拉选择框 -->
<select
v-else
v-model="data[index][col.key]"
@change="updateCell(data[index], col.key)"
>
<option v-for="option in col.options" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// 接收列配置和数据
const props = defineProps({
columns: Array,
data: Array
})
// 定义常量:行高
const rowHeight = 40
// 可见行数
const visibleCount = ref(0)
// 起始行索引
const startIndex = ref(0)
// 滚动位置
const scrollTop = ref(0)
// 计算属性:表格总高度
const totalHeight = computed(() => props.data.length * rowHeight)
// 计算属性:可见数据范围
const visibleData = computed(() => {
const endIndex = startIndex.value + visibleCount.value
return props.data.slice(startIndex.value, endIndex)
})
// 滚动事件处理
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
// 根据滚动位置计算起始行索引
startIndex.value = Math.floor(scrollTop.value / rowHeight)
}
// 组件挂载后计算可见行数
onMounted(() => {
const container = document.querySelector('.virtual-table')
visibleCount.value = Math.ceil(container.clientHeight / rowHeight)
})
</script>3. Canvas表格方案
设计思路
使用Canvas绘制整个表格,完全绕过DOM操作,性能最佳。
核心代码
vue
<template>
<div class="canvas-container">
<!-- Canvas元素用于绘制表格 -->
<canvas ref="canvas" @click="handleClick" @scroll="handleScroll"></canvas>
<!-- 下拉选择框,用于编辑单元格 -->
<div v-if="showDropdown" class="dropdown" :style="dropdownStyle">
<select v-model="selectedValue" @change="updateSelectedCell" size="5">
<option v-for="option in currentOptions" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
// 接收列配置和数据
const props = defineProps({
columns: Array,
data: Array
})
// Canvas相关引用
const canvas = ref(null)
const ctx = ref(null)
// 下拉框状态
const showDropdown = ref(false)
const selectedValue = ref('')
const currentCell = ref(null)
const scrollTop = ref(0)
// 计算属性:当前单元格的可选选项
const currentOptions = computed(() => {
const col = props.columns.find(col => col.key === currentCell.value?.key)
return col?.options || []
})
// 计算属性:下拉框位置样式
const dropdownStyle = computed(() => {
if (!currentCell.value) return {}
return {
left: currentCell.value.x + 'px',
top: (currentCell.value.y - scrollTop.value) + 'px'
}
})
// 组件挂载后初始化Canvas
onMounted(() => {
ctx.value = canvas.value.getContext('2d')
initCanvas()
renderTable()
})
// 初始化Canvas尺寸
const initCanvas = () => {
canvas.value.width = canvas.value.offsetWidth
canvas.value.height = canvas.value.offsetHeight
}
// 渲染表格内容
const renderTable = () => {
if (!ctx.value) return
// 清空画布
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 绘制表头
ctx.value.fillStyle = '#f5f5f5'
ctx.value.fillRect(0, 0, canvas.value.width, 40)
let x = 0
props.columns.forEach(col => {
ctx.value.fillStyle = '#333'
ctx.value.font = '14px Arial'
ctx.value.fillText(col.title, x + 10, 25)
// 绘制列宽
const colWidth = col.width || 100
x += colWidth
})
// 计算可见行范围
const startRow = Math.floor(scrollTop.value / 40)
const visibleRowCount = Math.ceil(canvas.value.height / 40)
// 绘制数据行(只绘制可见部分)
for (let i = startRow; i < Math.min(startRow + visibleRowCount, props.data.length); i++) {
const row = props.data[i]
const y = i * 40 - scrollTop.value
// 交替行背景色
ctx.value.fillStyle = i % 2 === 0 ? '#fff' : '#f9f9f9'
ctx.value.fillRect(0, y, canvas.value.width, 40)
// 绘制单元格内容
let cellX = 0
props.columns.forEach(col => {
const colWidth = col.width || 100
ctx.value.fillStyle = '#333'
ctx.value.font = '14px Arial'
ctx.value.fillText(row[col.key], cellX + 10, y + 25)
cellX += colWidth
})
}
}
// 处理Canvas点击事件
const handleClick = (e) => {
const rect = canvas.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top + scrollTop.value
// 计算点击的单元格行索引
const rowIndex = Math.floor(y / 40)
// 排除表头
if (rowIndex < 1 || rowIndex >= props.data.length) return
// 计算点击的单元格列索引
let colX = 0
let colIndex = 0
for (let i = 0; i < props.columns.length; i++) {
const colWidth = props.columns[i].width || 100
if (x >= colX && x < colX + colWidth) {
colIndex = i
break
}
colX += colWidth
}
// 获取列配置
const column = props.columns[colIndex]
// 如果是可编辑列,显示下拉框
if (column.editable) {
currentCell.value = {
row: rowIndex,
key: column.key,
x: colX,
y: rowIndex * 40
}
selectedValue.value = props.data[rowIndex][column.key]
showDropdown.value = true
} else {
showDropdown.value = false
}
}
// 更新选中的单元格
const updateSelectedCell = () => {
if (currentCell.value) {
props.data[currentCell.value.row][currentCell.value.key] = selectedValue.value
showDropdown.value = false
// 重新渲染表格
renderTable()
}
}
// 处理滚动事件
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
// 滚动时重新渲染表格
renderTable()
}
</script>Canvas方案的优势
极致性能
完全绕过DOM操作,避免大量节点创建和渲染- 只绘制可视区域内容,内存占用极低
- 滚动时只需重新绘制,无需重新计算布局
内存效率
- 千万行数据只需存储数据本身,无需创建对应DOM节点
- 无论数据量多大,Canvas渲染开销基本恒定
渲染控制
- 可以精细控制渲染过程,实现各种优化策略
- 支持增量渲染和脏矩形优化
灵活性
- 可以轻松实现复杂表格样式和交互效果
- 支持自定义绘制逻辑和动画效果
跨平台一致性
- Canvas在不同浏览器和设备上表现一致
- 不受CSS兼容性问题影响
总结
对于千万行数据的表格展示,Canvas方案具有明显优势:
- 性能远超DOM方案,特别是在大数据量场景下
- 内存占用极低,不会因数据量增加而线性增长
- 提供更灵活的渲染控制和优化空间
虚拟表格方案是次优选择,在性能和开发复杂度之间取得平衡,而原生table方案只适用于少量数据场景。
其次当要使用Canvas方案处理超大数据集,可以结合虚拟表格化技术实现最佳性能。