el-table虚拟列表封装

框架:vue2

UI 库:Element UI

背景

商品编辑规格部分,规格项和规格值通过笛卡尔积运算之后会生产 sku 表格,因为笛卡尔积是每组规格值的相乘计算,所以很容易产生大数据量的表格;经测试,Element UI 的表格在商品编辑页面中(固定列数)展示超过 100 条以上,页面就会有明显的卡顿;我们的客户有做图书类目的,规格数目有超过 700 条、1000 条的,进入编辑页面直接卡死;所以需要优化此部分性能;

虚拟列表优化方案确定

虚拟列表最终确定实现方案为:改造 Element UI 的表格

实现思路

思路: 固定高度的表格区域内,写一个滚动条,每一行固定高度,通过表格数量*每一行的高度就能算出来这个滚动条应该是什么长度;监听滚动条的滚动距离就能算出来当前表格应该展示第 n 到 m 行数据;然后数据驱动视图渲染;

表格部分分为 2 个元素,表格元素和滚动条元素

  1. 表格元素和滚动条元素一样大小区域,层级为上下级关系,上下布局:表格覆盖滚动条,或者滚动条覆盖表格
    1. 弊端:表格区域无法点击 (内部表单元素无法聚焦),或者滚动条无法聚焦拖动
  1. 表格元素和滚动条元素同高不同宽,同层级,左右布局
    1. 弊端:触摸板滚动在表格区域无法滚动

表格部分 1 个元素,借用表格原来的滚动条,❓如何撑起来表格原声的滚动条呢?

  1. 表格行首加一个占位元素,给这个元素设置表格该有的高度(行高 * 行数 - 表格实际展示行数 * 行高)
    1. 弊端:实际展示表格的区域被挤下去了,页面中看不到
  1. 表格行尾加一个占位元素,给这个元素设置表格该有的高度(行高 * 行数 - 表格实际展示行数 * 行高)
    1. 弊端:实际展示表格的区域被挤上去了,页面中看不到

表格中行首、行尾各加一个占位元素,两个元素高度之和 = 行高 * 行数 - 表格实际展示行数 * 行高

初始化状态:行首高度为 0

滚动时:动态计算高度,分别赋高度给行首占位元素和行尾占位元素

滚动条滚动到底时:尾部元素高度为 0

代码实现

1. 流程图:

2. 核心方法执行顺序:

初始化阶段 mounted() → initTable() → updateData(0) → setTableHeight()

滚动更新阶段 handleScroll() → updateIndex() → initTable() → updateData() → 智能合并数据 → setTableHeight()

数据更新阶段 watch:data() → initTable() → 重新计算渲染范围

激活状态同步 eventBus.$on('changeIndex') ←→ updateData()中的索引边界检查

3. 关键算法说明:

智能数据合并:当新旧数据范围有重叠时,采用slice+concat方式保留重叠区域DOM,避免重新渲染

激活行同步:通过eventBus在currentRealActiveIndex超出渲染范围时重置激活状态

滚动节流:使用requestAnimationFrame实现滚动事件节流,确保16ms触发一次更新

kotlin 复制代码
<template>
  <el-table
    ref="vTable"
    :data="vData"
    :max-height="height"
    class="v-table"
    v-bind="$attrs">
    <slot name="default"></slot>
    <template #append>
      <slot name="append"></slot>
    </template>
  </el-table>
</template>

<script>
import Vue from 'vue'

export default {
  name: 'ElVTable',
  props: {
    height: {
      type: Number,
    },
    data: Array,
    useVitrual: {
      type: Boolean,
      default: false
    },
    itemHeight: {
      required: true,
      type: Number,
    }
  },
  data () {
    return {
      topSit: null,
      bottomSit: null,
      vData: [],
      vIndex: 0,
      ticking: false,
      scrollLis: null,
      preStart: -1,
      preEnd: -1,
      currentRealActiveIndex: -1,
      eventBus: null,
    }
  },
  provide () {
    return {
      getCurrentIndex: (index) => this.getCurrentIndex(index),
      itemHeight: this.itemHeight,
      eventBus: () => this.eventBus,
    }
  },
  created () {
    this.eventBus = new Vue()
    this.eventBus.$on('changeIndex', data => {
      this.currentRealActiveIndex = data
    })
  },
  mounted () {
    if (this.height && this.useVitrual) {
      this.initTable()
      this.initScroll()
    }
  },
  beforeDestroy () {
    this.clearScroll()
  },
  watch: {
    data (val) {
      if (val && this.height && this.useVitrual) {
        this.initTable()
      } else {
        this.vData = val
      }
    }
  },
  computed: {},
  methods: {
    getTable () {
      return this.$refs.vTable
    },
    handleScroll () {
      if (!this.ticking) {
        requestAnimationFrame(() => {
          this.updateIndex()
          this.ticking = false
        })
        this.ticking = true
      }
    },
    initScroll () {
      const scrollWrapper = this.$refs.vTable?.$refs?.bodyWrapper
      if (scrollWrapper) {
        this.scrollLis = scrollWrapper.addEventListener('scroll', this.handleScroll)
      }
    },
    clearScroll () {
      const scrollWrapper = this.$refs.vTable.$refs.bodyWrapper
      scrollWrapper.removeEventListener('scroll', this.handleScroll)
    },
    setTableHeight (start, end) {
      const tableBody = this.$refs.vTable.$children.find(item => item.$options.name === 'ElTableBody')
      if (!this.topSit) {
        this.topSit = document.createElement('div')
        tableBody.$el.insertBefore(this.topSit, tableBody.$el.children[0])
      }
      if (!this.bottomSit) {
        this.bottomSit = document.createElement('div')
        tableBody.$el.appendChild(this.bottomSit)
      }
      this.topSit.style.height = start === 0 ? '0px' : (start) * this.itemHeight + 'px'
      this.bottomSit.style.height = end >= this.data.length ? '0px' : (this.data.length - end) * this.itemHeight + 'px'
    },
    updateIndex () {
      const bodyWrapper = this.$refs.vTable.$refs.bodyWrapper
      const scrollTop = bodyWrapper.scrollTop
      this.vIndex = Math.floor(scrollTop / this.itemHeight)
      this.initTable()
    },
    initTable () {
      const {
        start,
        end
      } = this.updateData(this.vIndex)
      this.setTableHeight(start, end)
    },
    updateData (index) {
      const visibleItems = Math.ceil(this.height / this.itemHeight) // 计算可见行数
      const buffer = 3 // 滚动缓冲区

      // 计算新的起止索引(带缓冲区)
      let start = Math.max(0, index - buffer)
      if (start > 0 && start >= this.data.length - visibleItems - 2 * buffer) { // buffer * 2 判断的是滚动到底部之后可能会往回滚动
        start = this.data.length - visibleItems - 2 * buffer
      }
      const end = Math.min(
        this.data.length,
        index + visibleItems + buffer
      )

      // 如果范围没有变化则跳过更新
      if (this.preStart === start && this.preEnd === end) {
        return {
          start,
          end
        }
      }

      // 智能更新数据逻辑
      if (this.preStart !== -1 && this.preEnd !== -1) {
        if (start >= this.preStart && end <= this.preEnd) { // 情况1:新范围在旧范围内
          // 不需要更新数据
        } else if (start <= this.preEnd && end >= this.preStart) { // 情况2:有部分重叠
          const overlapStart = Math.max(start, this.preStart) // 重叠区起始索引
          const overlapEnd = Math.min(end, this.preEnd) // 重叠区结束索引
          // 获取需要新增的前部数据(旧范围之前的新数据)
          const newFrontData = this.data.slice(start, overlapStart)
          // 获取需要新增的尾部数据(旧范围之后的新数据)
          const newTailData = this.data.slice(overlapEnd, end)
          this.vData = [
            ...newFrontData, // 新增前部数据
            ...this.vData.slice( // 保留重叠区域现有数据
              overlapStart - this.preStart, // 计算重叠区在现有数据中的起始偏移
              overlapEnd - this.preStart // 计算重叠区在现有数据中的结束偏移
            ),
            ...newTailData // 新增尾部数据
          ]
        } else { // 情况3:完全无重叠
          this.vData = this.data.slice(start, end)
        }
      } else {
        this.vData = this.data.slice(start, end)
      }

      this.$nextTick(() => {
        this.$emit('updateData', {
          start,
          end
        })
      })

      // 更新索引并重置激活状态
      this.preStart = start
      this.preEnd = end

      if (this.currentRealActiveIndex < start || this.currentRealActiveIndex >= end) {
        this.currentRealActiveIndex = -1
        this.eventBus.$emit('changeIndex', -1)
      }

      return {
        start,
        end
      }
    },
    getCurrentIndex (index) {
      return index + this.preStart
    },
  }
}

</script>

<style scoped>
/* 修正后的选择器写法 */
::v-deep .el-table__body-wrapper::-webkit-scrollbar {
  width: 12px; /* 纵向滚动条宽度 */
  height: 12px; /* 横向滚动条高度 */
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-track {
  background: #f5f5f5;
  border-radius: 6px;
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 6px;
}

::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}

</style>

4. 实际落地后白屏严重

由于直接拖动滚动条后,数据重新渲染,而且每一行表单元素较多,表单元素(如输入框、下拉菜单、按钮等)需要处理用户输入、焦点状态、验证规则等动态交互,浏览器需为其构建更复杂的事件监听机制

所以有如果直接拖动滚动条,数据没有重叠的时候就会产生白屏;

解决方案: 初次渲染为只读元素,鼠标浮入变表单元素

kotlin 复制代码
<template>
  <el-table-column v-bind="$attrs">
    <template #header="{row, $index}">
      <slot :$index="$index" :row="row" name="header">
        {{ $attrs.label }}
      </slot>
    </template>
    <template slot-scope="{row,$index}">
      <div :style="{height: (itemHeight - 17) + 'px'}" class="detail-cell" @mouseenter="handleShowEdit(row,$index)">
        <slot :$index="$index" :isEditing="isEditing($index)" :row="row">
          {{ row[$attrs.prop] }}
        </slot>
      </div>
    </template>
  </el-table-column>
</template>

<script>

export default {
  name: 'el-v-column',
  props: {
    useVirtualDetail: {
      type: Boolean,
      default: true
    }
  },
  inject: ['getCurrentIndex', 'itemHeight', 'eventBus'],
  data () {
    return {
      init: false,
      currentActiveIndex: -1, // 记录虚拟表格中的索引位置
      currentRealActiveIndex: -1, // 用于记录当前真正的索引位置
      currentActiveEl: null
    }
  },
  computed: {
    isEditing () {
      return (index) => {
        return this.currentRealActiveIndex === this.getCurrentIndex(0) + index
      }
    }
  },
  mounted () {
    this.eventBus().$on('changeIndex', (data) => {
      if (this.currentActiveEl && data === -1) {
        this.removeElListener(this.currentActiveEl)
      }
      this.currentRealActiveIndex = data
    })
  },
  methods: {
    handleShowEdit (row, index) {
      this.bindClickOutside(row, index)
    },
    getTableChildren (table, index) {
      const tableBody = table.$children.find(item => item.$options.name === 'ElTableBody')
      const rowList = tableBody.$children.filter(item => item.$options.name === 'ElTableRow')
      return rowList.find(item => item.index === index)
    },
    getParentTable () {
      let parent = this.$parent
      while (parent.$options.name !== 'ElTable') {
        parent = parent.$parent
      }
      return parent
    },
    getCurrentEl (index) {
      const table = this.getParentTable()
      const currentRow = this.getTableChildren(table, index)
      return currentRow.$el
    },
    bindClickOutside (row, index) {
      const el = this.getCurrentEl(index)

      const isSameCell = this.currentRealActiveIndex === this.getCurrentIndex(0) + index

      if (isSameCell) return

      // 不匹配时 清空上一个元素的事件
      if (!isSameCell && this.currentActiveIndex !== -1) {
        this.currentActiveIndex = -1
        this.removeElListener(this.currentActiveEl)
      }

      const _this = this
      this.init = false
      this.currentActiveIndex = index
      this.currentRealActiveIndex = this.getCurrentIndex(this.currentActiveIndex)
      this.eventBus().$emit('changeIndex', this.currentRealActiveIndex)
      // this.currentActiveEl = el
      // // 在元素上绑定一个点击事件监听器
      // el.clickOutsideEvent = function (event) {
      //   if (!(_this.currentActiveEl === event.target || _this.currentActiveEl?.contains(event.target)) && _this.init) {
      //     _this.currentRealActiveIndex = -1
      //     _this.currentActiveIndex = -1
      //     _this.removeElListener(el)
      //     _this.init = false
      //
      //     _this.eventBus().$emit('changeIndex', -1)
      //   }
      //   // 若点击前后为不同cell 或者没有绑定cell  则初始化点击事件
      //
      //   if (!_this.init && (!isSameCell || _this.currentActiveIndex === -1)) {
      //     _this.init = true
      //   }
      // }
      // // 在文档上添加点击事件监听器
      // document.addEventListener("click", el.clickOutsideEvent)
    },
    removeElListener () {
      if (this.currentActiveEl) {
        document.removeEventListener("click", this.currentActiveEl.clickOutsideEvent)
        this.currentActiveEl.clickOutsideEvent = null
      }

      this.currentActiveEl = null
    },

  },

  beforeDestroy () {
    if (this.currentActiveEl) {
      document.removeEventListener("click", this.currentActiveEl.clickOutsideEvent)
    }
    this.eventBus().$off('changeIndex')
  },
}

</script>
<style>
.detail-cell {
  overflow-y: auto;
  display: flex;
  align-items: center;
}

</style>

数据校验

原有 以 实际 dom 元素为基准 触发的数据校验机制已经无法满足我们的场景了;此时得换用以数据为基准触发校验了;

1. 数据整体保存时校验,

告知用户第几行有问题

javascript 复制代码
validTableData (tableData) {
  return new Promise((resolve, reject) => {
    const errors = []
    const errorsRowIndex = []
    // 遍历所有SKU项
    tableData?.forEach((row, index) => {
      // 校验SKU编码
      this.validPrdSnRules(null, row.prdSn, (error) => {
        error && errors.push(`第${index + 1}行SKU编码: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)

      // 校验库存
      this.validatePrdNumber(null, row.prdNumber, (error) => {
        error && errors.push(`第${index + 1}行库存: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)

      // 校验价格
      this.validatePrdPrice(null, row.prdPrice, (error) => {
        error && errors.push(`第${index + 1}行价格: ${error.message}`)
        error && errorsRowIndex.push(index + 1)
      }, row)
    })

    // 返回校验结果
    resolve({
      type: errors.length > 0 ? 'error' : 'success',
      data: errors,
      message: errors.length > 0 ? `请完善如下行规格信息:  ${[...new Set(errorsRowIndex)].join(',    ')}` : ''
    })
  })
}

2. 表格滑动时校验

标红处理每一行的报错明细

xml 复制代码
<template>
  <elVTable
    :key="tableKey"
    ref="multiSpecTable"
    :data="showTableData"
    :height="600"
    :itemHeight="108"
    row-key="prdDesc"
    border
    header-row-class-name="tableClss"
    useVitrual
    @updateData="handleUpdateData"
  ></elVTable>
</template>
<script>
  export default {
    data() {
      return {
        debouncedValidate: null
      }
    },
    mounted(){
      // 创建防抖校验方法
      this.debouncedValidate = _.debounce((indexArray) => {
        this.$nextTick(() => {
          indexArray.forEach(item => {
            this.formRef.validateField(`goodsPublishProduct.${item}.prdNumber`)
            this.formRef.validateField(`goodsPublishProduct.${item}.prdPrice`)
          })
        })
      }, 1000)
    },
    methods:{
      createRangeArray (start, end) {
        return Array.from({ length: end - start + 1 }, (_, i) => start + i)
      },
      
      handleUpdateData ({start,end}) {
        const indexArray = this.createRangeArray(start, end)
        // 使用预先创建的防抖方法
        this.debouncedValidate(indexArray)
      },
    }
  }

</script>

效果预览

相关推荐
Komorebi゛几秒前
【CSS】圆锥渐变流光效果边框样式实现
前端·css
工藤学编程13 分钟前
零基础学AI大模型之CoT思维链和ReAct推理行动
前端·人工智能·react.js
徐同保13 分钟前
上传文件,在前端用 pdf.js 提取 上传的pdf文件中的图片
前端·javascript·pdf
怕浪猫14 分钟前
React从入门到出门第四章 组件通讯与全局状态管理
前端·javascript·react.js
欧阳天风22 分钟前
用setTimeout代替setInterval
开发语言·前端·javascript
EndingCoder25 分钟前
箭头函数和 this 绑定
linux·前端·javascript·typescript
郑州光合科技余经理26 分钟前
架构解析:同城本地生活服务o2o平台海外版
大数据·开发语言·前端·人工智能·架构·php·生活
沐墨染28 分钟前
大型数据分析组件前端实践:多维度检索与实时交互设计
前端·elementui·数据挖掘·数据分析·vue·交互
xkxnq31 分钟前
第一阶段:Vue 基础入门(第 11 天)
前端·javascript·vue.js
lifejump32 分钟前
Pikachu | Unsafe Filedownload
前端·web安全·网络安全·安全性测试