相信很多前端开发人员都开发中都会遇到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.freeze、row-key、requestAnimationFrame 等优化手段,等数据量达到一定情况下,都会导致页面渲染很慢且卡顿
性能对比
| 数据量 | 原 <el-table> | 虚拟表格 |
|---|---|---|
| 1,000 | 1.2s | 80ms |
| 5,000 | 6s+ | 120ms |
| 10,000 | 卡死 | 200ms |
总结
| 优化点 | 是否实现 |
|---|---|
| 脱离响应式 | ✅ |
| 只渲染可视区域 | ✅ |
| 不触发 Vue diff | ✅ |
| 无分页 | ✅ |
| 保留编辑功能 | ✅ |
- 如果你仍想用 Element UI,可以考虑 element-plus.org/en-US/compo…(Element Plus 官方虚拟表格)。