Vue3+ElementUI Plus 利用el-table 做虚拟列表(仅供参考)

98 阅读3分钟

声明:概念代码,不完善,思路参考,请勿直接拿去用

最终效果:el-table一直只渲染一小段数据

屏幕截图 2025-09-05 135237.png

一、直接在页面组件写


    <div class="eltable-container">

        <el-table :data="virtualTableData" :style="virtualTableStyleObj" ref="mainTableRef" stripe border>

            <el-table-column type="index" label="序号" width="80">

                <!-- 在虚拟列表场景下,由于只渲染可视区域内的数据行,使用type="index"会导致序号从0开始重新计数。解决方案是自定义序号列,根据实际数据位置计算序号。 -->

                <template #default="{ $index }">

                    <span>{{ startIdx + $index + 1 }}</span>

                </template>

            </el-table-column>

            <el-table-column v-for="item in purchaseMainTableColumns" :prop="item.path" :label="item.label"

                :width="item.dynamicWidth" :fixed="item.fixed" :sortable="item.sortable">

            </el-table-column>

        </el-table>

    </div>

</template>

<script setup>

import { ref, reactive, onMounted,computed,nextTick   } from 'vue'

import PurchaseOrderAPI from '@/api/order/purchase/index.js' //数据源接口

import {

  purchaseMainTableColumns,

} from './config/tableConfig.js' //列配置

let virtualTableStyleObj = {

    height: '800px',

    width: '100%'

} //虚拟表格样式

const dataTotalCount = 1000 //数据源总数

let tableListData = reactive([]) //过渡数据源

let virtualTableData = ref([]) //数据源

let mainTableRef = ref(null) //表格实例

// 添加这些变量

const rowHeight = 50; // 实际行高

const bufferCount = 5; // 缓冲区行数

const visibleCount = ref(0); // 渲染行数

const startIdx = ref(0); // 起始索引

const placeholderHeight = computed(() => tableListData.length * rowHeight); // 占位高度(自定义滚动依赖于此高度)

const viewportHeight = 800;

onMounted(async() => {

    let apiData = await PurchaseOrderAPI.getPurchasePageList({page:{

        pageNumber: 1,

        pageSize: 1000,

        totalPage: -1,

        totalRow: -1

    }})

    tableListData.push(...apiData.data.records)

    // console.log(tableListData);

    // console.log(mainTableRef.value?.$refs);

    const scrollBarRef = mainTableRef.value?.$refs?.scrollBarRef;

    const wrapRef = scrollBarRef.wrapRef; // 获取真实滚动容器

    // console.log('wrapRef',wrapRef); //el-scrollbar__wrap el-scrollbar__wrap--hidden-default

    // 获取表格内容区域高度(视口高度)

    // const tableBodyWrapper = document.querySelector('.el-table__body-wrapper');

    // 直接获取表格的 body wrapper

    // 这里是 获取包裹table 的容器,目的是获取到一屏可用高度,计算实际渲染行数, .eltable-container 和  .el-table__body-wrapper 的height一样?

    // const tableBodyWrapper = mainTableRef.value?.$el?.querySelector('.el-table__body-wrapper');

    // if (!tableBodyWrapper) {

    //   console.error('无法找到表格滚动容器');

    //   return;

    // }

    // // console.log(tableBodyWrapper);

    // // debugger

    // const viewportHeight = tableBodyWrapper.clientHeight;

    // console.log(viewportHeight); // 800

    // 计算实际渲染行数

    visibleCount.value = Math.ceil(viewportHeight / rowHeight) + bufferCount;// 25 // Math.ceil 表示向上取整 比如 800/42 = 19.047619  向上取整 20

  
    // 创建占位元素(利用他滚动)

    const placeholder = document.createElement('div');

    placeholder.id = 'vheight';

    placeholder.style.height = `${tableListData.length * rowHeight}px`;

    wrapRef.append(placeholder);

    // 初始化响应式数据

    updateVirtualData();

    // 监听滚动事件 (为什么要 给wrapRef?)

    wrapRef.addEventListener('scroll', handleScroll);

})

let isScrolling = false;

let lastScrollTop = 0;

const handleScroll = (event) => {

// requestAnimationFrame(updateVirtualData)

//本次滚动方向 (也可以不用)

let scrollDirection = 'down';

const scrollTop = event.target.scrollTop; // Y方向滚动的数值

// console.log(scrollTop);

  if(scrollTop - lastScrollTop > 0){

    // console.log('向下滚动');

    scrollDirection = 'down';

    if(scrollTop >= (dataTotalCount * 50 - 15 * 50))return  //(dataTotalCount * 50 - 15 * 50) //保险起见 放到向下滚动这里 209744这个数值代表 滚动到底部边界 是为了解决一下子滚动到最底部不停抽搐的bug

    //这里确实解决了 抽搐问题,但是底部有一段空白 有时空白比较大 ,反正也不是大问题 暂时搁置

  }

  else if(scrollTop - lastScrollTop < 0){

    // console.log('向上滚动');

    scrollDirection = 'up';

  }

  // 如果滚动位置未变化,跳过处理

  if (scrollTop === lastScrollTop) return;

  lastScrollTop = scrollTop;

  // 如果已经有动画帧请求,跳过

  if (isScrolling) return;

  isScrolling = true;

  // 使用 requestAnimationFrame 确保在浏览器重绘前执行

  requestAnimationFrame(() => {

    // 计算新的起始索引

    const newStartIdx = Math.floor(scrollTop / rowHeight);

    // console.log('newStartIdx', newStartIdx);

    // 只有当索引变化超过阈值时才更新

    if (Math.abs(newStartIdx - startIdx.value) > 1) {

      startIdx.value = newStartIdx;

      updateVirtualData();

    }

    // 定位表格体

    const tableBody = event.target.querySelector('.el-table__body');

    if (tableBody) {

      tableBody.style.transform = `translateY(${startIdx.value * rowHeight}px)`;  //这个相当于手动 滚动表体到 指定位置

    }

    isScrolling = false;

  });

};

// 从总数据源截取要渲染的数据

const updateVirtualData = () => {

  virtualTableData.value = tableListData.slice(

    startIdx.value,

    startIdx.value + visibleCount.value

  );

};

</script>

<style scoped lang='scss'>

</style>

二、组合式API + 组件封装

useVirtualTable.js


import { ref, computed } from 'vue'

export function useVirtualList(options) {

  const startIdx = ref(0)

  const visibleCount = computed(() => {

    return Math.ceil(options.containerHeight / options.rowHeight) + 5

  })

  const virtualData = ref([])

  const updateVirtualData = (newStartIdx = startIdx.value) => {

    if (!options.sourceData.value) return

    startIdx.value = newStartIdx

    const endIdx = Math.min(

      startIdx.value + visibleCount.value,

      options.sourceData.value.length

    )

    virtualData.value = options.sourceData.value.slice(

      startIdx.value,

      endIdx

    )

  }

  return {

    virtualData,

    startIdx,

    visibleCount,

    updateVirtualData

  }

}

VirtualTable.vue


  <div class="virtual-table-container">

    <el-table ref="mainTableRef" :data="virtualData" :style="{ height: containerHeight + 'px' }">

      <el-table-column type="index" label="序号" width="80">

        <!-- 在虚拟列表场景下,由于只渲染可视区域内的数据行,使用type="index"会导致序号从0开始重新计数。解决方案是自定义序号列,根据实际数据位置计算序号。 -->

        <template #default="{ $index }">

          <span>{{ startIdx + $index + 1 }}</span>

        </template>

      </el-table-column>

  


      <!-- 动态列渲染 -->

      <el-table-column v-for="col in dynamicColumns" :key="col.path" :prop="col.path" :label="col.label"

        :width="col.dynamicWidth" />

    </el-table>

  </div>

</template>

  


<script setup>

import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'

import { useVirtualList } from '@/utils/useVirtualTable.js'

  


const props = defineProps({

  sourceData: Array,        // 原始数据源

  dynamicColumns: Array,   // 动态列配置

  containerHeight: {       // 容器高度

    type: Number,

    default: 800

  },

  rowHeight: {              // 行高配置

    type: Number,

    default: 50

  }

})

  


const mainTableRef = ref(null)

const scrollContainer = computed(() => {

  return mainTableRef.value?.$el.querySelector('.el-table__body-wrapper')

})

  


// 创建虚拟列表实例

const { virtualData, startIdx, visibleCount, updateVirtualData } = useVirtualList({

  sourceData: computed(() => props.sourceData),

  containerHeight: props.containerHeight,

  rowHeight: props.rowHeight,

  containerRef: scrollContainer

})

  


console.log(virtualData, startIdx);

  


// 监听数据源变化

watch(props.sourceData, (newVal) => {

  


  // debugger

  if (newVal && newVal.length) {

    updateVirtualData(0)

    const scrollBarRef = mainTableRef.value?.$refs?.scrollBarRef;

    const wrapRef = scrollBarRef.wrapRef; //

  


    // const viewportHeight = 800;

  


    // // 计算实际渲染行数

    // visibleCount.value = Math.ceil(viewportHeight / rowHeight) + bufferCount;// 25 // Math.ceil 表示向上取整 比如 800/42 = 19.047619  向上取整 20

  


    // 创建占位元素

    const placeholder = document.createElement('div');

    placeholder.id = 'vheight';

    placeholder.style.height = `${1000 * rowHeight}px`;

    wrapRef.append(placeholder);

  


    // 监听滚动事件 (为什么要 给wrapRef?)

    wrapRef.addEventListener('scroll', handleScroll);

    // console.log(virtualData,props.dynamicColumns);

  


  }

}, { immediate: true })

  
  


let isScrolling = false;

let lastScrollTop = 0;

const dataTotalCount = 1000

const rowHeight = 50; // 实际行高

const bufferCount = 5; // 缓冲区行数

// 处理滚动事件

const handleScroll = (event) => {

  


  // debugger

  // if (!scrollContainer.value) return

  


  // const scrollTop = scrollContainer.value.scrollTop

  // const newStartIdx = Math.floor(scrollTop / props.rowHeight)

  


  // // 避免频繁更新

  // if (Math.abs(newStartIdx - startIdx.value) > 5) {

  //   startIdx.value = newStartIdx

  //   updateVirtualData()

  // }

  


  // // 同步表格位置

  // const tableBody = scrollContainer.value.querySelector('.el-table__body')

  // if (tableBody) {

  //   tableBody.style.transform = `translateY(${startIdx.value * props.rowHeight}px)`

  // }

  
  
  


  //本次滚动方向 (也可以不用)

  let scrollDirection = 'down';

  


  const scrollTop = event.target.scrollTop; // Y滚动的数值

  console.log(scrollTop);

  
  
  


  if (scrollTop - lastScrollTop > 0) {

    console.log('向下滚动');

    scrollDirection = 'down';

    if (scrollTop >= (dataTotalCount * 50 - 15 * 50)) return  //(dataTotalCount * 50 - 15 * 50) //保险起见 放到向下滚动这里 209744这个数值代表 滚动到底部边界 是为了解决一下子滚动到最底部不停抽搐的bug

    //这里确实解决了 抽搐问题,但是底部有一段空白 有时空白比较大 ,反正也不是大问题 暂时搁置

  }

  else if (scrollTop - lastScrollTop < 0) {

    console.log('向上滚动');

    scrollDirection = 'up';

  }

  // 如果滚动位置未变化,跳过处理

  if (scrollTop === lastScrollTop) return;

  


  lastScrollTop = scrollTop;

  


  // 如果已经有动画帧请求,跳过

  if (isScrolling) return;

  


  isScrolling = true;

  


  // 使用 requestAnimationFrame 确保在浏览器重绘前执行

  requestAnimationFrame(() => {

  


    // 计算新的起始索引

    const newStartIdx = Math.floor(scrollTop / rowHeight);

    // console.log('newStartIdx', newStartIdx);

  


    // 只有当索引变化超过阈值时才更新

    if (Math.abs(newStartIdx - startIdx.value) > 1) {

      startIdx.value = newStartIdx;

      updateVirtualData();

    }

  


    // 定位表格体

    const tableBody = event.target.querySelector('.el-table__body');

    if (tableBody) {

      tableBody.style.transform = `translateY(${startIdx.value * rowHeight}px)`;  //这个相当于手动 滚动表体到 指定位置

    }

  


    isScrolling = false;

  });

}

  


onMounted(async () => {

  


  await nextTick();

  


})

  


// 清理事件监听

onBeforeUnmount(() => {

  if (mainTableRef.value) {

    const scrollBarRef = mainTableRef.value?.$refs?.scrollBarRef;

    const wrapRef = scrollBarRef.wrapRef; // 获取真实滚动容器

    wrapRef.removeEventListener('scroll', handleScroll)

  }

})

</script>

testVirtualTable.vue

<template>

   <VirtualTable

    :source-data="purchaseOrderData"

    :dynamic-columns="purchaseMainTableColumns"

    :container-height="800"

    :row-height="50"

  />
</template>

<script setup>

import { ref, reactive, onMounted,computed,nextTick   } from 'vue'

import PurchaseOrderAPI from '@/api/order/purchase/index.js' //接口

import VirtualTable from '@/components/base-ui/virtual-table/VirtualTable.vue' // 虚拟表格封装

import {

  purchaseMainTableColumns,

} from './config/tableConfig.js' //列配置

let purchaseOrderData = reactive([]) // 虚拟表格数据源

onMounted(async() => {

  //请求列表数据

    let apiData = await PurchaseOrderAPI.getPurchasePageList({page:{

        pageNumber: 1,

        pageSize: 1000,

        totalPage: -1,

        totalRow: -1

    }})

    // 数据源赋值

    purchaseOrderData.push(...apiData.data.records)

})

</script>

<style scoped lang='scss'>

</style>