输出内容才能更好的了解学习的常识,前文:
基于【虚拟列表】高性能烘托海量数据
基于【Event Loop】的长列表烘托优化

前言

在前文中咱们了解到:

  1. 在某种特别场景下,咱们需求将 很多数据 运用不分页的办法烘托到列表上,这种列表叫做长列表
  2. 因为事情循环的机制,一次性很多的烘托耗时较长,并且烘托期间会阻塞页面交互事情,所以咱们运用时刻分片机制将烘托分为屡次。
  3. 剖析实在业务场景,将悉数数据烘托到列表中是无用且浪费资源的行为,只需求依据用户的视窗进行部分烘托即可,所以运用到虚拟列表技能。

前文中咱们依据 “不管翻滚到什么方位,浏览器只需求烘托可见区域内的节点” 的思路完成了虚拟列表处理了长列表问题,但在一些细节和特别状况的处理上仍是有所短缺,例如:

  1. 高度不定的列表项会导致内容呈现错位、偏移等状况。
  2. 列表项含有异步资源,会在烘托后再次改动高度。
  3. 一次性很多数据的恳求导致恳求响应与数据处理时刻过长。

在本文中咱们就来一同研讨这些场景,并对原版的虚拟列表做出优化

假如觉得有收成还望咱们点赞、收藏

动态高度

剖析

在前文的虚拟列表完成中,列表项高度itemSize都是固定的。

// template -> list-item
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
// export defualt -> data
itemSize: 50,

因而很多直接与 列表项高度itemSize 关联的特点,都很简单核算:

  1. 列表总高度listHeight= listData.length * itemSize
  2. 当时窗口偏移量currentOffset= scrollTop – (scrollTop % itemSize)
  3. 列表数据的开端/完毕索引start/end= ~~(scrollTop / itemSize)
    . . . . . .

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成

但在实践状况中列表元素多为高度不固定的列表项,它或许是多行文本、图片之类的可变内容,如体系日志、微博等等。

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成

不固定的高度会导致上述的特点无法正常核算。

关于高度不固定的列表项,咱们遇到的问题如下:

  1. 如何获取实在高度?
  2. 相关的特点该如何核算?
  3. 列表烘托的办法有何改动?

计划

如何获取实在高度?

  • 假如能获得列表项高度数组,实在高度问题就很好处理。但在实践烘托之前是很难拿到每一项的实在高度的,所以咱们采用预估一个高度烘托出实在DOM,再依据DOM的实践状况去设置实在高度。

  • 创立一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在烘托后依据DOM实践状况更新缓存列表

相关的特点该如何核算?

  • 显然曾经的核算办法都无法运用了,因为那都是针对固定值规划的。

  • 于是咱们依据缓存列表重写 核算特点、翻滚回调函数,例如列表总高度的核算可以运用缓存列表最终一项的定位字段的值。

列表烘托的办法有何改动?

  • 因为用于烘托页面元素的数据是依据 开端/完毕索引 在 数据列表 中挑选出来的,所以只需确保索引的正确核算,那么烘托办法是无需改变的。

  • 关于开端索引,咱们将原先的核算公式改为:在 缓存列表 中查找第一个底部定位大于 列表笔直偏移量 的项并回来它的索引。

  • 关于完毕索引,它是依据开端索引生成的,无需修正。

完成

预估&初始化列表

先设置一个虚拟 预估高度preItemSize,用于列表初始化。

同时维护一个记载实在列表项数据的 缓存列表positions

data() {
    return {
        . . . . . .
        // 预估高度
        preItemSize: 50,
        // 缓存列表
        positions = [
            // 列表项目标
            {
                index: 0,  // 对应listData的索引
                top: 0,  // 列表项顶部方位
                bottom: 50,  // 列表项底部方位
                height: 50,  // 列表项高度
            }
        ]
    }
}

在创立组件时先用preItemSizepositions进行初始化,在后续更新时再进行替换。

created() {
    this.initPositions(this.listData, this.positions)
},
methods: {
    initPositions(listData, itemSize) {
        this.positions = listData.map((item, index) => {
            return  {
                index, 
                top: index * itemSize, 
                bottom: (index + 1) * itemSize,
                height: itemSize, 
            }
        })
    }
}

注:listData即数据列表,里边是每一项数据对应的内容。

列表总高度listHeight的核算办法改动为缓存列表positions最终一项的bottom

computed: {
      listHeight() {
          // return this.listData.length * this.itemSize;
+       return this.positions[this.positions.length - 1].bottom;
      },
}

更新实在数据

在每次烘托后,获取实在DOM的高度去替换positions里的预估高度

updated生命周期在数据改变视图更新往后触发所以能获取到实在DOM

咱们利用Vue的updated钩子来完成这一功用

期间遍历实在列表的每一个节点,对比 节点 和 列表项 生成高度差dValue判别是否需求更新:

updated() {
    this.$nextTick(() => {
        // 依据实在元素巨细,修正对应的缓存列表
        this.updatePositions()
    })
},
methods: {
  updatePositions() {  
      let nodes = this.$refs.items;
      nodes.forEach((node) => {
            // 获取 实在DOM高度
            const { height } = node.getBoundingClientRect();
            // 依据 元素索引 获取 缓存列表对应的列表项
            const index = +node.id
            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;
                  }
              }
          })
      }
}

此外在更新完positions后,当时窗口偏移量currentOffset也要依据实在状况从头赋值:

updated() {
    this.$nextTick(() => {
        // 依据实在元素巨细,更新对应的缓存列表
        this.updatePositions()
        // 更新完缓存列表后,从头赋值偏移量
        this.currentOffset = this.getCurrentOffset()
    })
},
methods: {
  updatePositions() {  //. . . }
  getCurrentOffset() {
      if(this.start >= 1) {
        this.currentOffset = this.positions[this.start - 1].bottom
      } else {
        this.currentOffset = 0;
      }
  }
}

重写翻滚回调

翻滚触发的回调函数里核算了 开端/完毕索引start/end 和 当时窗口偏移量currentOffset ,现在高度不固定后都需求从头核算,而完毕索引依赖于开端索引所以不需求修正。

从头核算 开端索引start

定高时咱们不必树立数组(树立了也仅仅重复的数据),直接依据scrollTopitemSize核算索引即可

this.start = ~~(scrollTop / this.itemSize);

但不定高时,只能带着scrollTop在列表中逐一寻觅(后续运用查找算法优化)。两个核算的最终目的都是找到当时方位对应的数据索引

列表数据开端索引start的核算办法修正为:遍历 缓存列表positions 匹配第一个大于当时翻滚距离scrollTop的项,并回来该项的索引。

mounted() {
    . . . . . .
    // 绑定翻滚事情
    let target = this.$refs.virtualList
    let scrollFn = (event) => this.scrollEvent(event.target)
    target.addEventListener("scroll",  scrollFn);
},
methods: {
    scrollEvent(target) {
      const scrollTop = target.scrollTop;
      // this.start = ~~(scrollTop / this.itemSize);
+   this.start = this.getStartIndex(scrollTop)
      this.end = this.start + this.visibleCount;
      this.currentOffset = scrollTop - (scrollTop % this.itemSize);
    },
    getStartIndex(scrollTop = 0) {
        let item = this.positions.find(item => item && item.bottom > scrollTop); 
        return item.index;
    }
},

从头核算 窗口偏移量currentOffset

翻滚后当即依据positions的预估值(此时数据还未更新)核算窗口偏移量currentOffset

scrollEvent() {
     . . . . . .
    // this.currentOffset = scrollTop - (scrollTop % this.itemSize);
    this.currentOffset = this.getCurrentOffset()  
},

优化

positions是遍历listData生成的,listData本是有序的,所以positions也是一个次序数组

Array.find办法 时刻复杂度 O(n)O(n),查找 索引start 功率较低 ❌

二分查找十分合适次序存储结构 时刻复杂度log2nlog_2{n},功率较高 ✔️

<script>
. . . . . .
varbinarySearch=function(list,target){  
constlen=list.length  
letleft=0,right=len-1
    let tempIndex = null
while(left<=right){  
letmidIndex=(left+right)>>1  
letmidVal=list[midIndex].bottom  
if(midVal===target){
            returnmidIndex  
        } elseif(midVal<target){
            left=midIndex+1  
        } else{
            // list不一定存在与target持平的项,不断收缩右区间,寻觅最匹配的项
            if(tempIndex === null || tempIndex > midIndex) {
                tempIndex = midIndex
            }
            right--
        }
}  
    // 假如没有查找到彻底匹配的项 就回来最匹配的项
returntempIndex  
};
export default {
    . . . . . .
    methods: {
        . . . . . .
        getStartIndex(scrollTop = 0) {
            // let item = this.positions.find(i => i && i.bottom > scrollTop); 
            // return item.index;
+          return binarySearch(this.positions, scrollTop)
        }
    },
}
</script>

运转查看一下作用,不定高问题现已处理了

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成

翻滚缓冲

剖析

上文中,为了正确核算不定高列表项,同时在 updated生命周期 和 翻滚回调 中添加了额外操作,这都添加了浏览器担负

因而快速翻滚列表时,咱们很明显的观察到白屏闪耀的状况,即翻滚后,先加载出白屏内容(没有烘托)然后敏捷替换为表格内容,制造出一种闪耀的现象。

注:白屏闪耀是浏览器性能低导致的,事情循环中的烘托操作没有跟上窗口的翻滚,额外操作仅仅加重了这种状况。

计划

为了使页面平滑翻滚,咱们在原先的列表结构上再参加缓冲区,烘托区域由可视区+缓冲区一起组成,这给翻滚回调和页面烘托更多处理时刻。

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成

用户在可视区翻滚,脱离可视区后当即进入缓冲区,同时烘托下一部分可视区的数据。在脱离缓冲区后新的数据大概率也烘托完成了。

而缓冲区域包含多少个元素呢?

咱们创立一个变量表示份额数值,这个份额数值是相关于 最大可见列表项数 的,依据这个 相对份额 和 开端/完毕索引 核算上下缓冲区的巨细

对烘托流程有什么影响?

列表显现数据 原先是依据索引核算,现在额外参加上下缓冲区巨细从头核算,会额外烘托缓冲元素。

完成

创立一个特点代表份额值:

data: {
    bufferPercent: 0.5, // 即每个缓冲区只缓冲 0.5 * 最大可见列表项数 个元素
},

创立三个核算特点,分别代表 缓冲区规范多少个元素 + 上下缓冲区实践包含多少个元素:

computed: {
    bufferCount() {
        return this.visibleCount * this.bufferPercent >> 0; // 向下取整
    },
    // 运用索引和缓冲数量的最小值 避免缓冲不存在或许过多的数据
    aboveCount() {
        return Math.min(this.start, this.bufferCount);
    },
    belowCount() {
        return Math.min(this.listData.length - this.end, this.bufferCount);
    },
}

重写 列表显现数据visibleData 的核算办法:

computed: {
    visibleData() {
        // return this.listData.slice(this.start, this.end);
+      return this.listData.slice(this.start - this.aboveCount, this.end + this.belowCount);
    },
}

因为多出了缓冲区域所以窗口偏移量currentOffset也要依据缓冲区的内容从头核算:

    getCurrentOffset() {
      if(this.start >= 1) {
        // return this.positions[this.start - 1].bottom;
        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完成

异步加载

其实在列表项中包含图片的场景,图片多为高度固定的缩略图,只需求在核算时依据图给每个列表项加一个固定高度,多于一行的图片直接省略。这样异步加载关于虚拟列表就没有影响了。

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成
假如实在要处理图片不定高的场景,只要在列表中的图片彻底加载后再从头更新positions了,利用Image.onloadDOM.resizeObserver在异步加载后回调翻滚函数。我试了下应该都是可行的。

    <div v-for="item in visibleData" :key="item.id" :id="item.id" ref="items" class="list-item">
        {{ item.value }}
        <img :src="item.img" @load="updatePositions" />
    </div>
or
mounted() {
    let content = this.$refs.content
    let resizeObserver = new ResizeObserver(() => this.updatePositions())
    resizeObserver.observe(content)
},

懒加载数据

一次性恳求很多数据或许会使后端处理时刻添加,过大的响应体也会导致全体恳求响应耗时添加,用户等待时刻较长体感较差。

因而咱们结合懒加载的办法,在每次翻滚触底时加载部分新数据并更新positions,避免单次恳求等待时刻过长。

// 翻滚回调
    scrollEvent(target) {
      const { scrollTop, scrollHeight, clientHeight } = target;
      this.start = this.getStartIndex(scrollTop);
      this.end = this.start + this.visibleCount;
      this.currentOffset = this.getCurrentOffset()
      // 触底
      if ((scrollTop +  clientHeight) === scrollHeight) {
          // 模拟数据恳求
          let len = this.listData.length + 1
          for (let i = len; i <= len + 100; i++) {
            this.listData.push({id: i, value: i + '字符内容'.repeat(Math.random() * 20) })
          }
          this.initPositions(this.listData, this.preItemSize)
      }
    },

有些同学或许会想,懒加载时初始数据量较少,会导致翻滚条很短,间接给用户一种数据量很少的错觉。

关于这种状况咱们需求跟后端做好和谐,接口回来的数据格式大致规定为这样

data: {
    page: 1,
    size: 1000,
    count: 10000,
    list: [1...1000],
    updateTime: '...',
    . . . . . .
}

然后运用data.count初始化positions,在后续懒加载到对应索引的数据时,替换positions里的内容。

总结

在最终咱们简单总结一下,为了优化虚拟列表咱们做了哪些操作。

  1. 不定高:因为很难在烘托之前拿到元素实在高度,咱们采取预估高度初始化后从头烘托的计划来正确烘托不定高内容。偏重写了翻滚回调函数和部分与itemSize相关的核算特点。
  2. 缓冲区:为了处理性能低时数据烘托不及时形成的白屏闪耀,咱们创立上下缓冲区额外烘托数据,为可视区的烘托提供更多缓冲时刻。为此要重写start/endcurrentOffset的核算办法。
  3. 异步加载:假如一定要处理列表异步加载不定高元素的场景,咱们通过img.onloadResizeObserver在加载完成后更新列表。
  4. 懒加载:一次性很多数据的恳求或许会导致恳求响应时刻变长,咱们运用触底加载新数据并更新positions的办法来分化单次恳求的数据量。

深化【虚拟列表】动态高度、缓冲、异步加载... Vue完成

题外话

因为怕一次性内容太多读者看着头疼 (便是我自己),所以将虚拟列表拆成两篇文章。

虽然在阅读连贯性上存在一定缺陷,但我觉得 一篇根底 + 一篇进阶 这种节奏仍是挺好的~

回想起我写作之路的第一篇文章便是与虚拟列表相关,回头一看现已一年了,不禁慨叹。

写作期间收成颇多,还有各位掘友的支持也让我有更多动力走下去 (激烈暗示)

结语

虚拟列表全体完成下来是十分考验代码实践才能和问题处理才能的,期望你能有所收成。

不要光看不实践哦,后续会持续更新前端相关的常识

脚踏实地不水文,真的不关注一下吗~

写作不易,假如觉得有收成还望咱们点赞、收藏

孤陋寡闻,如有问题或建议欢迎咱们指教。

参阅文章

# 「前端进阶」高性能烘托十万条数据(虚拟列表)