背景
同事在业务上又碰到一个比较难搞的需求,需要渲染60+列的表格,表格元素也还是输入框、下拉等等,组件还是用的antdVue1.x(这玩意的性能感觉,dddd);所以就在想实现横向虚拟滚动,但是又不想重新手写,目光首先就看到了vxe-table,但是用它的横向虚拟滚动就5行就产生卡顿了,在思考之余就想到了前阵子看过的IntersectionObserver这个API,试试能不能用它来做到元素的显示隐藏。
卡顿的本质就是因为渲染了太多的表单元素所导致的,通过判断可视区域元素的进出就可以做到动态的渲染表单元素,因此就做了以下的操作。
实现
列表还是采用的a-table来做实现,其实也可以用原生。(完整demo代码放在最后)
模板内容
vue
<template>
<div class="virtual-list-page">
<div class="page-header">
<h2>提交失败 - 横向虚拟滚动测试 (IntersectionObserver)</h2>
<div class="tip">
<div>当前单据存在细类SKU超出宽度上限,不可提交!</div>
<div>
可删除以下细类的单据SKU或对以下细类老品进行淘汰后继续新品引入。
</div>
</div>
</div>
<div class="info">
<p>使用 IntersectionObserver 实现虚拟滚动</p>
<p>
总列数: {{ allColumns.length }}, 当前可见:
{{ visibleColumns.size }}
</p>
</div>
<a-table
:columns="tableColumns"
:data-source="tableData"
:scroll="{ x: totalTableWidth, y: 400 }"
tableLayout="fixed"
ref="virtualTable"
>
<template
v-for="column in tableColumns"
:slot="column.dataIndex"
slot-scope="text, record, index"
>
<div
:key="column.dataIndex"
:id="`cell-${index}-${column.dataIndex}`"
class="cell-observer-placeholder"
>
<div v-show="visibleColumns.has(parseInt(column.index))">
<!-- 输入框列 -->
<a-input
v-if="column.type === 'input'"
:value="record[column.dataIndex]"
@change="
handleInputChange(
index,
column.dataIndex,
$event
)
"
size="small"
/>
<!-- 下拉框列 -->
<a-select
v-else-if="column.type === 'select'"
:value="record[column.dataIndex]"
@change="
handleSelectChange(
index,
column.dataIndex,
$event
)
"
size="small"
:dropdownMatchSelectWidth="false"
>
<a-select-option
v-for="option in column.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
<!-- 普通文本列 -->
<span v-else>
{{ getDataValue(record, column.dataIndex) }}
</span>
</div>
</div>
</template>
</a-table>
<div class="pagination-info">
总共 {{ tableData.length }} 条记录,{{ allColumns.length }} 列
</div>
</div>
</template>
思路
绑定观察元素
IntersectionObserver需要用到DOM元素本身,如果获取每一个单元格来进行观察,又会做很多的消耗,而且在上下滚动的过程中也会出现问题,转而去使用观察表头在可视区域的移动,vue可以通过ref的方式来获取,但是插槽的部分我尝试了并不能拿到,就改用key的形式来获取表头DOM。
JavaScript
observeColumns() {
this.$nextTick(() => {
// 清除之前的观察器
if (this.intersectionObserver) {
this.intersectionObserver.disconnect()
}
// 观察第一行的所有列单元格
for (
let rowIndex = 0;
rowIndex < this.allColumns.length;
rowIndex++
) {
const column = this.allColumns[rowIndex]
const elementId = column.field
const cellElement = document.querySelector(
`[key="${elementId}"]`
)
if (cellElement) {
cellElement.dataset.index = rowIndex
this.intersectionObserver.observe(cellElement)
}
}
})
}
观察逻辑
这里将观察函数单独做了定义是为了初始化首屏可视区域的列,观察器每次会调用的元素是移出可视区域以及刚进入可视区域的元素,将列数进行排列,再补充中间值,再做预加载值。
JavaScript
initIntersectionObserver() {
// 创建 IntersectionObserver 实例观察单元格占位符
this.intersectionObserver = new IntersectionObserver(
(entries) => this.initIntersectionObserverCallback(entries),
{
root: this.$refs.virtualTable.$el,
rootMargin: '600px',
threshold: 0.01
}
)
},
initIntersectionObserverCallback(entries) {
const result = new Set()
entries.forEach((entry) => {
const colIndex = parseInt(entry.target.dataset.index)
result.add(colIndex)
})
// 通过arr最小值与最大值获取区间,再根据最小值、最大值预加载可见列
const arr = [...result].sort((a, b) => a - b)
const minIndex = arr[0]
const maxIndex = arr[arr.length - 1]
// 预加载机制:在最小值基础上往前预加载8列,最大值基础上往后预加载8列
const preloadCount = 8
const startIndex = Math.max(0, minIndex - preloadCount)
const endIndex = Math.min(
this.allColumns.length - 1,
maxIndex + preloadCount
)
// 创建扩展的可见列集合
const extendedResult = new Set()
for (let i = startIndex; i <= endIndex; i++) {
extendedResult.add(i)
}
// 更新可见列集合
this.$set(this, 'visibleColumns', new Set([...extendedResult]))
},
生命周期调用
js
mounted() {
// 初始化 IntersectionObserver
this.initIntersectionObserver()
// 观察列元素
this.observeColumns()
this.$nextTick(() => {
if (this.intersectionObserver) {
const records = this.intersectionObserver.takeRecords()
this.initIntersectionObserverCallback(records)
}
})
},
beforeDestroy() {
// 清理 IntersectionObserver
this.unobserveColumns()
}
完整代码
这里比上面多了一个滚动的思路是想优化下白屏?但是效果不咋地的感觉
vue
<!-- src/pages/testpage/index.vue -->
<template>
<div class="virtual-list-page">
<div class="page-header">
<h2>提交失败 - 横向虚拟滚动测试 (IntersectionObserver)</h2>
<div class="tip">
<div>当前单据存在细类SKU超出宽度上限,不可提交!</div>
<div>
可删除以下细类的单据SKU或对以下细类老品进行淘汰后继续新品引入。
</div>
</div>
</div>
<div class="info">
<p>使用 IntersectionObserver 实现虚拟滚动</p>
<p>
总列数: {{ allColumns.length }}, 当前可见:
{{ visibleColumns.size }}
</p>
</div>
<a-table
:columns="tableColumns"
:data-source="tableData"
:scroll="{ x: totalTableWidth, y: 400 }"
tableLayout="fixed"
ref="virtualTable"
>
<template
v-for="column in tableColumns"
:slot="column.dataIndex"
slot-scope="text, record, index"
>
<div
:key="column.dataIndex"
:id="`cell-${index}-${column.dataIndex}`"
class="cell-observer-placeholder"
>
<div v-show="visibleColumns.has(parseInt(column.index))">
<!-- 输入框列 -->
<a-input
v-if="column.type === 'input'"
:value="record[column.dataIndex]"
@change="
handleInputChange(
index,
column.dataIndex,
$event
)
"
size="small"
/>
<!-- 下拉框列 -->
<a-select
v-else-if="column.type === 'select'"
:value="record[column.dataIndex]"
@change="
handleSelectChange(
index,
column.dataIndex,
$event
)
"
size="small"
:dropdownMatchSelectWidth="false"
>
<a-select-option
v-for="option in column.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
<!-- 普通文本列 -->
<span v-else>
{{ getDataValue(record, column.dataIndex) }}
</span>
</div>
</div>
</template>
</a-table>
<div class="pagination-info">
总共 {{ tableData.length }} 条记录,{{ allColumns.length }} 列
</div>
<div class="actions">
<a-button @click="printData" type="primary">打印当前数据</a-button>
</div>
</div>
</template>
<script>
export default {
name: 'ModalCheckControlTest',
data() {
return {
allColumns: [],
tableData: [],
columnWidth: 150,
visibleColumns: new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
intersectionObserver: null,
headerIntersectionObserver: null,
scrollTimer: null,
scrollDebounceTimer: null
}
},
computed: {
totalTableWidth() {
return this.allColumns.length * this.columnWidth
},
tableColumns() {
return this.allColumns.map((col) => ({
title: col.title,
dataIndex: col.field,
width: col.width,
index: col.index,
type: col.type,
options: col.options,
scopedSlots: col.scopedSlots
}))
}
},
methods: {
generateColumns() {
const columns = []
for (let i = 1; i <= 90; i++) {
// 前10列为输入框
if (i <= 10) {
columns.push({
title: `输入字段${i}`,
width: this.columnWidth,
field: `inputField${i}`,
type: 'input',
dataIndex: `inputField${i}`,
scopedSlots: { customRender: `inputField${i}` }
})
}
// 11-20列为下拉框
else if (i <= 60) {
columns.push({
title: `选择字段${i - 10}`,
width: this.columnWidth,
field: `selectField${i - 10}`,
type: 'select',
options: [
{ label: '选项1', value: 'option1' },
{ label: '选项2', value: 'option2' },
{ label: '选项3', value: 'option3' },
{ label: '选项4', value: 'option4' }
],
dataIndex: `selectField${i - 10}`,
scopedSlots: { customRender: `selectField${i - 10}` }
})
}
// 其余为普通文本
else {
columns.push({
title: `字段${i}`,
width: this.columnWidth,
field: `field${i}`,
scopedSlots: { customRender: `field${i}` }
})
}
}
return columns.map((col, index) => ({
...col,
index
}))
},
generateMockData() {
const data = []
for (let i = 1; i <= 60; i++) {
const item = { id: i }
// 输入字段
for (let j = 1; j <= 10; j++) {
item[`inputField${j}`] = `输入数据${i}-${j}`
}
// 下拉字段
for (let j = 1; j <= 10; j++) {
item[`selectField${j}`] =
j % 4 === 0
? 'option1'
: j % 4 === 1
? 'option2'
: j % 4 === 2
? 'option3'
: 'option4'
}
// 普通字段
for (let j = 21; j <= 60; j++) {
item[`field${j}`] = `数据${i}-${j}`
}
data.push(item)
}
return data
},
getDataValue(row, field) {
return row[field] !== undefined ? row[field] : '-'
},
handleInputChange(rowIndex, field, event) {
const value = event.target ? event.target.value : event
this.$set(this.tableData[rowIndex], field, value)
},
handleSelectChange(rowIndex, field, value) {
this.$set(this.tableData[rowIndex], field, value)
},
printData() {
console.log('当前数据:', this.tableData)
alert(
`数据已打印到控制台,前3行数据:\n${JSON.stringify(
this.tableData.slice(0, 3),
null,
2
)}`
)
},
initIntersectionObserver() {
// 创建 IntersectionObserver 实例观察单元格占位符
this.intersectionObserver = new IntersectionObserver(
(entries) => this.initIntersectionObserverCallback(entries),
{
root: this.$refs.virtualTable.$el,
rootMargin: '600px',
threshold: 0.01
}
)
},
initIntersectionObserverCallback(entries) {
const result = new Set()
entries.forEach((entry) => {
const colIndex = parseInt(entry.target.dataset.index)
result.add(colIndex)
})
// 通过arr最小值与最大值获取区间,再根据最小值、最大值预加载可见列
const arr = [...result].sort((a, b) => a - b)
const minIndex = arr[0]
const maxIndex = arr[arr.length - 1]
// 预加载机制:在最小值基础上往前预加载2列,最大值基础上往后预加载2列
const preloadCount = 8
const startIndex = Math.max(0, minIndex - preloadCount)
const endIndex = Math.min(
this.allColumns.length - 1,
maxIndex + preloadCount
)
// 创建扩展的可见列集合
const extendedResult = new Set()
for (let i = startIndex; i <= endIndex; i++) {
extendedResult.add(i)
}
// 更新可见列集合
this.$set(this, 'visibleColumns', new Set([...extendedResult]))
},
observeColumns() {
this.$nextTick(() => {
// 清除之前的观察器
if (this.intersectionObserver) {
this.intersectionObserver.disconnect()
}
// 观察第一行的所有列单元格
for (
let rowIndex = 0;
rowIndex < this.allColumns.length;
rowIndex++
) {
const column = this.allColumns[rowIndex]
const elementId = column.field
const cellElement = document.querySelector(
`[key="${elementId}"]`
)
if (cellElement) {
cellElement.dataset.index = rowIndex
this.intersectionObserver.observe(cellElement)
}
}
})
},
unobserveColumns() {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect()
}
if (this.headerIntersectionObserver) {
this.headerIntersectionObserver.disconnect()
}
},
// 根据滚动百分比计算可见列
calculateVisibleColumnsByPercentage(scrollPercentage) {
if (this.allColumns.length === 0) return
// 计算当前视口能显示的列数(估算)
const visibleColumnCount = Math.ceil(1200 / this.columnWidth) // 假设容器宽度为1200px
// 根据滚动百分比计算中心位置
const centerIndex = Math.floor(
(scrollPercentage / 100) * this.allColumns.length
)
// 计算可见范围
const startIndex = Math.max(
0,
centerIndex - Math.floor(visibleColumnCount / 2)
)
const endIndex = Math.min(
this.allColumns.length - 1,
startIndex + visibleColumnCount - 1
)
// 添加预加载
const preloadCount = 10
const actualStartIndex = Math.max(0, startIndex - preloadCount)
const actualEndIndex = Math.min(
this.allColumns.length - 1,
endIndex + preloadCount
)
// 创建新的可见列集合
const newVisibleColumns = new Set()
for (let i = actualStartIndex; i <= actualEndIndex; i++) {
newVisibleColumns.add(i)
}
return newVisibleColumns
},
// 处理滚动事件(带防抖)
handleScroll(event) {
const target = event.target
if (target.scrollWidth <= target.clientWidth) return
// 计算滚动百分比
const scrollLeft = target.scrollLeft
const maxScroll = target.scrollWidth - target.clientWidth
const scrollPercentage = (scrollLeft / maxScroll) * 100
// 清除之前的防抖定时器
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer)
}
// 设置防抖定时器
this.scrollDebounceTimer = setTimeout(() => {
// 立即根据滚动百分比计算并设置可见列
const visibleColumns =
this.calculateVisibleColumnsByPercentage(scrollPercentage)
if (visibleColumns) {
this.$set(this, 'visibleColumns', visibleColumns)
}
// 清除之前的 IntersectionObserver 定时器
if (this.scrollTimer) {
clearTimeout(this.scrollTimer)
}
// 设置定时器,在滚动停止后重新启用 IntersectionObserver
this.scrollTimer = setTimeout(() => {
// 重新触发 IntersectionObserver 检查
if (this.intersectionObserver) {
const records = this.intersectionObserver.takeRecords()
if (records.length > 0) {
this.initIntersectionObserverCallback(records)
}
}
}, 100)
}, 50) // 50ms 防抖延迟
},
// 初始化滚动监听
initScrollListener() {
this.$nextTick(() => {
const tableBody =
this.$refs.virtualTable.$el.querySelector('.ant-table-body')
if (tableBody) {
tableBody.addEventListener('scroll', this.handleScroll, {
passive: true
})
}
})
},
// 移除滚动监听
removeScrollListener() {
this.$nextTick(() => {
const tableBody =
this.$refs.virtualTable.$el.querySelector('.ant-table-body')
if (tableBody) {
tableBody.removeEventListener('scroll', this.handleScroll)
}
// 清除所有定时器
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer)
}
if (this.scrollTimer) {
clearTimeout(this.scrollTimer)
}
})
}
},
mounted() {
// 生成60列
this.allColumns = this.generateColumns()
// 生成60行数据
this.tableData = this.generateMockData()
// 初始化 IntersectionObserver
this.initIntersectionObserver()
// 观察列元素
this.observeColumns()
// 初始化滚动监听
this.initScrollListener()
this.$nextTick(() => {
console.log(this.$refs.virtualTable, '表格引用')
if (this.intersectionObserver) {
const records = this.intersectionObserver.takeRecords()
this.initIntersectionObserverCallback(records)
}
})
},
beforeDestroy() {
// 清理 IntersectionObserver
this.unobserveColumns()
// 移除滚动监听
this.removeScrollListener()
}
}
</script>
<style lang="scss" scoped>
.virtual-list-page {
padding: 20px;
background: #fff;
}
.page-header {
margin-bottom: 20px;
h2 {
margin-bottom: 16px;
}
}
.tip {
color: #ff4d4f;
margin-bottom: 16px;
div {
margin-bottom: 4px;
}
}
.info {
margin-bottom: 16px;
padding: 10px 0;
color: #1890ff;
font-weight: bold;
}
.header-observer-placeholder,
.cell-observer-placeholder {
width: 100%;
height: 100%;
}
.pagination-info {
margin-top: 10px;
text-align: right;
color: #666;
font-size: 12px;
}
.actions {
margin-top: 20px;
text-align: center;
}
</style>