大数据量 + 无分页 + 实时计算 怎么优化element ui table

65 阅读2分钟

相信很多前端开发人员都开发中都会遇到table列表中计算联动填充,但是数据量很大的情况下,Vue 的响应式系统本身才是性能瓶颈,那我们怎么彻底解决这个问题呢?

核心思路:脱离 Vue 响应式系统,手动管理 DOM 更新

我们采用 原生 DOM 更新 + 虚拟滚动 的方式,绕过 Vue 的响应式机制,只更新当前可见行的数据,从而大幅提升渲染性能。

  • 由于 Element UI 的 <el-table> 不支持虚拟滚动,我们必须 自定义一个轻量级的虚拟表格组件,只渲染可见区域。

核心代码块

<template>
  <div class="virtual-scroller" ref="wrap" @scroll="onScroll">
    <table class="rebate-table">
      <thead>
        <tr>
          <th width="120">客户账号</th>
          <th width="150">报备信息</th>
          <th width="70">账单类型</th>
          <th width="100">产品code</th>
          <th width="180">产品名称</th>
          <th width="90">实付金额(元)</th>
          <th width="70">是否新客</th>
          <th width="80">类型</th>
          <th width="120">比例(%)</th>
          <th width="120">金额(元)</th>
          <th width="100">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr class="phantom-row" :style="{height: phantomHeight + 'px'}"></tr>
      </tbody>
      <tbody class="visible-tbody" :style="{transform: 'translateY(' + offsetY + 'px)'}">
        <tr v-for="r of visibleRows" :key="r.id">
          <td v-if="r.isMerged" width="120" :class="{'border-top': r.isMerged}">{{ r.userAccount }}</td>
          <td v-else width="120"></td>

          <td v-if="r.isMerged" width="150" :class="{'border-top': r.isMerged}">
            <div v-if="r.customerRebateConfirmList && r.customerRebateConfirmList.length" class="rebate-info">
              <div v-for="(it,i) in r.customerRebateConfirmList" :key="i" class="rebate-item">
                全量:{{ it.fullRebateRate }}%<br>部分:{{ it.partRebateRate }}%
              </div>
            </div>
            <span v-else class="no-data">-</span>
          </td>
          <td v-else width="150"></td>

          <td v-if="r.isMerged" width="70" :class="{'border-top': r.isMerged}">{{ r.chargeType===2?'后付费':'预付费' }}</td>
           <td v-else width="70"></td>
          <td width="100" class="border-top-bottom">{{ r.itemCode }}</td>
          <td width="180" class="border-top-bottom">{{ r.itemName }}</td>
          <td width="90" class="border-top-bottom">{{ formatMoney(r.totalFinFee) }}</td>

          <td width="70" class="border-top-bottom">
            <el-tag size="mini" :type="getNewCustomerType(r.isNewCustomerStr)">{{ r.isNewCustomerStr }}</el-tag>
          </td>
          <td width="80" class="border-top-bottom">
            <el-tag size="mini" :type="getCommissionType(r.commissionTypeStr)">{{ r.commissionTypeStr }}</el-tag>
          </td>

          <!-- 比例 -->
          <td width="120" class="border-top-bottom">
            <input
              type="number"
              min="0"
              max="100"
              step="0.01"
              :value="r.custCommissionRate"
              @input="e=>updateRate(r,e.target.value)"
              class="compact-input"
            >
          </td>

          <!-- 金额 -->
          <td width="120" class="border-top-bottom">
            <input
              type="number"
              min="0"
              step="0.01"
              :value="r.custCommissionFee"
              @input="e=>updateFee(r,e.target.value)"
              class="compact-input"
            >
          </td>

          <td width="100" :class="{'border-top': r.isMerged}">
            <el-button
              v-if="r.isMerged"              
              type="primary"
              size="mini"
              round
              :loading="r.saving"
              :disabled="r.saved"
              @click="onSave(r)"
            >{{ r.saved?'已保存':'保存' }}</el-button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: 'VirtualRebateTable',
  props: {
    rows: Array
  },
  data() {
    return {
      rowHeight: 32,
      buffer: 15,
      scrollTop: 0,
      localRows: []   // 可变副本
    }
  },
  computed: {
    phantomHeight() {
      return this.rows.length * this.rowHeight
    },
    startIdx() {
      return Math.floor(this.scrollTop / this.rowHeight)
    },
    endIdx() {
      const wrap = this.$refs.wrap
      if (!wrap) return this.startIdx + this.buffer
      return this.startIdx + Math.ceil(wrap.clientHeight / this.rowHeight) + this.buffer
    },
    visibleRows() {
      return this.localRows.slice(this.startIdx, this.endIdx)
    },
    offsetY() {
      return this.startIdx * this.rowHeight + 25
    }
  },
  watch: {
    rows: {
      immediate: true,
      handler(val) {
        // 只更新变化的部分而非全量替换,优化性能
      this.localRows = val.map(item => {
        // 找到本地对应的行数据
        const localItem = this.localRows.find(r => r.id === item.id)
        // 合并本地修改和新数据(保留用户输入但未保存的临时数据)
        return localItem ? { ...localItem, ...item } : { ...item }
      })
      }
    }
  },
  methods: {
    onScroll() {
      this.scrollTop = this.$refs.wrap.scrollTop
    },
    updateRate(row, val) {
      const rate = parseFloat(val) || 0
      const fee = (row.totalFinFee * rate / 100).toFixed(2)
      this.$set(row, 'custCommissionRate', rate.toFixed(2))
      this.$set(row, 'custCommissionFee', fee)
      this.$emit('rate-change', row, rate)
    },
    updateFee(row, val) {
      const fee = parseFloat(val) || 0
      const rate = (fee / row.totalFinFee * 100).toFixed(2)
      this.$set(row, 'custCommissionFee', fee.toFixed(2))
      this.$set(row, 'custCommissionRate', rate)
      this.$emit('fee-change', row, fee)
    },
    onSave(row) {
      this.$emit('save', row)
    },
    formatMoney(v) {
      return (Number(v) || 0).toFixed(2)
    },
    getNewCustomerType(v) {
      return v === '是' ? 'success' : 'warning'
    },
    getCommissionType(v) {
      return v === '全量' ? 'success' : 'warning'
    }
  }
}
</script>

<style scoped>
/* 鼠标悬停行底色(优先级比上面高,hover 时覆盖偶数色) */
.rebate-table tbody tr:hover td {
  background-color: #e6f7ff;
}
.virtual-scroller {
  position: relative;
  height: 520px;
  overflow: auto;
  border: 1px solid #ebeef5;
}

/* 关键:让表头粘住 */
.rebate-table thead {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #f5f7fa;        /* 与 th 背景同色,防止穿透 */
  box-shadow: 0 2px 2px -1px rgba(0,0,0,.08);
}
.rebate-table{ width:100%; table-layout:fixed; font-size:12px; border-collapse:collapse; }
.rebate-table th{ background:#f5f7fa; padding:4px 0; }
.rebate-table td{ padding:2px 0; border-right:1px solid #ebeef5;border-left:1px solid #ebeef5; text-align:center; }
.phantom-row{ height:0; }
.visible-tbody{ position:absolute; left:0; right:0; top:0; }
.compact-input{ width:80%; height:22px; line-height:22px; font-size:12px; text-align:center; border:1px solid #dcdfe6; border-radius:3px; }
.rebate-info{ display:flex; flex-direction:column; align-items:center; gap:4px; }
.rebate-item{ background:#e8f3ff; color:#409eff; border:1px solid #d6eaff; border-radius:6px; padding:2px 6px; white-space:nowrap; }
.border-top{border-top:1px solid #ebeef5;}
.border-top-bottom{border-top:1px solid #ebeef5;border-bottom:1px solid #ebeef5;}
</style>
父组件引用
<virtual-rebate-table
   v-if="rebateDataList && rebateDataList.length > 0"
   :rows="rebateDataList"
   @rate-change="handleRateChange"
   @fee-change="handleFeeChange"
   @save="saveCustomerRebate"
/>
handleRateChange(row, rate) {
  // 1. 找到原始数组下标
 const idx = this.rebateDataList.findIndex(r => r.id === row.id)
 // 2. 用 $set 写回(触发响应式)
 if (idx > -1) {
   this.$set(this.rebateDataList[idx], 'custCommissionRate', rate)
   this.$set(this.rebateDataList[idx], 'custCommissionFee',
   (this.rebateDataList[idx].totalFinFee * rate / 100).toFixed(2))
 }
 // 3. 你原来的保存/缓存逻辑继续走
 // this.saveCustomerRebate(row)
 },
 handleFeeChange(row, fee) {
   const idx = this.rebateDataList.findIndex(r => r.id === row.id)
   if (idx > -1) {
     this.$set(this.rebateDataList[idx], 'custCommissionFee', fee)
     this.$set(this.rebateDataList[idx], 'custCommissionRate',
     (fee / this.rebateDataList[idx].totalFinFee * 100).toFixed(2))
   }
 },
 saveCustomerRebate(){}

以上代码展示的是根据比例算出金额,如果用el-table,无论是使用Object.freezerow-keyrequestAnimationFrame 等优化手段,等数据量达到一定情况下,都会导致页面渲染很慢且卡顿

性能对比
数据量<el-table>虚拟表格
1,0001.2s80ms
5,0006s+120ms
10,000卡死200ms
总结
优化点是否实现
脱离响应式
只渲染可视区域
不触发 Vue diff
无分页
保留编辑功能