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

虚拟列表优化方案确定

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

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

表格部分 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>
效果预览
