el-table 大数据量渲染性能优化指令
·
这是一个为 Element Plus 表格(el-table)实现虚拟滚动的 Vue 自定义指令。
当前版本问题
- 多选事件重复触发:受影响事件有:@select-all、@selection-change、@select 请在使用事件时信任最后一次触发结果
未完成功能
- 排序
- 过滤
- 表尾合计行
- 其他
核心功能
- 大数据量渲染优化:只渲染可视区域内的数据行,大幅减少 DOM 节点数量
- 动态 padding 定位:通过
paddingTop/paddingBottom模拟完整列表的滚动空间 - 保持表格原生交互:完全保留了 Element UI 表格的选中、事件等功能
工作流程
原始数据(originData) → 计算可视区范围 → 切片数据(visibleData) → 替换 tableData
主要配置项
| 参数 | 说明 | 默认值 |
|---|---|---|
isVirtual | 是否启用虚拟滚动 | false |
originData | 原始完整数据 | 必填 |
rowHeight | 每行高度(px) | 40 |
bufferSize | 上下缓冲行数 | 5 |
count | 可视区显示行数 | 20 |
暴露的方法
_virtualScrollRefresh()- 刷新视图_virtualScrollToRow(index)- 滚动到指定行_virtualScrollToTop()/_virtualScrollToBottom()- 滚动到顶/底部_virtualScrollSelectAll()/_virtualScrollClearAll()- 全选/清空
使用示例
<el-table v-virtual-scroll="virtualConfig">
<el-table-column label="序号" width="80" align="center">
<template #default="{ $index }">
{{ getRealIndex($index) }}
</template>
</el-table-column>
</el-table>
const startIndex = ref(0) // 当前渲染的起始索引
// 获取真实索引
const getRealIndex = (visibleIndex) => {
return startIndex.value + visibleIndex + 1
}
const virtualConfig = {
isVirtual: true, // 启用虚拟滚动
count: 20, // 可视区域显示20行(固定值)
bufferSize: 5, // 缓冲区5行
rowHeight: 40, // 默认行高40px
debug: true, // 开启调试日志
autoAdjustScroll: true, // 自动调整滚动位置
// 滚动回调
onScroll: (info) => {
startIndex.value = info.startIndex
console.log('滚动信息:', info)
},
// 初始化完成回调
onInit: (methods) => {
console.log('虚拟滚动已初始化', methods)
// 可以保存 methods 供后续使用
window.tableMethods = methods
}
}
亮点
- 透明代理:自动转换选中事件中的代理对象为原始数据,保持上层逻辑不变
- 方法劫持:重写
store.toggleRowSelection等方法,确保 API 行为一致 - 性能优化:使用
requestAnimationFrame节流滚动事件
const virtualScrollDirective = {
mounted(el, binding) {
const options = binding.value || {}
if (!options.isVirtual) return
const tableInstance = el.__vueParentComponent?.proxy
if (!tableInstance) return
const originData = options.originData || tableInstance.$attrs?.originData
if (!originData) return
const tableData = tableInstance.store?.states?.data
if (!tableData) return
// 获取 row-key
const rowKey =
tableInstance.rowKey || tableInstance.$props?.rowKey || tableInstance.$attrs?.rowKey || 'id'
const getRowKey = typeof rowKey === 'function' ? rowKey : (row) => row[rowKey]
const config = {
rowHeight: options.rowHeight || 40,
bufferSize: options.bufferSize || 5,
visibleCount: options.count || 20,
currentStart: 0,
currentEnd: 0,
scrollTop: 0,
}
const scrollContainer = el.querySelector('.el-scrollbar__wrap')
if (!scrollContainer) return
const tableEl = el.querySelector('.el-table__body-wrapper')?.querySelector('table')
// 存储选中的 key
const selectedKeys = new Set()
const store = tableInstance.store
const selection = store?.states?.selection
let allCanCheckData = 0
// 获取 selectable 函数
const getSelectable = () => {
// 从表格列中获取 selectable 属性
const selectionColumn = tableInstance?.columns?.find((col) => col.type === 'selection')
return selectionColumn?.selectable || (() => true)
}
let lastEmitVersion = null
let versionCounter = 0
const getVersion = () => {
// 每次 selectedKeys 变化时递增版本号
// 需要在 selectedKeys 被修改的地方调用
return versionCounter
}
// 触发 selection-change 事件
const emitSelectionChange = () => {
const currentVersion = getVersion()
// 前置校验:版本号未变化则跳过
if (currentVersion === lastEmitVersion) {
return
}
lastEmitVersion = versionCounter
const selectedRows = originData.filter((row) => selectedKeys.has(getRowKey(row)))
tableInstance.$emit?.('selection-change', selectedRows)
}
// 更新表格选中状态
const updateSelection = (data = undefined) => {
if (!selection) return
if (!data) data = getVisibleData()
selection.value = data.filter((row) => selectedKeys.has(getRowKey(row)))
// 触发 selection-change 事件
emitSelectionChange()
}
const selectAll = () => {
const selectable = getSelectable()
allCanCheckData = 0
originData.forEach((row) => selectable(row) && selectedKeys.add(getRowKey(row)))
allCanCheckData = selectedKeys.size
versionCounter++
updateSelection()
// console.log('全选',selectedKeys.values())
// 触发 select-all 事件
tableInstance.$emit?.('select-all', Array.from(selectedKeys.values()))
}
const clearAll = () => {
selectedKeys.clear()
versionCounter++
allCanCheckData = 0
updateSelection()
// console.log('全选1')
// 触发 select-all 事件
tableInstance.$emit?.('select-all', [])
}
const isAllSelected = () => selectedKeys.size >= allCanCheckData && allCanCheckData > 0
const isIndeterminate = () => selectedKeys.size != allCanCheckData
const originToggleRowSelection = store?.toggleRowSelection
const originToggleAllSelection = store.toggleAllSelection
// 重写 store 中的方法
if (store) {
console.log(tableInstance, store)
// store.updateSort = (column, prop, order) => {
// console.log(column, prop, order)
// }
store.toggleRowSelection = (row, selected, emitChange = true, ignoreSelectable = false) => {
originToggleRowSelection(row, selected, emitChange, ignoreSelectable)
// clearEmit(originToggleRowSelection,row, selected, emitChange, ignoreSelectable)
toggleRow(row, selected)
// updateHeaderCheckbox()
}
store.toggleAllSelection = () => {
originToggleAllSelection()
setTimeout(() => {
if (isAllSelected() && !isIndeterminate()) {
clearAll()
} else {
selectAll()
}
}, 10)
// updateHeaderCheckbox()
// clearEmit(originToggleAllSelection)
}
store.clearSelection = () => {
clearAll()
}
}
// 切换单行选中
const toggleRow = (row, val) => {
const key = getRowKey(row)
if (val === true) {
selectedKeys.add(key)
versionCounter++
} else if (val === false) {
selectedKeys.delete(key)
versionCounter++
} else {
if (selectedKeys.has(key)) {
selectedKeys.delete(key)
versionCounter++
} else {
selectedKeys.add(key)
versionCounter++
}
}
updateSelection()
}
const getVisibleData = () => originData.slice(config.currentStart, config.currentEnd)
const updateView = () => {
const visibleData = getVisibleData()
tableData.value.splice(0, tableData.value.length, ...visibleData)
updateSelection(visibleData)
if (tableEl) {
tableEl.style.paddingTop = `${config.currentStart * config.rowHeight}px`
tableEl.style.paddingBottom = `${(originData.length - config.currentEnd) * config.rowHeight}px`
}
options.onScroll?.({
scrollTop: config.scrollTop,
startIndex: config.currentStart,
endIndex: config.currentEnd,
totalCount: originData.length,
})
}
const calculateRange = (scrollTop) => ({
startIndex: Math.max(Math.floor(scrollTop / config.rowHeight) - config.bufferSize, 0),
endIndex: Math.min(
Math.floor(scrollTop / config.rowHeight) + config.visibleCount + config.bufferSize,
originData.length,
),
})
const scrollToRow = (rowIndex) => {
if (rowIndex < 0 || rowIndex >= originData.length) return
const targetScrollTop = rowIndex * config.rowHeight
scrollContainer.scrollTop = targetScrollTop
const { startIndex, endIndex } = calculateRange(targetScrollTop)
if (config.currentStart !== startIndex || config.currentEnd !== endIndex) {
config.currentStart = startIndex
config.currentEnd = endIndex
updateView()
}
}
let rafId = null
const handleScroll = () => {
if (rafId) return
rafId = requestAnimationFrame(() => {
config.scrollTop = scrollContainer.scrollTop
const { startIndex, endIndex } = calculateRange(config.scrollTop)
if (config.currentStart !== startIndex || config.currentEnd !== endIndex) {
config.currentStart = startIndex
config.currentEnd = endIndex
updateView()
}
rafId = null
})
}
const refresh = () => {
config.currentStart = 0
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
scrollContainer.scrollTop = 0
updateView()
}
// 初始化
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
updateView()
scrollContainer.addEventListener('scroll', handleScroll)
let resizeObserver = null
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => refresh())
resizeObserver.observe(scrollContainer)
}
// 滚动到底部 - 修复
const scrollToBottom = () => {
// 计算最大滚动距离
const maxScrollTop = originData.length * config.rowHeight - scrollContainer.clientHeight
scrollContainer.scrollTop = Math.max(0, maxScrollTop)
}
// 滚动到顶部 - 修复
const scrollToTop = () => {
scrollContainer.scrollTop = 0
}
// 暴露方法
el._virtualScrollRefresh = refresh
el._virtualScrollToRow = scrollToRow
el._virtualScrollSelectAll = selectAll
el._virtualScrollClearAll = clearAll
el._virtualScrollToBottom = scrollToBottom
el._virtualScrollToTop = scrollToTop
el._cleanup = () => {
if (originToggleRowSelection) tableInstance.toggleRowSelection = originToggleRowSelection
if (el._stopWatch) el._stopWatch()
scrollContainer.removeEventListener('scroll', handleScroll)
resizeObserver?.disconnect()
if (rafId) cancelAnimationFrame(rafId)
}
},
unmounted(el) {
el._cleanup?.()
},
}
export default virtualScrollDirective
更多推荐
所有评论(0)