一、问题背景
在企业级后台开发中,我们经常会遇到这样的场景:
客户要求在一个表格中展示所有历史订单,数据量动辄几万甚至十几万条。
直接使用 Element Plus 的 el-table 渲染会出现:
- 页面卡死:渲染 10000 行需要 3-5 秒
- 滚动掉帧:滚动时明显卡顿
- 内存爆炸:DOM 节点过多,移动端直接闪退
为什么不用现成的虚拟滚动表格?
| 方案 |
核心问题 |
| el-table-v2 |
多级表头完全无法支持;多选、排序、筛选等功能缺失需要手动实现;虽然有虚拟滚动但功能阉割严重 |
| vxe-table |
功能强大但需要引入完整组件库(约500KB+),与现有 el-table API 完全不兼容,样式需要重新适配,存量代码几乎无法复用 |
| 自定义虚拟滚动表格 |
需要从零实现排序、筛选、多选、固定列、多级表头等复杂功能,开发成本数月起步 |
为什么选择指令方案?
核心优势:零侵入、低成本
<!-- 原有代码 -->
<el-table :data="bigData" @selection-change="handleSelect">
<el-table-column prop="name" label="姓名" />
</el-table>
<!-- 只需添加一个指令,其他代码完全不变 -->
<el-table v-virtual-scroll="config" @selection-change="handleSelect">
<el-table-column prop="name" label="姓名" />
</el-table>
- ✅ 多级表头:完全保留
- ✅ 多选/排序/筛选:完全保留
- ✅ 固定列/操作列:完全保留
- ✅ 自定义模板/插槽:完全保留
- ✅ 存量代码零改动:只需添加指令属性
二、核心原理
虚拟滚动的本质
只渲染可视区域内的数据行,其他行用空白占位
┌──────────────────────┐
│ 缓冲区(上方10行) │ ← 预先渲染,防止滚动时白屏
├──────────────────────┤
│ │
│ 可视区域(20行) │ ← 用户实际看到的部分
│ │
├──────────────────────┤
│ 缓冲区(下方10行) │
└──────────────────────┘
核心计算公式
// 根据滚动位置计算需要渲染的起始行
startIndex = max(floor(scrollTop / rowHeight) - bufferSize, 0)
// 计算结束行
endIndex = min(
floor(scrollTop / rowHeight) + visibleCount + bufferSize,
totalCount
)
三、技术难点与解决方案
难点1:如何让表格只渲染部分数据?
el-table 的 :data 绑定的是什么,它就渲染什么。我们只需要动态替换这个数组:
const tableData = tableInstance.store?.states?.data
const updateView = () => {
// 只取可见区域的数据
const visibleData = originData.slice(currentStart, currentEnd)
// 替换表格数据(使用 splice 触发最小量更新)
tableData.value.splice(0, tableData.value.length, ...visibleData)
}
难点2:如何保持滚动条长度正确?
表格只渲染了部分数据,但滚动条需要根据总数据量来决定长度。
解决方案:用 padding 撑开表格高度
const tableEl = el.querySelector('.el-table__body-wrapper')?.querySelector('table')
if (tableEl) {
// 上方 padding = 前面跳过的行数 × 行高
tableEl.style.paddingTop = `${currentStart * rowHeight}px`
// 下方 padding = 后面未渲染的行数 × 行高
tableEl.style.paddingBottom = `${(originData.length - currentEnd) * rowHeight}px`
}
难点3:如何拦截 el-table 的内部事件?
el-table 的滚动、排序、筛选都是通过 Vue 的 emit 触发的。我们需要拦截这些事件,用虚拟滚动处理后的数据响应。
const installInterceptor = (targetInstance, interceptorsMap) => {
const originalEmit = targetInstance.emit
targetInstance.emit = function(event, ...args) {
const interceptor = interceptorsMap[event]
if (interceptor) {
const result = interceptor(...args)
// 如果拦截器返回了数组,用返回值替换原参数
if (Array.isArray(result)) {
args = result
}
}
return originalEmit.call(this, event, ...args)
}
}
难点4:多选功能如何保持选中状态?
虚拟滚动会导致选中的行可能不在当前视图中,需要单独维护选中状态。
// 用 Set 存储所有选中行的 key
const selectedKeys = new Set()
// 更新表格选中状态时,从完整数据中查找
const updateSelection = (visibleData) => {
// 先找可见数据中的选中行
const selects = visibleData.filter(row => selectedKeys.has(getRowKey(row)))
// 如果可见区域没有,但全局有选中,需要补充(保证勾选框状态正确)
if (selects.length === 0 && selectedKeys.size > 0) {
selects.push(originData.find(row => selectedKeys.has(getRowKey(row))))
}
selection.value = selects
}
难点5:排序和筛选后如何重置虚拟滚动?
排序和筛选会改变数据顺序和数量,需要重置滚动位置和渲染范围。
const refresh = () => {
// 重置到第一页
currentStart = 0
currentEnd = Math.min(visibleCount + bufferSize, originData.length)
// 滚动条回到顶部
scrollContainer.scrollTop = 0
// 重新渲染
updateView()
}
四、完整代码实现
类型定义
// types.ts
export interface virtualConfig {
isVirtual?: boolean // 是否启用虚拟滚动
originData?: any[] // 原始完整数据
rowHeight?: number // 行高(px),默认40
bufferSize?: number // 缓冲区行数,默认5
count?: number // 可视区域行数,默认20
isDebug?: boolean // 调试模式
onInit?: (callback: (data: any[]) => void) => void | Promise<any[]>
onScroll?: (info: { scrollTop: number; startIndex: number; endIndex: number; totalCount: number }) => void
interceptorsMap?: Record<string, Function>
}
export type interceptorsMapType = Record<string, Function>
指令核心代码
// virtualScroll.ts
import { interceptorsMapType, virtualConfig } from './types'
const virtualScrollDirective = {
mounted(el, binding) {
const options = binding.value || { originData: [] }
const tableInstance = el.__vueParentComponent?.proxy
if (!tableInstance) return
const isDebug = options.isDebug
let originData = options.originData || tableInstance.$attrs?.originData || []
let backData: any[] = [] // 筛选前的数据备份
let isFilter = false
// 获取表格内部状态
const states = tableInstance.store?.states
const tableData = states?.data
if (!tableData) return
// 不启用虚拟滚动,直接渲染全部数据
if (!options.isVirtual) {
tableData.value = originData
return
}
// 获取 row-key
const rowKey = tableInstance.rowKey || tableInstance.$props?.rowKey ||
tableInstance.$attrs?.rowKey || 'id'
const getRowKey = typeof rowKey === 'function'
? rowKey
: (row: any) => 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')
// 选中状态管理
const selectedKeys = new Set()
const selection = tableInstance.store?.states?.selection
// 获取 selectable 函数(哪些行可以选中)
const getSelectable = () => {
const selectionColumn = tableInstance?.columns?.find(
(col: any) => col.type === 'selection'
)
return selectionColumn?.selectable || (() => true)
}
// 全选
const selectAll = () => {
const selectable = getSelectable()
const nowSelectData: any[] = []
originData.forEach((row: any) => {
if (selectable(row)) {
selectedKeys.add(getRowKey(row))
nowSelectData.push(row)
}
})
updateSelection()
return nowSelectData
}
// 清空选中
const clearAll = () => {
selectedKeys.clear()
updateSelection()
}
// 更新表格选中状态
const updateSelection = (data?: any[]) => {
if (!selection) return
const sourceData = data || getVisibleData()
const selects = sourceData.filter((row: any) => selectedKeys.has(getRowKey(row)))
if (selects.length === 0 && selectedKeys.size > 0) {
selects.push(originData.find((row: any) => selectedKeys.has(getRowKey(row))))
}
selection.value = selects
}
// 获取可见数据
const getVisibleData = (oriData = originData) => {
return oriData.slice(config.currentStart, config.currentEnd)
}
// 更新视图
const updateView = (oriData = originData) => {
const visibleData = getVisibleData(oriData)
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: number) => ({
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
),
})
// 滚动处理(RAF 节流)
let rafId: number | null = null
const handleScroll = (scrollTop: number) => {
if (rafId) return
rafId = requestAnimationFrame(() => {
config.scrollTop = 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()
}
// 滚动到指定行
const scrollToRow = (rowIndex: number) => {
if (rowIndex < 0 || rowIndex >= originData.length) return
const targetScrollTop = rowIndex * config.rowHeight
scrollContainer.scrollTop = targetScrollTop
}
// 滚动到底部
const scrollToBottom = () => {
const maxScrollTop = originData.length * config.rowHeight - scrollContainer.clientHeight
scrollContainer.scrollTop = Math.max(0, maxScrollTop)
}
// 滚动到顶部
const scrollToTop = () => {
scrollContainer.scrollTop = 0
}
// 更新数据(用于动态加载)
const updateData = (newData: any[]) => {
if (!newData || !Array.isArray(newData)) return
originData = newData
selectedKeys.clear()
config.currentStart = 0
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
config.scrollTop = 0
updateView()
if (scrollContainer) {
scrollContainer.scrollTop = 0
}
}
// 事件拦截器
const interceptorsMap: interceptorsMapType = {
scroll: (value: { scrollTop: number }) => {
handleScroll(value.scrollTop)
},
'sort-change': (value: { prop: string; order: string }) => {
const { prop, order } = value
if (order === 'ascending') {
updateView(originData.toSorted((a, b) => a[prop] - b[prop]))
} else if (order === 'descending') {
updateView(originData.toSorted((a, b) => b[prop] - a[prop]))
} else {
updateView(originData)
}
},
select: (value: any[], row: any) => {
const isSelect = value.includes(row)
if (isSelect) {
selectedKeys.add(getRowKey(row))
} else {
selectedKeys.delete(getRowKey(row))
}
return [originData.filter((row: any) => selectedKeys.has(getRowKey(row))), row]
},
'select-all': (value: any[]) => {
if (value.length === 0) {
clearAll()
return []
} else {
return selectAll()
}
},
'filter-change': (value: Record<string, any[]>) => {
const filterCondition = Object.values(value)
const isNowFilter = filterCondition.some(arr => arr.length > 0)
if (!isFilter && isNowFilter) {
backData = [...originData]
} else {
originData = [...backData]
}
isFilter = isNowFilter
// 应用各列的筛选条件
states.columns.value.forEach((columnConfig: any) => {
const filterNowData = columnConfig.filteredValue
if (filterNowData?.length > 0 && columnConfig.filterMethod) {
originData = originData.filter((dataItem: any) => {
return filterNowData.some((filterValue: any) =>
columnConfig.filterMethod(filterValue, dataItem, columnConfig)
)
})
}
})
refresh()
},
...(options.interceptorsMap || {}),
}
// 安装拦截器
const installInterceptor = () => {
const childProxy = tableInstance.$refs?.childRef || tableInstance.refs?.childRef
if (!childProxy) {
setTimeout(installInterceptor, 100)
return
}
let targetInstance = childProxy
if (childProxy.__vnode) targetInstance = childProxy.__vnode.component
if (targetInstance?.emit && !targetInstance.__interceptorInstalled) {
const originalEmit = targetInstance.emit
targetInstance.emit = function(event: string, ...args: any[]) {
const interceptor = interceptorsMap[event]
if (interceptor) {
const result = interceptor(...args)
if (Array.isArray(result)) args = result
}
return originalEmit.call(this, event, ...args)
}
targetInstance.__interceptorInstalled = true
}
}
installInterceptor()
// 初始化
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
updateView()
// 监听容器大小变化
let resizeObserver: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => refresh())
resizeObserver.observe(scrollContainer)
}
// 暴露方法给外部调用
el._virtualScrollUpdateData = updateData
el._virtualScrollRefresh = refresh
el._virtualScrollToRow = scrollToRow
el._virtualScrollSelectAll = selectAll
el._virtualScrollClearAll = clearAll
el._virtualScrollToBottom = scrollToBottom
el._virtualScrollToTop = scrollToTop
tableInstance._virtualScrollUpdateData = updateData
tableInstance._virtualScrollRefresh = refresh
tableInstance._virtualScrollToRow = scrollToRow
tableInstance._virtualScrollSelectAll = selectAll
tableInstance._virtualScrollClearAll = clearAll
tableInstance._virtualScrollToBottom = scrollToBottom
tableInstance._virtualScrollToTop = scrollToTop
// 清理
el._cleanup = () => {
resizeObserver?.disconnect()
if (rafId) cancelAnimationFrame(rafId)
}
tableInstance._cleanup = el._cleanup
},
unmounted(el) {
el._cleanup?.()
},
}
export default virtualScrollDirective
五、使用指南
基本用法
<template>
<el-table
v-virtual-scroll="virtualOptions"
row-key="id"
border
>
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="age" label="年龄" sortable />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
</el-table>
</template>
<script setup lang="ts">
import virtualScrollDirective from './directives/virtualScroll'
const vVirtualScroll = virtualScrollDirective
// 模拟10万条数据
const generateData = () => {
const data = []
for (let i = 1; i <= 100000; i++) {
data.push({
id: i,
name: `用户${i}`,
age: Math.floor(Math.random() * 60) + 18,
address: `北京市朝阳区某某路${i}号`
})
}
return data
}
const virtualOptions = {
isVirtual: true,
originData: generateData(),
rowHeight: 48,
bufferSize: 10,
count: 20,
isDebug: false,
onScroll: (info) => {
console.log(`滚动到第 ${info.startIndex} - ${info.endIndex} 行`)
}
}
</script>
序号列适配(重要)
由于虚拟滚动只渲染可见区域的数据,scope.$index 返回的是可见区域内的索引,需要转换为真实索引:
<template>
<el-table
ref="tableRef"
v-virtual-scroll="virtualScrollConfig"
row-key="id"
>
<!-- 序号列:需要适配虚拟滚动 -->
<el-table-column label="序号" min-width="60" fixed="left">
<template #default="scope">
{{ getRealIndex(scope.$index) }}
</template>
</el-table-column>
<!-- 其他列完全不需要改动 -->
<el-table-column prop="name" label="姓名" />
</el-table>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const startIndex = ref(0)
// 获取真实数据索引(1-based)
const getRealIndex = (visibleIndex: number) => {
return startIndex.value + visibleIndex + 1
}
const virtualScrollConfig = ref({
isVirtual: true,
originData: [],
count: 20,
bufferSize: 10,
rowHeight: 40,
onScroll: (info) => {
startIndex.value = info.startIndex
},
})
</script>
动态接口数据更新
方式一:通过 ref 调用更新方法(推荐)
<template>
<el-table
ref="tableRef"
v-virtual-scroll="virtualScrollConfig"
row-key="id"
/>
</template>
<script setup lang="ts">
const tableRef = ref()
const virtualScrollConfig = ref({
isVirtual: true,
originData: [], // 初始空数组
count: 20,
bufferSize: 10,
rowHeight: 40,
onScroll: (info) => {
startIndex.value = info.startIndex
},
})
const loadData = async () => {
const res = await api.getTableData()
// 必须调用指令暴露的更新方法
tableRef.value?._virtualScrollUpdateData?.(res.data)
}
</script>
方式二:分页查询 + 虚拟滚动
<template>
<div>
<el-pagination
v-model:current-page="pageNum"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
<el-table
ref="tableRef"
v-virtual-scroll="virtualScrollConfig"
row-key="id"
>
<el-table-column label="序号">
<template #default="scope">
{{ (pageNum - 1) * pageSize + getRealIndex(scope.$index) }}
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" />
</el-table>
</div>
</template>
<script setup lang="ts">
const tableRef = ref()
const pageNum = ref(1)
const pageSize = ref(20)
const total = ref(0)
const startIndex = ref(0)
const getRealIndex = (visibleIndex: number) => {
return startIndex.value + visibleIndex + 1
}
const virtualScrollConfig = ref({
isVirtual: true,
originData: [],
count: pageSize.value,
bufferSize: 10,
rowHeight: 48,
onScroll: (info) => {
startIndex.value = info.startIndex
},
})
const loadData = async () => {
const res = await api.getTableData({
pageNum: pageNum.value,
pageSize: pageSize.value,
})
total.value = res.data.total
tableRef.value?._virtualScrollUpdateData?.(res.data.records)
}
const handlePageChange = () => {
loadData()
tableRef.value?._virtualScrollToTop?.()
}
</script>
指令暴露的方法
| 方法 |
说明 |
_virtualScrollUpdateData(data) |
更新表格数据(动态接口必用) |
_virtualScrollRefresh() |
刷新视图(重置滚动位置) |
_virtualScrollToRow(index) |
滚动到指定行 |
_virtualScrollToTop() |
滚动到顶部 |
_virtualScrollToBottom() |
滚动到底部 |
_virtualScrollSelectAll() |
全选 |
_virtualScrollClearAll() |
清空选中 |
六、配置参数说明
| 参数 |
类型 |
默认值 |
说明 |
| isVirtual |
boolean |
false |
是否启用虚拟滚动 |
| originData |
array |
[] |
原始完整数据 |
| rowHeight |
number |
40 |
行高(px),必须与实际行高一致 |
| bufferSize |
number |
5 |
缓冲区行数,越大滚动越平滑但渲染越多 |
| count |
number |
20 |
可视区域行数,通常根据容器高度计算 |
| isDebug |
boolean |
false |
调试模式,开启后打印日志 |
| onScroll |
function |
- |
滚动回调,返回当前滚动信息 |
| interceptorsMap |
object |
- |
自定义事件拦截器 |
七、注意事项
- 必须设置
row-key:用于唯一标识每一行,保证多选功能正常
- 固定行高:当前实现要求所有行高度一致,不支持动态行高
- 表格容器必须有固定高度:虚拟滚动需要知道可视区域大小
- 序号列需要适配:使用
onScroll 回调获取起始索引
- 动态数据必须调用更新方法:直接修改
originData 不会触发重新渲染
toSorted 方法兼容性:代码中使用 ES2023 的 toSorted,如需兼容旧浏览器请替换为 [...originData].sort()
八、性能对比
| 指标 |
普通 el-table |
虚拟滚动指令 |
| 初始渲染时间 |
3000ms+ |
< 100ms |
| DOM 节点数 |
100000+ |
~500 |
| 内存占用 |
500MB+ |
~30MB |
| 滚动帧率 |
< 20fps |
60fps |
九、总结
本文介绍的虚拟滚动指令通过以下技术点解决了大表格的性能问题:
- 按需渲染:只渲染可视区域 ± 缓冲区的数据
- Padding 撑开:用上下 padding 模拟完整表格高度
- 事件拦截:拦截表格内部事件,用虚拟数据响应
- RAF 节流:滚动事件使用 requestAnimationFrame 优化
- 状态同步:独立维护选中状态,跨区域保持选中
该方案已在生产环境稳定运行,支持 10 万行数据的流畅滚动,且对业务代码几乎零侵入。
源码地址:GitHub: vue3-el-table-virtual-scroll
如果这篇文章对你有帮助,欢迎点赞、收藏、转发~