高性能渲染长列表(无限滚动)

8,424 阅读3分钟

背景

最近在公司的项目(vue)中要求展示一页显示500条数据,本以为简单的渲染出来就行了。没想到初次渲染的时间过长,在批量操作的时候更新列表也会导致页面卡顿。十分影响用户体验,本文记录了这次列表的优化过程。影响体验的主要原因是同事渲染的DOM过多,因此从这个方面入手。

原理

准备采用虚拟列表,虚拟列表其实是按需显示的一种实现,即只对屏幕可视区域进行渲染,对不可见区域中的数据不渲染或部分渲染的方案,从而使长列表的能高性能渲染。

假设:

一共有500条数据,单条数据高度为10px,整个列表的高度就为5000px,屏幕可视区域的高度为100px

初次加载

渲染最前面的10条数据,其他数据隐藏 QQ截图20210825142343.png

发生滚动

滚动了100px之后,前10条滚动到不可见区域,11-20条在可见区域需要显示 QQ截图20210825142433.png

预渲染

为了防止滚动后出现白屏,影响体验,所以要进行预渲染,预留几条渲染

QQ截图20210825152158.png

实现

html部分
  • InfiniteScrollContainer 是滚动的容器
  • InfiniteScrollHeight 撑开容器 用于形成滚动条
  • InfiniteScroll 展示渲染的列表
  • InfiniteScrollItem 滚动列表的的单行渲染
  • listHeight 为所有的单行高度相加
  • translateY 是滚动条的偏移量
<div @scroll="scrollTops" class="InfiniteScrollContainer">
    <div class="InfiniteScrollHeight" :style="{ height: listHeight + 'px' }"></div>
    <div class="InfiniteScroll" :style="{ transform: 'translate3d(0,' + (renderList[0] ? renderList[0].infiniteScrollTop : 0) + 'px,0)' }">
        <div class="InfiniteScrollItem" :data-key="item.infiniteScrollId" :ref="'InfiniteScrollItem' + item.infiniteScrollId" v-for="(item, index) in renderList" :key="item.infiniteScrollId + '_' + item.infiniteScrollKey">
            <slot :item="item" :index="index" />
        </div>
    </div>
</div>
js部分
export default {
    name: "Scroll",
    data() {
        return {
            scrollHeight: 0, // 屏幕的可视区域高度
            renderList: [], // 要渲染的的列表
            listAll: [], // 总列表
            scrollTop: 0, // 滚动了多少距离
            listHeight: 0, // 容器内占位的高度计算
        };
    },
    // 搭建成了组件 外部传入的数据
    props: {
        //数据列表
        datas: {
            default: () => {
                return [];
            },
            type: Array,
        },
        //计算默认高度
        defHeight: {
            default: 50,
            type: Number,
        },
    },
    watch: {
        // 传入的数据变化重新获取列表
        datas(v) {
            this.init();
        },
    },
    mounted() {
        this.getInfiniteScrollHeight();
        this.init();
    },
    methods: {
        // 获取可见区的高度
        getInfiniteScrollHeight() {
            if (document.querySelector(".InfiniteScrollContainer")) {
                this.scrollHeight = document.querySelector(".InfiniteScrollContainer").offsetHeight;
            }
        },
        init() {
            if (this.datas.length > 0) {
                this.listAll = JSON.parse(JSON.stringify(this.datas));
                let height = 0;
                this.listAll.forEach((i, index) => {
                    i.infiniteScrollId = index; // 存入当前行的下标
                    i.infiniteScrollKey = Date.parse(new Date()); // vue-for循环使用的唯一值
                    i.infiniteScrollTop = index === 0 ? 0 : this.listAll[index - 1].infiniteScrollTop + this.listAll[index - 1].infiniteScrollHeight; // 当前的行的距离列表顶部的距离
                    i.infiniteScrollHeight = this.defHeight; // 单行的高度
                    height += this.defHeight;
                });
                this.listHeight = height; // 列表总高
                this.renderList = this.listAll.slice(0, 30); // 获取初始渲染的列表
            }
        },
        scrollTops(e) {
            // 滚动超出多少距离进行计算
            if (Math.abs(e.target.scrollTop - this.scrollTop) > this.defHeight) {
                this.scrollTop = e.target.scrollTop;
                this.getRenderList();
            }
        },
        getRenderList() {
            let start = Math.floor((this.scrollTop - 300 > 0 ? this.scrollTop - 300 : 0) / this.defHeight); // 计算渲染的开始位置  滚动区域小于300不进行预渲染
            let count = start + Math.ceil((this.scrollHeight + 600) / this.defHeight); // 添加了上下偏移的格300像素作为预渲染
            this.renderList = this.listAll.slice(start, Math.min(count, this.listAll.length)); // 获取渲染的列表
        },
        // 插件修改后获取数据
        getAllList() {
            let list = [];
            this.listAll.forEach((e) => {
                let i = Object.assign({}, e);
                delete i.infiniteScrollId;
                delete i.infiniteScrollKey;
                delete i.infiniteScrollTop;
                delete i.infiniteScrollHeight;
                list.push(i);
            });
            return list;
        },
    },
};

动态高度

上面是列表项固定高度的实现,而实际应用的时候,列表项的高度往往是由内容来决定。因此上面的方案就不适用了,要对上面的方案进行一点扩展。 展示了部分修改后的代码

获取单个的高度
init() {
    if (this.datas.length > 0) {
        this.listAll = JSON.parse(JSON.stringify(this.datas));
        //.....
        this.renderList = this.listAll.slice(0, 30); // 获取初始渲染的列表
        this.$nextTick(() => {
            this.renderList.forEach((i) => {
                this.renderListHeightChange(i);
            });
        });
    }
},
// 列表渲染出来 获取列表的真实高度
renderListHeightChange(v) {
    if (!v.hasRenderDom) {
        let id = v.infiniteScrollId;
        let offsetHeight = this.$refs["InfiniteScrollItem" + id][0].offsetHeight;
        this.listAll[id].infiniteScrollHeight = offsetHeight;
        this.listAll[id].hasRenderDom = true;
        // 重新统计下高度 防止
        for (let i = 0; i < this.listAll.length; i++) {
            if (i > 0) {
                this.listAll[i].infiniteScrollTop = this.listAll[i - 1].infiniteScrollTop + this.listAll[i - 1].infiniteScrollHeight;
            }
        }
    }
},
// 订单是否在可视区域
itemHasShow(item) {
    if (item.infiniteScrollTop < this.scrollTop && item.infiniteScrollTop + item.infiniteScrollHeight > this.scrollTop + this.scrollHeight) {
        return true;
    } else if (item.infiniteScrollTop > this.scrollTop && item.infiniteScrollTop < this.scrollTop + this.scrollHeight) {
        return true;
    } else {
        return item.infiniteScrollTop + item.infiniteScrollHeight > this.scrollTop - 300 && item.infiniteScrollTop + item.infiniteScrollHeight < this.scrollTop + this.scrollHeight + 300;
    }
},
getRenderList() {
    for (let i = 0; i < this.listAll.length; i++) {
        if (this.itemHasShow(listAll[i])) {
            let count = i + this.showCount; // 预估一个页面+预渲染的部分能最少展示多少条数据 prop传入
            this.renderList = this.listAll.slice(i, Math.min(count, this.listAll.length)); // 获取渲染的列表
            this.$nextTick(() => {
                this.renderList.forEach((i) => {
                    this.renderListHeightChange(i);
                });
            });
            break
        }
    }
},
列表的总高度要变成实时计算出来的
computed: {
    // 容器内占位的高度计算
    listHeight() {
        let height = 0;
        this.listAll.forEach((i) => {
            height += i.infiniteScrollHeight;
        });
        return height;
    },
},

点击查看完整代码

点击查看在线DEMO