虚拟列表

前言

在移动端的项目开发中,我们应该都有处理过类似新闻列表的数据加载,为了能够快速展现列表,我们应该尽可能的减少 DOM 节点的渲染,因此我往往会和后端接口协调一致,通过前端传递页码进行分页请求数据加载。不过这种处理方式的弊端也显而易见,因为用户每次滚动到底部都先去请求数据接口,然后再进行页面渲染,这里的 http 请求耗时会影响到用户体验,所以为了减少 http 请求,我们来学习一种优化方案:虚拟列表。

什么是虚拟列表

虚拟列表是指将可见区域的列表进行渲染,而对不可见区域的数据不渲染或部分渲染的技术,目的是为了提高渲染性能和用户体验。

图片1

实现

虚拟列表的实现,就是只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

根据虚拟列表的概念描述,我们可以提炼出几个关键点:

  • 可视区域的高度,假设它是固定高度。
  • 列表项的高度,假设它是固定高度。
  • 总列表。
  • 监听滚动偏移量。
  • 删除非可视区域的列表项,那么需要知道总列表的起始位置项和结束位置项。

我是使用 vue 官方脚手架初始化的模板,下面请看第一版实现:

<template>
    <div class="virtual-index-wrapper">
        <div
            ref="list"
            class="virtual-list-container"
            @scroll="onScroll($event)"
        >
            <div
                :style="{ height: listHeight + 'px' }"
                class="virtual-list-phantom"
            ></div>
            <ul
                :style="{ transform: getTransform }"
                class="virtual-list-content"
            >
                <li
                    v-for="item in visibleData"
                    :key="item.id"
                    :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
                    class="virtual-list-item"
                >
                    {{ item.val }}
                </li>
            </ul>
        </div>
    </div>
</template>

<script>
    let _arr = []
    for (let i = 0; i < 1000; i++) {
        _arr.push({
            id: i,
            val: i
        })
    }

    export default {
        name: 'VirtualIndex',
        data () {
            return {
                listData: _arr, // 总列表
                itemSize: 100, // 每项的高度
                screenHeight: 0, // 可视区域高度
                startOffset: 0, // 偏移量
                start: 0, // 起始索引
                end: 0, // 结束索引
            }
        },
        computed: {
            // 列表总高度
            listHeight () {
                return this.listData.length * this.itemSize
            },

            // 可显示的列表项数
            visibleCount () {
                return Math.ceil(this.screenHeight / this.itemSize)
            },

            // 真实列表数
            visibleData () {
                return this.listData.slice(this.start, Math.min(this.end, this.listData.length))
            },

            // 偏移量对应的 style,这个是相对 virtual-list-container 作偏移,始终显示在可视区域
            getTransform () {
                return `translate3d(0, ${this.startOffset}px, 0)`
            }
        },
        mounted () {
            this.screenHeight = this.$el.clientHeight
            this.end = this.start + this.visibleCount
        },
        methods: {
            onScroll () {
                // 当前滚动位置
                let scrollTop = this.$refs.list.scrollTop

                // 此时的开始索引
                this.start = Math.floor(scrollTop / this.itemSize)

                // 此时的结束索引
                this.end = this.start + this.visibleCount

                // 此时的偏移量
                this.startOffset = scrollTop - (scrollTop % this.itemSize)
            }
        }
    }
</script>

<style lang="scss" scoped>
    .virtual-index-wrapper {
        position: relative;
        height: 100vh;
    }
    .virtual-list {
        &-container {
            position: relative;
            height: 100%;
            overflow: auto;
            -webkit-overflow-scrolling: touch;
        }
        &-phantom {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
            z-index: -1;
        }
        &-content {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            text-align: center;
        }
        &-item {
            padding: 0 20px;
            color: #333;
            font-size: 32px;
            box-sizing: border-box;
            border-bottom: 1px solid #ccc;
        }
    }
</style>

效果图如下:

效果1

列表内容动态变化

在我们实际开发中,列表的内容高度可不像上面介绍那样是固定的,所以我们接下来要解决这个问题。

思路:以预估高度先行渲染,然后计算真实高度缓存起来。

  • 给列表每一项预设固定高度,接着初始计算它们的位置。
  • 渲染后根据它们的真实高度,更新它们缓存的位置信息。
  • 滚动高度,计算可视区域显示列表的项数。

下面的代码片段,我已用注释说明了,可以试着从生命周期顺序理解:

安装下依赖 faker在新窗口打开,为了帮助我们生成随机内容。

<template>
    <div class="virtual-improve-wrapper">
        <div
            ref="vList"
            class="virtual-list-container"
            @scroll="onScroll($event)"
        >
            <div
                ref="vPhantom"
                class="virtual-list-phantom"
            ></div>
            <ul
                ref="vContent"
                class="virtual-list-content"
            >
                <li
                    v-for="(item, index) in visibleData"
                    :id="item.id"
                    ref="vItem"
                    :key="index"
                    class="virtual-list-item"
                >
                    {{ item.id }}、{{ item.val }}
                </li>
            </ul>
        </div>
    </div>
</template>

<script>
    import faker from 'faker';

    let _arr = []
    for (let i = 0; i < 1000; i++) {
        _arr.push({
            id: `_${i}`,
            val: faker.lorem.sentences()
        })
    }

    export default {
        name: 'VirtualIndexImprove',
        data () {
            return {
                listData: _arr, // 总列表
                itemSize: 100, // 预设每项的高度
                screenHeight: 0, // 可视区域高度
                positions: [], // 缓存列表位置
                start: 0, // 起始索引
                end: 0, // 结束索引
            }
        },
        computed: {
            // 可显示的列表项数
            visibleCount () {
                return Math.ceil(this.screenHeight / this.itemSize)
            },

            // 真实列表数
            visibleData () {
                return this.listData.slice(this.start, this.end)
            }
        },
        created () {
            this.initPositions()
        },
        mounted () {
            this.screenHeight = this.$el.clientHeight // 获取可视区域高度
            this.end = this.start + this.visibleCount // 计算初始结束项
        },
        updated () {
            this.$nextTick(() => {
                if (this.$refs.vItem && this.$refs.vItem.length) {
                    // 1、更新列表每项高度和位置
                    this.updateItemPosition()

                    // 2、更新列表的总高度
                    const height = this.positions[this.positions.length - 1].bottom
                    this.$refs.vPhantom.style.height = `${height}px`

                    // 3、设置偏移量
                    this.setStartOffset()
                }
            })
        },
        methods: {
            // 初始化每项位置
            initPositions () {
                this.positions = this.listData.map((item, index) => ({
                    index,
                    height: this.itemSize,
                    top: index * this.itemSize,
                    bottom: (index + 1) * this.itemSize
                }))
            },

            // 更新列表每项高度和位置
            updateItemPosition () {
                let nodes = this.$refs.vItem // 获取列表 DOM
                nodes.forEach(elem => {
                    let rect = elem.getBoundingClientRect() // 返回元素的大小及其相对于视口的位置,如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于  width/height
                    let height = rect.height // 获取当前高度
                    let index = +elem.id.slice(1) // 创建当前的下标,下面更新时需要使用
                    let oldHeight = this.positions[index].height // 获取旧的高度
                    let diffVal = oldHeight - height // 对比旧的高度有无变化,有的话就更新它的位置信息,否则反之

                    // 判断是否更新位置信息
                    if (diffVal) {
                        this.positions[index].height = height // 更新成当前的高度
                        this.positions[index].bottom = this.positions[index].bottom - diffVal // 更新成当前的 bottom

                        // 以当前 DOM 开始,更新后面的列表位置信息
                        for (let k = index + 1; k < this.positions.length; k++) {
                            this.positions[k].top = this.positions[k - 1].bottom // 上一项 DOM 的 bottom 作为下一项的 top
                            this.positions[k].bottom = this.positions[k].bottom - diffVal // 更新方式同上面
                        }
                    }
                })
            },

            // 获取列表起始索引
            getStartIndex (scrollTop = 0) {
                // 二分法查找 https://huitoutunao.com/guide/algorithm/%E4%BB%8B%E7%BB%8D.html#%E4%BA%8C%E5%88%86%E6%B3%95%E6%9F%A5%E6%89%BE
                return this.binarySearch(this.positions, scrollTop)
            },

            binarySearch (list, scrollVal) {
                let start = 0
                let end = list.length -1
                let resIndex = null

                while (start <= end) {
                    let midIndex = parseInt((start + end) / 2) // 中间位置项
                    let midBottom = list[midIndex].bottom // 中间位置项的 bottom

                    if (midBottom === scrollVal) { // 说明滚动位置刚好和中间项的 bottom 重合
                        return midIndex + 1 // 返回下一项位置下标
                    } else if (midBottom < scrollVal) { // 说明中间项在滚动位置之间不合理
                        start = midIndex + 1 // 继续循环寻找符合条件的项
                    } else if (midBottom > scrollVal) {
                        if (!resIndex || resIndex > midIndex) {
                            resIndex = midIndex
                        }
                        end = end - 1 // 减 1 再除以 2 比上一次取的值小,所以上面判断 resIndex > midIndex 不断缩小搜索范围
                    }
                }

                return resIndex
            },

            // 设置列表偏移量
            setStartOffset () {
                const startOffset = this.start > 0
                    ? this.positions[this.start - 1].bottom
                    : 0
                this.$refs.vContent.style.transform = `translate3d(0, ${startOffset}px, 0)`
            },

            onScroll () {
                // 当前滚动位置
                const scrollTop = this.$refs.vList.scrollTop

                // 此时的开始索引
                this.start = this.getStartIndex(scrollTop)

                // 此时的结束索引
                this.end = this.start + this.visibleCount

                this.setStartOffset()
            }
        }
    }
</script>

<style lang="scss" scoped>
    .virtual-improve-wrapper {
        position: relative;
        height: 100vh;
    }
    .virtual-list {
        &-container {
            position: relative;
            height: 100%;
            overflow: auto;
            -webkit-overflow-scrolling: touch;
        }
        &-phantom {
            position: absolute;
            left: 0;
            top: 0;
            right: 0;
            z-index: -1;
        }
        &-content {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
        }
        &-item {
            padding: 20px;
            color: #333;
            font-size: 32px;
            box-sizing: border-box;
            border-bottom: 1px solid #ccc;
        }
    }
</style>

效果图如下:

效果2

结语

在处理长列表渲染性能体验上,虚拟列表是一种比较好的方案,但是在快速滑动列表时,会出现白屏的情况,这个可以通过增量缓存真实列表来降低白屏的几率。

本文到这里就结束了,希望这篇文章对你有所帮助。

参考文献