一、问题背景在企业级后台开发中,我们经常会遇到这样的场景: 客户要求在一个表格中展示所有历史订单,数据量动辄几万甚至十几万条。
直接使用 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> 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> 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> 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> 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()清空选中六、配置参数说明参数类型默认值说明isVirtualbooleanfalse是否启用虚拟滚动originDataarray[]原始完整数据rowHeightnumber40行高(px),必须与实际行高一致bufferSizenumber5缓冲区行数,越大滚动越平滑但渲染越多countnumber20可视区域行数,通常根据容器高度计算isDebugbooleanfalse调试模式,开启后打印日志onScrollfunction-滚动回调,返回当前滚动信息interceptorsMapobject-自定义事件拦截器七、注意事项必须设置 row-key:用于唯一标识每一行,保证多选功能正常 固定行高:当前实现要求所有行高度一致,不支持动态行高 表格容器必须有固定高度:虚拟滚动需要知道可视区域大小 序号列需要适配:使用 onScroll 回调获取起始索引 动态数据必须调用更新方法:直接修改 originData 不会触发重新渲染 toSorted 方法兼容性:代码中使用 ES2023 的 toSorted,如需兼容旧浏览器请替换为 [...originData].sort()
八、性能对比指标普通 el-table虚拟滚动指令初始渲染时间3000ms+< 100msDOM 节点数100000+~500内存占用500MB+~30MB滚动帧率< 20fps60fps九、总结本文介绍的虚拟滚动指令通过以下技术点解决了大表格的性能问题: 该方案已在生产环境稳定运行,支持 10 万行数据的流畅滚动,且对业务代码几乎零侵入。 |