vue手动实现虚拟列表

197 阅读1分钟

痛点:

我们从数据库获取数据时一般会进行分页,只需要渲染几十条数据,但是在某些场景下需要渲染几千上万条数据,这样就会造成页面卡顿,这时候虚拟化列表就发挥作用了。

vue3的element-plus已经有此功能,可直接引用。vue2的jym可以学习参考本文提供的思路。

虚拟化列表

虚拟化列表就是把数据存在内存中并不渲染,只在有限的区域内渲染有限的数据,简单说就是屏幕能看到那个区域再渲染。

实现思路:

  1. 将整体数据存在一个数组中,需要时再截取部分数据渲染。
  2. 监听盒子的滚动事件,使用event.deltaY获取滚动距离,根据滚动距离与每一行的行高计算显示数据的起始index,根据起始index与区域可承载的数量(显示区域高度/单条数据高度)从整体数据中截取渲染。
  3. 监听鼠标拖动滚动条事件,根据滚动条的偏移量占比计算出起始index,而后渲染。
  4. 将滚动条与表格index限制在区域内。

重点在第2点第3点

滚动条与起始index的关系示意图

虚拟列表示意图.png

监听盒子的滚动事件

<template>
    <div class="VirtualTable">
        <div class="table" :style="{ height: `${tableHeight}px` }" @wheel="scrollTable">
            <ul :style="{ height: `${tableHeight}px`, top: listTop }">
                <li v-for="(item, index) in tableData" :key="index">
                    <div>{{ startIndex + index + 1 }}</div>
                    <div>{{ item.date }}</div>
                    <div>{{ item.name }}</div>
                    <div>{{ item.address }}</div>
                </li>
            </ul>
        </div>
    </div>
</template>

<script>
export default {
    name: "VirtualTable",
    data() {
        return {
            totalCount: 1000,
            itemHeight: 50,
            tableHeight: 500,
            tableData: [],
            totalTableData: [],
            startIndex: 0,
            listTop: 0,
        };
    },
    computed: {
        visibleCount() {
            // 可视区域显示的条数 = 可视区域高度 / 每一条的高度
            return Math.ceil(this.tableHeight / this.itemHeight);
        },
        listHeight() {
            // 列表实际的高度 = 总条数 * 每一条的高度
            return this.totalTableData.length * this.itemHeight;
        },
        // 结束索引 = 开始索引 + 可视区域显示的条数
        endIndex() {
            return this.startIndex + this.visibleCount;
        },
    },
    mounted() {
        this.setTotalTableData();
    },
    methods: {
        // 滚动列表事件
        scrollTable(event) {
            // 计算开始索引 = 滚动的距离 / 每一条的高度 + 开始索引
            this.startIndex = Math.floor(event.deltaY / this.itemHeight) + this.startIndex;
            if (this.isExceedStartIndex()) {
                return;
            }
            this.setTableData();
        },
        // 设置列表显示的数据
        setTableData() {
            // 列表显示的数据 = 总数据.slice(开始索引, 结束索引)
            this.tableData = this.totalTableData.slice(this.startIndex, this.endIndex);
        },
        // 判断开始索引是否超出范围
        isExceedStartIndex() {
            let flag = true;
            // 开始索引的最大值 = 总条数 - 可视区域显示的条数
            const maxStartIndex = this.totalTableData.length - this.visibleCount;
            if (this.startIndex < 0) {
                this.startIndex = 0;
            } else if (this.startIndex > maxStartIndex) {
                this.startIndex = maxStartIndex;
            } else {
                flag = false;
            }
            return flag;
        },
        // 获取全部数据
        async setTotalTableData() {
            for (let index = 0; index < this.totalCount; index++) {
                this.totalTableData.push({
                    address: "浙江省 湖州市",
                    date: "1999-05-11",
                    name: "王小虎" + index,
                });
            }
            this.setTableData();
        },
    },
};
</script>
<style lang="less" scoped>
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}
.VirtualTable {
    overflow: hidden;
    .table {
        width: 800px;
        margin: 80px auto;
        border: 1px solid pink;
        position: relative;
        ul {
            position: absolute;
            left: 0;
            width: 100%;
            padding-right: 17px;
            li {
                display: flex;
                justify-content: space-around;
                height: 50px;
                line-height: 50px;
                border-bottom: 1px solid #ccc;
                > div {
                    width: 25%;
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                    text-align: center;
                }
            }
        }
    }
}
</style>

加入滚动条与完整代码

<template>
    <div class="VirtualTable">
        <div v-loading="loading" class="table" :style="{ height: `${tableHeight}px` }" @wheel="scrollTable">
            <ul :style="{ height: `${tableHeight}px`, top: listTop }">
                <li v-for="(item, index) in tableData" :key="index">
                    <div>{{ startIndex + index + 1 }}</div>
                    <div>{{ item.date }}</div>
                    <div>{{ item.name }}</div>
                    <div>{{ item.address }}</div>
                </li>
            </ul>
            <div ref="scrollBar" class="scroll_bar">
                <div
                    class="scroll_thumb"
                    :style="{ height: `${thumbHeight}px`, transform: `translateY(${thumbOffsetY}px)` }"
                    @mousedown="mousedown"
                ></div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name: "VirtualTable",
    data() {
        return {
            totalCount: 1000,
            loading: false,
            minThumbHeight: 40,
            itemHeight: 50,
            tableHeight: 500,
            tableData: [],
            totalTableData: [],
            startIndex: 0,
            listTop: 0,
            scrollBarRect: {},
            thumbOffsetY: 0,
            mouseOffsetY: 0,
        };
    },
    computed: {
        visibleCount() {
            // 可视区域显示的条数 = 可视区域高度 / 每一条的高度
            return Math.ceil(this.tableHeight / this.itemHeight);
        },
        listHeight() {
            // 列表实际的高度 = 总条数 * 每一条的高度
            return this.totalTableData.length * this.itemHeight;
        },
        // 结束索引 = 开始索引 + 可视区域显示的条数
        endIndex() {
            return this.startIndex + this.visibleCount;
        },
        thumbHeight() {
            // 滚动条的高度 = 可视区域高度 / 列表实际的高度 * 可视区域高度
            const height = (this.tableHeight / this.listHeight) * this.tableHeight;
            return Math.max(height, this.minThumbHeight);
        },
    },
    mounted() {
        this.setTotalTableData();
        window.addEventListener("resize", this.setBarRect);
        this.setBarRect();
    },
    beforeDestroy() {
        window.removeEventListener("resize", this.setBarRect);
        this.removeMouseEvent();
    },
    methods: {
        refresh() {
            this.startIndex = 0;
            this.thumbOffsetY = 0;
            this.setTotalTableData();
        },
        // 设置滚动条位置信息
        setBarRect() {
            this.scrollBarRect = this.$refs.scrollBar.getBoundingClientRect();
        },
        mousemove(event) {
            event.preventDefault();
            event.stopPropagation();
            // 计算thumb的偏移量 = 鼠标相对文档的位置 - 鼠标点击在thumb的相对位置 - 滚动条相对文档的位置
            this.thumbOffsetY = event.clientY - this.mouseOffsetY - this.scrollBarRect.top;
            this.setThumbOffsetLimit();
            const length = this.totalTableData.length - this.visibleCount;
            // 比率 = thumb偏移量 / (滚动条高度-thumb高度)
            const rate = this.thumbOffsetY / (this.scrollBarRect.height - this.thumbHeight);
            this.startIndex = Math.round(rate * length);
            this.isExceedStartIndex();
            this.setTableData();
        },
        // 限制thumb的偏移量的最大值和最小值
        setThumbOffsetLimit() {
            // 滚动条最大偏移量 = 滚动条的高度 - thumb的高度
            const maxOffsetY = this.scrollBarRect.height - this.thumbHeight;
            if (this.thumbOffsetY < 0) {
                this.thumbOffsetY = 0;
            } else if (this.thumbOffsetY > maxOffsetY) {
                this.thumbOffsetY = maxOffsetY;
            }
        },
        mousedown(event) {
            // 判断是否是鼠标左键
            if (event.button !== 0) return;
            this.setBarRect();
            // 记录鼠标点击在thumb的相对位置
            this.mouseOffsetY = event.offsetY;
            document.addEventListener("mousemove", this.mousemove);
            document.addEventListener("mouseup", this.mouseup);
        },
        mouseup() {
            this.removeMouseEvent();
        },
        removeMouseEvent() {
            document.removeEventListener("mousemove", this.mousemove);
            document.removeEventListener("mouseup", this.mouseup);
        },
        // 滚动列表事件
        scrollTable(event) {
            // 计算开始索引 = 滚动的距离 / 每一条的高度 + 开始索引
            this.startIndex = Math.floor(event.deltaY / this.itemHeight) + this.startIndex;
            this.setThumbOffsetY();
            if (this.isExceedStartIndex()) {
                return;
            }
            this.setTableData();
        },
        // 设置thumb偏移量
        setThumbOffsetY() {
            // 列表可滚动的列数
            const length = this.totalTableData.length - this.visibleCount;
            // 起始index的比率
            const rate = this.startIndex / length;
            // thumb的偏移量 = 起始index比率 * 滚动条可滚动的高度
            const offsetY = rate * (this.scrollBarRect.height - this.thumbHeight);
            this.thumbOffsetY = offsetY || 0;
            this.setThumbOffsetLimit();
        },
        // 判断开始索引是否超出范围
        isExceedStartIndex() {
            let flag = true;
            // 开始索引的最大值 = 总条数 - 可视区域显示的条数
            const maxStartIndex = this.totalTableData.length - this.visibleCount;
            if (this.startIndex < 0) {
                this.startIndex = 0;
            } else if (this.startIndex > maxStartIndex) {
                this.startIndex = maxStartIndex;
            } else {
                flag = false;
            }
            return flag;
        },
        // 设置列表显示的数据
        setTableData() {
            // 列表显示的数据 = 总数据.slice(开始索引, 结束索引)
            this.tableData = this.totalTableData.slice(this.startIndex, this.endIndex);
        },
        // 获取全部数据
        setTotalTableData() {
            for (let index = 0; index < this.totalCount; index++) {
                this.totalTableData.push({
                    address: "浙江省 湖州市",
                    date: "1999-05-11",
                    name: "王小虎" + index,
                });
            }
            this.setTableData();
        },
    },
};
</script>
<style lang="less" scoped>
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}
.VirtualTable {
    overflow: hidden;
    .table {
        position: relative;
        width: 800px;
        margin: 80px auto;
        border: 1px solid pink;
        ul {
            position: absolute;
            left: 0;
            width: 100%;
            padding-right: 17px;
            li {
                display: flex;
                justify-content: space-around;
                height: 50px;
                line-height: 50px;
                border-bottom: 1px solid #ccc;
                > div {
                    width: 25%;
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                    text-align: center;
                }
            }
        }
        .scroll_bar {
            position: absolute;
            width: 17px;
            height: 100%;
            right: 0;
            background-color: #ccc;
            .scroll_thumb {
                position: absolute;
                width: 100%;
                min-height: 40px;
                background-color: #999;
                cursor: pointer;
            }
        }
    }
}
</style>

效果图

虚拟列表实现效果图.gif

总结

实现效果较为粗糙,现在只能使用固定的行高,后面将加入动态高度功能。And 如有bug或者建议欢迎评论区指正。