【不深不浅】前端长列表优化

2,639 阅读9分钟

最近接到一个优化需求是对于钉钉小程序列表优化(框架uni-app),导致出现长列表渲染缓慢原因大致有两点

  • 业务需求导致无法使用分页,从而一次性渲染列表页面
  • 开发过程中,没有考虑后续业务发展,数据量增多,导致的渲染缓慢

页面分析

  • 列表的区域在红框内滚动(可视区域)
  • 列表数据结构比较复杂,有两层循环,且每一个蓝色框根据绿色子组件个数而导致高度不确定
  • 子组件复杂有操作和页面最底还有统计

方法研究

处理长列表有两种方式

  1. 滚动加载,这种方式就是经常提及的无限加载,需要后端接口的配合,这种方式的好处就是一次性请求接口的数据少,前端渲染压力小,页面访问速度加快,但是这种改进方式需要前后端的配合,并且很难符合产品的业务预期,显然这个改动大,所以暂不考虑
  2. 虚拟列表,只渲染可视区域,从上图分析只渲染用户屏幕除去红框外高度的区域,保证用户第一时间看到渲染后界面,通过监听滚动事件,更新可视区域的列表项,其他超出红框外区域不渲染。

实现思路

首先简单的说一下虚拟列表的定义 :虚拟列表是按需显示思路的一种实现,即虚拟列表是一种根据滚动容器元素的可视区域来渲染长列表数据中某一个部分数据的技术。

我们通过下面一张图来简单理解一下这个概念

Untitled 1.png

简单的说就是因为 DOM 元素的创建和渲染需要的时间成本很高,在数据量过多和元素复杂的情况下,完整渲染列表所需要的时间会不可控。所以只对「可见区域」进行渲染,可以达到极高的初次渲染性能

实现虚拟列表就是在处理用户滚动时,要改变列表在可视区域的渲染部分,其具体步骤如下:

  • 计算当前可见区域起始数据的 startIndex
  • 计算当前可见区域结束数据的 endIndex
  • 计算当前可见区域的数据,并渲染到页面中
  • 计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上

优化前

上面描述了思路,还有一个做之前需要测试的就是优化前的渲染速度需要了解,方便后续优化后的对比

条数 优化前
4条并且每四条下面还有1-2个子条数 2226ms
30条并且平均每条下面有4-5个子条数 12312ms

由于是在钉钉小程序上开发调试的 ,阿里的小程序开发者工具目前只有支付宝小程序才有实验室和Chrome Devtool Performance ,钉钉小程序暂时没有所以数据统计只是在uni-app生命周期中onLoad和页面渲染完成之后的时间差进行粗略的对比(其中包含接口请求时间),后续优化后也是按照这种统一纬度。

布局和实现

<template>
<!-- 可视区域 uni-app的滚动,因为需要监听scroll所以采用scroll-view ,
别框架可以监听更换 -->
    <scroll-view 
         class="list-view" 
         @scroll="handleScroll" 
         :style="{
            height:viewHeight + 'px'
         }"
         scroll-y>
		      <!-- 可滚动区域 -->
        <view class="list-view-phantom" :style="{
              height:contentHeight
          }"></view>
		      <!-- 可视区域的插槽 -->
        <view ref="content" class="list-view-content" :style="{
          paddingTop:_translate+"px"
        }">
            <slot name="list"></slot>
        </view>
      
    </scroll-view>
</template>

样式

<style lang="scss" scoped>

.list-view {
    overflow-y: auto;
    overflow-x: hidden;
    position: relative;
}

.list-view-phantom {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1;
}

.list-view-content {
    left: 0;
    right: 0;
    top: 0;
    position: absolute;
}

</style>

JavaScript 代码如下

export default {
        name: "VirtualListView",

        props: {
            data: {
                type: Array,
                default: []
            },
            // 可视区域高度
            viewHeight:{
                type: Number,
                default: 0
            },
            /**
             *itemHeader,itemHeight * 数量,otherHeight 总和就是蓝色框组件的高度
             *itemHeader蓝色标题高度
             *itemHeight sku列表单个高度(绿色框)
             *其余高度 (margin值等等)
             */
            itemHeader: {
                type: Number,
                default: 91
            },
            itemHeight: {
                type: Number,
                default: 79.5
            },
            otherHeight:{
                type: Number,
                default: 8
            },
        },
        data() {
            return {
                visibleData: [],  //列表数据
                estimatedItemSize: 20, // 估值
                lastMeasuredIndex: -1, // 缓存变量
                startIndex: 0, // 计算当前可见区域起始数据
                sizeAndOffsetCahce: {}, // 缓存对象
                _translate: 0, //startOffset 高度
            };
        },
        mounted() {
            this.updateVisibleData();
        },
        computed: {
            // 计算可滚动区域的高度
            contentHeight() {
                //....
            }
        },
        methods: {
            /**
             *updateVisibleData主要就是用来更新所需要的
             *当前显示的visibleData数据
             *_translate ,startOffset 高度
             */
            updateVisibleData(scrollTop) {
                //....
            },
            /**
             *handleScroll 监听Scroll更新updateVisibleData函数
             */
            handleScroll(e) {
                //throttle 关于节流函数这里就不说了
                throttle(() => {
                    const scrollTop = (this.old.scrollTop = e.detail.scrollTop);
                    this.updateVisibleData(scrollTop);
                }, 100)();
            }
        },
    }

基础的伪代码就已经大概就已经有了,接下来就是完成需求写逻辑了

首先的我们要把计算属性 contentHeight 补充完整,因为它是计算滚动区域的真正高度

        computed: {
            contentHeight() {
                /**
                 * 最简单粗暴的方法就是遍历,文章后面再想方法优化
                 */
                const  {  data,  itemSizeGetter  }  =  this;
                let  total  =  0;
                for  (let  i  =  0,  j  =  data.length;  i  <  j;  i++)  {
                    total  +=  itemSizeGetter.call(null,  data[i],  i);
                }
                return  total  +  "px";
            }
        },
        methods:{
            /**
             * 计算每个item的高度
             */
            itemSizeGetter(item, index) {
                return item.skuList.length * this.itemHeight +
                    this.itemHeader + this.otherHeight;
            },
        }

完成了可滚动区域的高度计算 ,现在就差可视区域的visibleData数据和startOffset 高度计算了

接下来我们就来完善updateVisibleData函数

	methods: {
                /**
                 *updateVisibleData主要就是用来更新所需要的
                 *当前显示的visibleData数据
                 *_translate ,startOffset 高度
                 */
                updateVisibleData(scrollTop) {
                    scrollTop = scrollTop || 0;
                    // 计算开始和结束的startIndex和endIndex
                    const startIndex = this.findNearestItemIndex(scrollTop) >=
                    this.data.length ?
                        this.findNearestItemIndex(scrollTop)-1:
                        this.findNearestItemIndex(scrollTop);
                    const endIndex = this.findNearestItemIndex(
                        scrollTop + this.viewHeight * 1
                    );
    
                    //startOffset 高度
                    this._translate = this.getItemSizeAndOffset(
                        startIndex
                    ).offset
    
                    // 当前显示的visibleData数据
                    this.visibleData = this.data.slice(
                        startIndex,
                        Math.min(endIndex + 1, this.data.length)
                    );
    
                    //方案一
                    //this.$refs.content.style.webkitTransform = `translate3d(0, ${_translate}px, 0)`;
                    //方案二 用paddingTop
                },
                /**
                 *通过scrollTop 来计算出这个位置的元素索引
                 */
                findNearestItemIndex(scrollTop){
                    const { data, itemSizeGetter } = this;
                    let total = 0;
                    for (let i = 0, j = data.length; i < j; i++) {
                        const size = this.getItemSizeAndOffset(i).size;
                        total += size;
                        if (total >= scrollTop || i === j -1) {
                            return i;
                        }
                    }
    
                    return 0;
                },
                /**
                 *通过索引 获取元素的信息
                 */
                getItemSizeAndOffset(index) {
                    const { data, itemSizeGetter } = this;
                    let total = 0;
                    for (let i = 0, j = Math.min(index, data.length - 1); i <= j; i++) {
                        const size = itemSizeGetter.call(null, data[i], i);
                        if (i === j) {
                            return {
                                offset: total,
                                size
                            };
                        }
                        total += size;
                    }
    
                    return {
                        offset: 0,
                        size: 0
                    };
                }
            }

这里说一下 code里面的方案一 和方案二

  • 方案一,下拉过程中碰到visibleData更新数据的时候,也就是临节点会出现闪烁,原因是由于content这个DOM跟随这scroll向上移动的时候,触发updateVisibleData函数开始渲染新的展示列表,然后计算新的startOffset高度,用css的Transform属性将其向下定位到startOffset的高度,这样一上一下如果组件相对复杂,渲染需要时间,就会出现肉眼所感知的闪烁,虽然使用了translate3d但是效果也不是很明显。(放弃)
  • 方案二:为了避免出现上诉问题,我想法是采用paddingTop,支撑计算startOffset高度。

上诉基本满足了虚拟列表的需求,但是其中看到很多重复性的查找,对于数据量大的话,性能也不太理想,我们接着优化

优化两个方面

优化从两个方面入手:

  1. 引入缓存(记录已经展示过数据的状态和值,避免重复计算)
  2. 算法优化(之前顺序查找时间复杂度为 O(N)。实际上列表项的计算结果天然就是一个有序的数组,可以使用二分查找来优化已缓存的结果的搜索性能,把时间复杂度降低到 O(lgN))

缓存

存储缓存结果的变量叫做 sizeAndOffsetCahce,类型为对象

记录最后一个缓存的下标为 lastMeasuredIndex,默认值为 -1

1:修改索引搜索增加缓存

        methods: {
            /**修改增加缓存
             *通过索引 获取元素的信息
             */
            getItemSizeAndOffset(index) {
                const {
                    lastMeasuredIndex,
                    sizeAndOffsetCahce,
                    data,
                    itemSizeGetter
                } = this;

                if (index == data.length) {
                    return sizeAndOffsetCahce[data.length - 1];
                }

                // 如果当前在缓存里面返回缓存数据
                if (lastMeasuredIndex >= index) {
                    return sizeAndOffsetCahce[index];
                }

                let offset = 0;
                // 读取已有缓存的内容
                if (lastMeasuredIndex >= 0) {
                    const lastMeasured = sizeAndOffsetCahce[lastMeasuredIndex];
                    if (lastMeasured) {
                        offset = lastMeasured.offset + lastMeasured.size;
                    }
                }

                for (let i = lastMeasuredIndex + 1; i <= index; i++) {
                    const item = data[i];
                    const size = itemSizeGetter.call(null, item, i);
                    sizeAndOffsetCahce[i] = {
                        size,
                        offset
                    };
                    offset += size;
                }

                if (index > lastMeasuredIndex) {
                    this.lastMeasuredIndex = index;
                }
                return sizeAndOffsetCahce[index];
            },

        }

2:修改计算属性 contentHeigh,可视区域一开始是把高度精准的计算出来,实际是没有必要的, 我们可以先计算出一部分,没有出现的节点高度可以预设一个值(estimatedItemSize),通过不断的下拉来修正可视区域的高度。

        computed: {
            contentHeight(){
                const { data, lastMeasuredIndex, estimatedItemSize } = this;
                let itemCount = data.length;
                if (itemCount) {
                    if (lastMeasuredIndex >= 0) {
                        const lastMeasuredSizeAndOffset = this.getLastMeasuredSizeAndOffset();
                        return (
                            lastMeasuredSizeAndOffset.offset +
                            lastMeasuredSizeAndOffset.size +
                            (itemCount - 1 - lastMeasuredIndex) *
                            estimatedItemSize +
                            "px"
                        );
                    } else {
                        return itemCount * estimatedItemSize + "px";
                    }
                } else {
                    return 0 + "px";
                }
            }
        },
        methods: {
            //  增加计算过高度的列表项的高度和
            getLastMeasuredSizeAndOffset() {
                return this.lastMeasuredIndex >= 0 ? this.sizeAndOffsetCahce[this.lastMeasuredIndex] : { offset: 0, size: 0 };
            }
        }

算法优化

算法优化 修改 findNearestItemIndex 方法,对于已缓存的结果使用二分查找,未缓存的Exponential Search(指数搜索)

		/**
		* 已缓存的二分查找
		*/
	 binarySearch(low, high, offset) {
                let index;
    
                while (low <= high) {
                    const middle = Math.floor((low + high) / 2);
                    const middleOffset = this.getItemSizeAndOffset(middle).offset;
                    if (middleOffset === offset) {
                        index = middle;
                        break;
                    } else if (middleOffset > offset) {
                        high = middle - 1;
                    } else {
                        low = middle + 1;
                    }
                }
    
                if (low > 0) {
                    index = low - 1;
                }
    
                if (typeof index === "undefined") {
                    index = 0;
                }
    
                return index;
            }
        }, 
            /**
            *未缓存的Exponential Search(指数搜索)
            */
	        exponentialSearch(scrollTop) {
                let bound = 1;
                const data = this.data;
                const start =
                    this.lastMeasuredIndex >= 0 ? this.lastMeasuredIndex : 0;
                while (
                    start + bound < data.length &&
                    this.getItemSizeAndOffset(start + bound).offset < scrollTop
                    ) {
                    bound = bound * 2;
                }
                return this.binarySearch(
                    start + Math.floor(bound / 2),
                    Math.min(start + bound, data.length),
                    scrollTop
                );
            },
				/**
				* 修改后的findNearestItemIndex
				* 通过scrollTop 来计算出这个位置的元素索引
				*/
			findNearestItemIndex(scrollTop) {
                        const {data, itemSizeGetter} = this;
                        const lastMeasuredOffset = this.getLastMeasuredSizeAndOffset()
                            .offset;
                        if (lastMeasuredOffset > scrollTop) {
                            return this.binarySearch(0, this.lastMeasuredIndex, scrollTop);
                        } else {
                            return this.exponentialSearch(scrollTop);
                        }
                    },
   
	

优化后效果

条数 优化前 优化后
4条并且每四条下面还有1-2个子条数 2226ms 604ms
30条并且平均每条下面有4-5个子条数 12312ms 663ms

从上面的时间纬度对比,效果还是很明显的,不会再根据数量增多而导致渲染时间增多了。


最后思考:

1.还有哪些优化空间?

2.修改计算属性 contentHeigh,采用的是先计算出一部分,没有出现的节点高度可以预设一个值(estimatedItemSize),通过不断的下拉来修正可视区域的高度。这个估算高度设计初衷是好的,让加载速度更快,但是滑动过程中会有肉眼可见滚动条突然变化甚至“跳跃”。如何抉择?