在这里插入图片描述

一、问题背景

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

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

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

七、注意事项

  1. 必须设置 row-key:用于唯一标识每一行,保证多选功能正常
  2. 固定行高:当前实现要求所有行高度一致,不支持动态行高
  3. 表格容器必须有固定高度:虚拟滚动需要知道可视区域大小
  4. 序号列需要适配:使用 onScroll 回调获取起始索引
  5. 动态数据必须调用更新方法:直接修改 originData 不会触发重新渲染
  6. 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

如果这篇文章对你有帮助,欢迎点赞、收藏、转发~

更多推荐