Vue3 + Element Plus 虚拟滚动指令:10万行数据不卡顿的解决方案
·

一、问题背景
在企业级后台开发中,我们经常会遇到这样的场景:
客户要求在一个表格中展示所有历史订单,数据量动辄几万甚至十几万条。
直接使用 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>
指令核心代码
import { interceptorsMapType, virtualConfig } from './types'
const virtualScrollDirective = {
mounted(
el: {
_virtualScrollUpdateData: (newData: any[]) => void
__vueParentComponent: { proxy: any }
querySelector: (arg0: string) => {
clientHeight: number
removeEventListener(arg0: string, handleScroll: () => void): unknown
addEventListener(arg0: string, handleScroll: () => void): unknown
scrollTop: number
(): any
new (): any
querySelector: { (arg0: string): any; new (): any }
}
_virtualScrollRefresh: () => void
_virtualScrollToRow: (rowIndex: any) => void
_virtualScrollSelectAll: () => void
_virtualScrollClearAll: () => void
_virtualScrollToBottom: () => void
_virtualScrollToTop: () => void
_cleanup: () => void
_stopWatch: () => void
},
binding: { value: virtualConfig },
vnode: any,
) {
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 originalDataBackup: any[] = []
let isFilter = false
if (options.onInit && (!originData || originData.length === 0)) {
const result = options.onInit((data: any[]) => {
// 回调方式
if (data && data.length > 0) {
originData = data
refresh()
}
})
// Promise 方式
if (result && typeof result.then === 'function') {
result.then((data) => {
if (data && data.length > 0) {
originData = data
refresh()
}
})
}
}
if (!originData) return
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: { [x: string]: 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')
// 存储选中的 key
const selectedKeys = new Set()
const store = tableInstance.store
const selection = store?.states?.selection
// 获取 selectable 函数
const getSelectable = () => {
// 从表格列中获取 selectable 属性
const selectionColumn = tableInstance?.columns?.find(
(col: { type: string }) => col.type === 'selection',
)
return selectionColumn?.selectable || (() => true)
}
// 更新表格选中状态
const updateSelection = (data: virtualConfig['originData'] | undefined = undefined) => {
if (!selection) return
if (!data) data = getVisibleData()
const selects = data!.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 selectAll = () => {
const selectable = getSelectable()
const nowSelectData: any[] = []
originData.forEach(
(row: any) =>
selectable(row) && selectedKeys.add(getRowKey(row)) && nowSelectData.push(row),
)
updateSelection()
return nowSelectData
}
const clearAll = () => {
selectedKeys.clear()
updateSelection()
return []
}
const log = (...args: (string | any[])[]) => {
isDebug && console.log(...args)
}
const interceptorLog = (event: any, ...args: any[]) => {
log(`${event} 🔒 拦截器:`, args)
}
// region 事件拦截
const interceptorsMap: interceptorsMapType = {
'sort-change': (value: { prop: any; order: any }) => {
interceptorLog('sort-change', value)
const { prop, order } = value
switch (order) {
case 'ascending':
log('sort-change ascending 触发视图更新')
updateView(
originData.toSorted(
(a: { [x: string]: number }, b: { [x: string]: number }) => a[prop] - b[prop],
),
)
break
case 'descending':
log('sort-change descending 触发视图更新')
updateView(
originData.toSorted(
(a: { [x: string]: number }, b: { [x: string]: number }) => b[prop] - a[prop],
),
)
break
default:
log('sort-change default 触发视图更新')
updateView(originData)
break
}
},
select: (value, row) => {
interceptorLog('select', value, '行数据:', row)
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]
// console.log(selectedKeys)
},
'select-all': (value) => {
interceptorLog('select-all', value)
const valueLength = value.length
let data
if (valueLength === 0) {
data = [clearAll()]
} else {
data = [selectAll()]
}
tableInstance.$emit?.('selection-change')
return data
},
'selection-change': (value) => {
interceptorLog('select-change', value)
const selectedRows = originData.filter((row: any) => selectedKeys.has(getRowKey(row)))
return [selectedRows || []]
},
scroll: (value: { scrollLeft: number; scrollTop: number }) => {
// interceptorLoglog('scroll', value)
handleScroll(value.scrollTop)
},
'filter-change': (value) => {
interceptorLog('filter-change', value)
const filterCondition: any[][] = Object.values(value)
const isNowFilter = !!filterCondition.find(
(filterConditionItem: any[]) => filterConditionItem.length > 0,
)
if (!isFilter && isNowFilter) originalDataBackup = [...originData]
else originData = [...originalDataBackup]
isFilter = isNowFilter
states.columns.value.map(
(columnConfig: {
filterMethod: (value: any, row: any, column: any) => boolean
filteredValue: any[]
columnKey: string
}) => {
const filterNowData = columnConfig.filteredValue
if (filterNowData.length > 0) {
const filterMethod = columnConfig.filterMethod
if (!filterMethod) {
return
}
originData = originData.filter((dataItem: any) => {
return !!filterNowData.find((filterNowDataItem: any) =>
filterMethod(filterNowDataItem, dataItem, columnConfig),
)
})
}
},
)
refresh()
},
...(options.interceptorsMap || {}),
}
// 获取父组件实例
const parentInstance = tableInstance
if (!parentInstance) {
console.error('无法获取父组件实例')
return
}
let retryCount = 0
const maxRetries = 30
const installInterceptor = () => {
// 获取子组件 ref
const childProxy =
parentInstance || parentInstance.$refs?.childRef || parentInstance.refs?.childRef
if (childProxy) {
// 正确处理 Vue 3 的组件实例
let childInstance = null
// 尝试多种方式获取真正的组件实例
if (childProxy.$) {
// Vue 3 组合式 API 的实例
childInstance = childProxy.$
} else if (childProxy.__vnode) {
childInstance = childProxy.__vnode.component
} else if (childProxy._) {
childInstance = childProxy._
} else if (childProxy.proxy) {
childInstance = childProxy.proxy
}
// 确定有 emit 方法的实例
const targetInstance = childInstance?.emit ? childInstance : childProxy
if (
targetInstance &&
typeof targetInstance.emit === 'function' &&
!targetInstance.__interceptorInstalled
) {
const originalEmit = targetInstance.emit
targetInstance.emit = function (event: string, ...args: any[] | any) {
const interceptor = interceptorsMap[event]
if (interceptor) {
const result = interceptor(...args)
if (result === false) {
log(`⛔ 事件被阻止: ${event}`)
return
}
const types = ['[object Array]', '[object Object]']
if (types.includes(Object.prototype.toString.call(result))) {
if (Array.isArray(result)) {
// 返回数组:完全替换
args = result
log(`🔄 参数数组被替换: ${event}`, args)
} else if (args.length === 1) {
// 单参数场景:允许直接返回值
args = [result]
log(`🔄 单参数被替换: ${event}`, args)
} else {
// 多参数场景:不支持非数组返回值
log(`⚠️ 多参数事件 ${event} 的拦截器返回了非数组,已忽略`, result)
}
}
}
return originalEmit!.call(this, event, ...args)
}
targetInstance.__interceptorInstalled = true
targetInstance.__originalEmit = originalEmit
log('✅ 事件拦截器安装成功', Object.keys(interceptorsMap))
return
}
}
retryCount++
if (retryCount < maxRetries) {
setTimeout(installInterceptor, 100)
} else {
console.error('❌ 未能找到子组件实例')
}
}
installInterceptor()
//endregion
const getVisibleData = (oriData = originData) =>
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,
),
})
const scrollToRow = (rowIndex: number) => {
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
log('scrollToRow 触发视图更新')
updateView()
}
}
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
log('handleScroll 触发视图更新')
updateView()
}
rafId = null
})
}
const refresh = () => {
config.currentStart = 0
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
scrollContainer.scrollTop = 0
log('refresh 触发视图更新')
updateView()
}
// 初始化
config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
log('初始化 触发视图更新')
updateView()
let resizeObserver = null
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => refresh())
//@ts-ignore
resizeObserver.observe(scrollContainer)
}
// 滚动到底部 - 修复
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
// 更新视图
log('updateData 触发视图更新')
updateView()
// 重置滚动位置
if (scrollContainer) {
scrollContainer.scrollTop = 0
}
}
// 暴露方法
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: { _cleanup: () => void }) {
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
如果这篇文章对你有帮助,欢迎点赞、收藏、转发~
更多推荐

所有评论(0)