这是一个为 Element Plus 表格el-table)实现虚拟滚动的 Vue 自定义指令。

当前版本问题

  • 多选事件重复触发:受影响事件有:@select-all、@selection-change、@select 请在使用事件时信任最后一次触发结果

未完成功能

  • 排序
  • 过滤
  • 表尾合计行
  • 其他

核心功能

  • 大数据量渲染优化:只渲染可视区域内的数据行,大幅减少 DOM 节点数量
  • 动态 padding 定位:通过 paddingTop/paddingBottom 模拟完整列表的滚动空间
  • 保持表格原生交互:完全保留了 Element UI 表格的选中、事件等功能

工作流程

原始数据(originData) → 计算可视区范围 → 切片数据(visibleData) → 替换 tableData

主要配置项

参数说明默认值
isVirtual是否启用虚拟滚动false
originData原始完整数据必填
rowHeight每行高度(px)40
bufferSize上下缓冲行数5
count可视区显示行数20

暴露的方法

  • _virtualScrollRefresh() - 刷新视图
  • _virtualScrollToRow(index) - 滚动到指定行
  • _virtualScrollToTop() / _virtualScrollToBottom() - 滚动到顶/底部
  • _virtualScrollSelectAll() / _virtualScrollClearAll() - 全选/清空

使用示例


  <el-table v-virtual-scroll="virtualConfig">
<el-table-column label="序号" width="80" align="center">
        <template #default="{ $index }">
          {{ getRealIndex($index) }}
        </template>
      </el-table-column>
</el-table>

const startIndex = ref(0) // 当前渲染的起始索引
// 获取真实索引
const getRealIndex = (visibleIndex) => {
  return startIndex.value + visibleIndex + 1
}
const virtualConfig = {
  isVirtual: true,      // 启用虚拟滚动
  count: 20,            // 可视区域显示20行(固定值)
  bufferSize: 5,        // 缓冲区5行
  rowHeight: 40,        // 默认行高40px
  debug: true,          // 开启调试日志
  autoAdjustScroll: true, // 自动调整滚动位置

  // 滚动回调
  onScroll: (info) => {
    startIndex.value = info.startIndex
    console.log('滚动信息:', info)
  },

  // 初始化完成回调
  onInit: (methods) => {
    console.log('虚拟滚动已初始化', methods)
    // 可以保存 methods 供后续使用
    window.tableMethods = methods
  }
}

亮点

  1. 透明代理:自动转换选中事件中的代理对象为原始数据,保持上层逻辑不变
  2. 方法劫持:重写 store.toggleRowSelection 等方法,确保 API 行为一致
  3. 性能优化:使用 requestAnimationFrame 节流滚动事件
const virtualScrollDirective = {
  mounted(el, binding) {
    const options = binding.value || {}
    if (!options.isVirtual) return

    const tableInstance = el.__vueParentComponent?.proxy
    if (!tableInstance) return

    const originData = options.originData || tableInstance.$attrs?.originData
    if (!originData) return

    const tableData = tableInstance.store?.states?.data
    if (!tableData) return

    // 获取 row-key
    const rowKey =
      tableInstance.rowKey || tableInstance.$props?.rowKey || tableInstance.$attrs?.rowKey || 'id'
    const getRowKey = typeof rowKey === 'function' ? rowKey : (row) => 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
    let allCanCheckData = 0
    // 获取 selectable 函数
    const getSelectable = () => {
      // 从表格列中获取 selectable 属性
      const selectionColumn = tableInstance?.columns?.find((col) => col.type === 'selection')
      return selectionColumn?.selectable || (() => true)
    }
    let lastEmitVersion = null
    let versionCounter = 0
    const getVersion = () => {
      // 每次 selectedKeys 变化时递增版本号
      // 需要在 selectedKeys 被修改的地方调用
      return versionCounter
    }
    // 触发 selection-change 事件
    const emitSelectionChange = () => {
      const currentVersion = getVersion()

      // 前置校验:版本号未变化则跳过
      if (currentVersion === lastEmitVersion) {
        return
      }
      lastEmitVersion = versionCounter
      const selectedRows = originData.filter((row) => selectedKeys.has(getRowKey(row)))
      tableInstance.$emit?.('selection-change', selectedRows)
    }

    // 更新表格选中状态
    const updateSelection = (data = undefined) => {
      if (!selection) return
      if (!data) data = getVisibleData()
      selection.value = data.filter((row) => selectedKeys.has(getRowKey(row)))
      // 触发 selection-change 事件
      emitSelectionChange()
    }

    const selectAll = () => {
      const selectable = getSelectable()
      allCanCheckData = 0
      originData.forEach((row) => selectable(row) && selectedKeys.add(getRowKey(row)))
      allCanCheckData = selectedKeys.size
      versionCounter++
      updateSelection()
      // console.log('全选',selectedKeys.values())
      // 触发 select-all 事件
      tableInstance.$emit?.('select-all', Array.from(selectedKeys.values()))
    }

    const clearAll = () => {
      selectedKeys.clear()
      versionCounter++
      allCanCheckData = 0
      updateSelection()
      // console.log('全选1')
      // 触发 select-all 事件
      tableInstance.$emit?.('select-all', [])
    }

    const isAllSelected = () => selectedKeys.size >= allCanCheckData && allCanCheckData > 0
    const isIndeterminate = () => selectedKeys.size != allCanCheckData

    const originToggleRowSelection = store?.toggleRowSelection
    const originToggleAllSelection = store.toggleAllSelection
    // 重写 store 中的方法
    if (store) {
      console.log(tableInstance, store)
      // store.updateSort = (column, prop, order) => {
      //     console.log(column, prop, order)
      // }
      store.toggleRowSelection = (row, selected, emitChange = true, ignoreSelectable = false) => {
        originToggleRowSelection(row, selected, emitChange, ignoreSelectable)
        // clearEmit(originToggleRowSelection,row, selected, emitChange, ignoreSelectable)
        toggleRow(row, selected)
        // updateHeaderCheckbox()
      }
      store.toggleAllSelection = () => {
        originToggleAllSelection()
        setTimeout(() => {
          if (isAllSelected() && !isIndeterminate()) {
            clearAll()
          } else {
            selectAll()
          }
        }, 10)
        // updateHeaderCheckbox()
        // clearEmit(originToggleAllSelection)
      }

      store.clearSelection = () => {
        clearAll()
      }
    }

    // 切换单行选中
    const toggleRow = (row, val) => {
      const key = getRowKey(row)
      if (val === true) {
        selectedKeys.add(key)
        versionCounter++
      } else if (val === false) {
        selectedKeys.delete(key)
        versionCounter++
      } else {
        if (selectedKeys.has(key)) {
          selectedKeys.delete(key)
          versionCounter++
        } else {
          selectedKeys.add(key)
          versionCounter++
        }
      }
      updateSelection()
    }

    const getVisibleData = () => originData.slice(config.currentStart, config.currentEnd)
    const updateView = () => {
      const visibleData = getVisibleData()
      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) => ({
      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) => {
      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
        updateView()
      }
    }

    let rafId = null
    const handleScroll = () => {
      if (rafId) return
      rafId = requestAnimationFrame(() => {
        config.scrollTop = scrollContainer.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()
    }

    // 初始化
    config.currentEnd = Math.min(config.visibleCount + config.bufferSize, originData.length)
    updateView()
    scrollContainer.addEventListener('scroll', handleScroll)

    let resizeObserver = null
    if (typeof ResizeObserver !== 'undefined') {
      resizeObserver = new ResizeObserver(() => refresh())
      resizeObserver.observe(scrollContainer)
    }
    // 滚动到底部 - 修复
    const scrollToBottom = () => {
      // 计算最大滚动距离
      const maxScrollTop = originData.length * config.rowHeight - scrollContainer.clientHeight
      scrollContainer.scrollTop = Math.max(0, maxScrollTop)
    }

    // 滚动到顶部 - 修复
    const scrollToTop = () => {
      scrollContainer.scrollTop = 0
    }
    // 暴露方法
    el._virtualScrollRefresh = refresh
    el._virtualScrollToRow = scrollToRow
    el._virtualScrollSelectAll = selectAll
    el._virtualScrollClearAll = clearAll
    el._virtualScrollToBottom = scrollToBottom
    el._virtualScrollToTop = scrollToTop

    el._cleanup = () => {
      if (originToggleRowSelection) tableInstance.toggleRowSelection = originToggleRowSelection
      if (el._stopWatch) el._stopWatch()
      scrollContainer.removeEventListener('scroll', handleScroll)
      resizeObserver?.disconnect()
      if (rafId) cancelAnimationFrame(rafId)
    }
  },

  unmounted(el) {
    el._cleanup?.()
  },
}

export default virtualScrollDirective

更多推荐