一、问题背景

在企业级后台开发中,我们经常会遇到这样的场景:

客户要求在一个表格中展示所有历史订单,数据量动辄几万甚至十几万条。

直接使用 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-自定义事件拦截器

七、注意事项

  1. 必须设置 row-key:用于唯一标识每一行,保证多选功能正常

  2. 固定行高:当前实现要求所有行高度一致,不支持动态行高

  3. 表格容器必须有固定高度:虚拟滚动需要知道可视区域大小

  4. 序号列需要适配:使用 onScroll 回调获取起始索引

  5. 动态数据必须调用更新方法:直接修改 originData 不会触发重新渲染

  6. toSorted 方法兼容性:代码中使用 ES2023 的 toSorted,如需兼容旧浏览器请替换为 [...originData].sort()

八、性能对比

指标普通 el-table虚拟滚动指令初始渲染时间3000ms+< 100msDOM 节点数100000+~500内存占用500MB+~30MB滚动帧率< 20fps60fps

九、总结

本文介绍的虚拟滚动指令通过以下技术点解决了大表格的性能问题:

  • 按需渲染:只渲染可视区域 ± 缓冲区的数据

  • Padding 撑开:用上下 padding 模拟完整表格高度

  • 事件拦截:拦截表格内部事件,用虚拟数据响应

  • RAF 节流:滚动事件使用 requestAnimationFrame 优化

  • 状态同步:独立维护选中状态,跨区域保持选中

该方案已在生产环境稳定运行,支持 10 万行数据的流畅滚动,且对业务代码几乎零侵入。


评论(0条)

请登录后评论
ziyuan

ziyuan Rank: 16

0

0

0

( 此人很懒并没有留下什么~~ )

首页

栏目

搜索

会员