虚拟列表渲染大数量级列表

公司的大屏智慧工地项目中,接入了一些设备,页面上需要显示设备的报警信息,报警信息日积月累后的数量过大,渲染到页面上时间过长,造成页面卡顿的现响。由于对接的是别人的接口,没有做分页处理,同时后端同事暂时也没有时间处理,所以解决问题的任务就落到了前端打工仔的肩膀上了。

各项固定高度的虚拟列表

其实在项目本身里的场景还是比较简单的,每一项也没有图片信息,文字过长后也是省略处理,所以每一行的高度是固定的。

实现思路

  1. 根据项数和高度计算出总高度,生成一个滚动元素,此元素用于初始化滚动条,同时也是实际列表元素的背景
  2. 实际列表的显示区域随着scroll事件进行偏移transform: tranlate()
  3. 显示的项数是固定的,显示具体哪些项的内容随着滚动替换

页面结构

javascript 复制代码
<template>
    <div class="container" ref="virtualList">
        <!-- 占位, 用于形成滚动条 -->
        <div class="phantom" :style="{ height: listHeight + 'px' }"></div>
        <!-- 实际展示的内容列表 -->
        <div class="content" :style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }">
            <div v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px', '--height': itemSize }"
                class="list-item">
                {{ item.value }}
            </div>
        </div>
    </div>
</template>

具体的结构示意图如下,在滚动时通过transform: tranlate()偏移显示列表,让显示列表能够一直在父元素视图内:

逻辑实现

数据初始化

javascript 复制代码
export default {
    name: 'BaseVisualList',
    components: {
    },
    data() {
        return {
            listData: [],
            itemSize: 50, // 每项高度
            screenHeight: 0, // 可视区域高度
            start: 0,        // 起始索引
            end: null,       // 结束索引
            currentOffset: 0, // 当前偏移量
        }
    },
    mounted() {
        for (let i = 1; i <= 1000; i++) {
            this.listData.push({ id: i, value: '字符内容' + i })
        }
        this.screenHeight = this.$el.clientHeight
        this.start = 0
        this.end = this.start + this.visibleCount

        this.$refs.virtualList.addEventListener('scroll', event => this.scrollEvent(event.target))
    },
    computed: {
        // 总高度
        listHeight() {
            return this.listData.length * this.itemSize
        },
        // 可以看到的项数
        visibleCount() {
            return Math.ceil(this.screenHeight / this.itemSize)
        },
        visibleData() {
            return this.listData.slice(this.start, this.end)
        }
    }
}

滚动事件

最后的currentOffset的计算方式要减去(scrollTop % this.itemSize)我的理解为了防止滚动到最后的时候发生抖动,例如我滚一次是滚动100px,此时已经显示到1000中的999项了,再滚一次,那么此时只需要偏移60px,如果还是偏移100px,那么就会往下挤40px,这样就会导致可以一直往下滚:

javascript 复制代码
scrollEvent(target) {
    // 当前滚动位置
    let scrollTop = target.scrollTop;
    // 此时的开始索引
    // 双波浪线对结果进行取整操作,得到最接近且小于等于该结果的整数值
    this.start = ~~(scrollTop / this.itemSize); 
    // 此时的结束索引
    this.end = this.start + this.visibleCount;
    // 此时的偏移量
    this.currentOffset = scrollTop - (scrollTop % this.itemSize);
}

各项不固定高度的虚拟列表

复杂一点的列表可能内容长度不确定,同时还可能存在图片等其他信息,这时候列表项的高度就不是固定的了。

实现思路

  1. 给定预定的高度、显示项数,每一项记录上边缘top下边缘bottom的距离顶部差
  2. 滚动时用下边缘距离比较scrollTop,找到最近的一个下边缘大于scrollTop的即为第一个需要显示的
  3. 每次滚动后缓存每一项的真实高度
  4. 减轻滚动过快页面白屏的现象加入缓冲区,即上下多渲染一部分内容

逻辑实现

根据预计高度初始化内容

javascript 复制代码
created() {
    for (let i = 1; i <= 10000; i++) {
        this.listData.push({ id: i, value: i + '字符内容'.repeat(Math.random() * 20), url: 'https://th.bing.com/th?u=https%3a%2f%2fth.bing.com%2fth%3fid%3dORMS.134b6f51000ec2aa66b3dc62cb309792%26pid%3dWdp&ehk=wqdvjD0ExLTidICzbTGgd844N62aZ1gRDMVk4idmJMw%3d&w=186&h=88&c=8&rs=2&o=6&pid=WP0' })
    }
    this.initPositions(this.listData, this.preItemSize)
},

computed: {
    // 总高度
    listHeight() {
        return this.positions[this.positions.length - 1].bottom;
    }
}

滚动触发更新

  1. 更新起始项的索引,更新可视列表,从而触发Vue生命周期中的updated生命周期,从而更新每一项缓存的top和bottom数据
  2. 更新y轴的偏移量:用下边缘距离比较scrollTop,找到最近的一个下边缘bottom大于scrollTop的即为第一个需要显示的,因为还有缓存区的存在,所以偏移距离需要减去缓存区的高度
javascript 复制代码
 scrollEvent(target) {
    const { scrollTop } = target;
    this.start = this.getStartIndex(scrollTop);
    this.end = this.start + this.visibleCount;
    this.currentOffset = this.getCurrentOffset()
}

// 初始化列表
initPositions(listData, itemSize) {
    this.positions = listData.map((item, index) => {
        return {
            index,
            top: index * itemSize,
            bottom: (index + 1) * itemSize,
            height: itemSize,
        }
    })
}

getStartIndex(scrollTop = 0) {
    return binarySearch(this.positions, scrollTop)
    
    let binarySearch = function (list, target) {
        const len = list.length
        let left = 0, right = len - 1
        let tempIndex = null

        while (left <= right) {
            let midIndex = (left + right) >> 1
            let midVal = list[midIndex].bottom

            if (midVal === target) {
                return midIndex
            } else if (midVal < target) {
                left = midIndex + 1
            } else {
                // list不一定存在与target相等的项,不断收缩右区间,寻找最匹配的项
                if (tempIndex === null || tempIndex > midIndex) {
                    tempIndex = midIndex
                }
                right--
            }
        }
        // 如果没有搜索到完全匹配的项 就返回最匹配的项
        return tempIndex
    };
}

updated() {
    this.$nextTick(() => {
        if (!this.$refs.items || !this.$refs.items.length) {
            return;
        }
        // 根据真实元素大小,修改对应的缓存列表
        this.updatePositions()
        // 更新完缓存列表后,重新赋值偏移量
        this.currentOffset = this.getCurrentOffset()
    })
}

// 该方法用于更新每一项的top、bottom、heigiht值
updatePositions() {
    let nodes = this.$refs.items;
    nodes.forEach((node) => {
        // 获取 真实DOM高度
        const { height } = node.getBoundingClientRect();
        // 根据 元素索引 获取 缓存列表对应的列表项
        const index = node.id - 1
        let oldHeight = this.positions[index].height;
        // dValue:真实高度与预估高度的差值 决定该列表项是否要更新
        let dValue = oldHeight - height;
        // 如果有高度差 !!dValue === true
        if (dValue) {
            // 更新对应列表项的 bottom 和 height
            this.positions[index].bottom = this.positions[index].bottom - dValue;
            this.positions[index].height = height;
            // 依次更新positions中后续元素的 top bottom
            for (let k = index + 1; k < this.positions.length; k++) {
                this.positions[k].top = this.positions[k - 1].bottom;
                this.positions[k].bottom = this.positions[k].bottom - dValue;
            }
        }
    })
}

getCurrentOffset() {
    if (this.start >= 1) {
        // 计算偏移量时减去上缓冲区的列表项的高度
        let size = this.positions[this.start].top - (this.positions[this.start - this.aboveCount] ?
            this.positions[this.start - this.aboveCount].top : 0);
        return this.positions[this.start - 1].bottom - size;
    } else {
        return 0;
    }
}

参考文章

本文的思路都是基于大佬的这两篇文章的,更详细的思路可以看以下两篇文章

基于【虚拟列表】高性能渲染海量数据

深入【虚拟列表】动态高度、缓冲、异步加载... Vue实现

相关推荐
并不会1 小时前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
悦涵仙子1 小时前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
衣乌安、1 小时前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜1 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师1 小时前
CSS的三个重点
前端·css
耶啵奶膘2 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^4 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie4 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic5 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿5 小时前
webWorker基本用法
前端·javascript·vue.js