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>

效果预览

相关推荐
小鱼小鱼干几秒前
【Tauri】Tauri中Channel的使用
前端
拾光拾趣录3 分钟前
CSS全面指南:从基础布局到高级技巧与实践
前端·css
南屿im6 分钟前
基于 Promise 封装 Ajax 请求:从 XMLHttpRequest 到现代化异步处理
前端·javascript
青松学前端6 分钟前
vue-2.7源码解读之初始化流程和响应式实现
前端·vue.js·前端框架
杨进军7 分钟前
前端线上问题的那些事儿
前端·javascript·前端框架
每天开心9 分钟前
深入探索 React Hooks: useState 与 useEffect 的力量 🌟
前端·javascript·ai编程
流星稍逝11 分钟前
Vue3 + Uniapp 图片压缩公共方法封装
前端·vue.js
受之以蒙11 分钟前
Rust & WASM 之 wasm-bindgen 基础:让 Rust 与 JavaScript 无缝对话
前端·笔记·rust
中微子12 分钟前
React Props 传值规范详解
前端·react.js
namehu12 分钟前
Taro 小程序 Video 组件 referrer-policy="origin" 属性失效排查记
前端·taro