IntersectionObserver实现横向虚拟滚动列表

背景

同事在业务上又碰到一个比较难搞的需求,需要渲染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>
相关推荐
float_六七2 小时前
SQL中的NULL陷阱:为何=永远查不到空值
java·前端·sql
小满zs2 小时前
Next.js第三章(App Router)
前端
Sheldon一蓑烟雨任平生2 小时前
Vue3 KeepAlive(缓存组件实例)
vue.js·vue3·组件缓存·keepalive·缓存组件实例·onactivated·ondeactivated
小满zs2 小时前
Next.js第二章(项目搭建)
前端
前端小张同学2 小时前
基础需求就用AI写代码,你会焦虑吗?
java·前端·后端
小满zs2 小时前
Next.js第一章(入门)
前端
摇滚侠2 小时前
CSS(层叠样式表)和SCSS(Sassy CSS)的核心区别
前端·css·scss
不爱吃糖的程序媛2 小时前
Electron 桌面应用开发入门指南:从零开始打造 Hello World
前端·javascript·electron
Dontla2 小时前
前端状态管理,为什么要状态管理?(React状态管理、zustand)
前端·react.js·前端框架