虚拟滚动那些事儿

4,410 阅读5分钟

背景

在APP使用hybrid架构后,APP内页面绝大部分都由原生转为h5实现。在处理APP内长列表(如评论列表等)时,若元素数量过多,DOM节点也会随之增加,降低整个页面的性能,所以就有了这个虚拟滚动轮子的应用场景(基于Vue)。

参考

一个Vue核心开发者的轮子:Akryum/vue-virtual-scroller
这个轮子考虑的情况十分周全,给出了列表项高度已知且一致、列表项高度已知但不一致、列表项高度未知三种情况的解决方案,三种情况的性能也是递减的。当已知的信息越多,能做的优化也越多,性能更好。
作者基于最理想的情况:使用者渲染的列表高度一致且已知时,要求使用者传入列表项的同一高度,完成了基础组件RecycleScroller。而后对于高度已知但不保证每一项高度一致、完全不知道每一项高度的情况基于基础组件做了进一步运算,有种优雅降级的感觉。
而最后一种情况,则是在运行时实时计算项的真实高度,则有一种动态语言运行时的感觉。相应地,当运行前直到的信息越多,就越能优化,提高性能又可以类比到静态语言编译时优化的概念,十分有趣。

轮子的具体用法如下:

<template>
  <DynamicScroller
    :items="items"
    :min-item-size="54"
    class="scroller"
  >
    <template v-slot="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[
          item.message,
        ]"
        :data-index="index"
      >
        <div class="avatar">
          <img
            :src="item.avatar"
            :key="item.avatar"
            alt="avatar"
            class="image"
          >
        </div>
        <div class="text">{{ item.message }}</div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

<script>
export default {
  props: {
    items: Array,
  },
}
</script>

<style scoped>
.scroller {
  height: 100%;
}
</style>

可以看到,在不知道项实际高度的情况下,必须使用DynamicScrollerItem组件包裹你的内容,以计算项的实际高度。

效果图

实际图
说明:当滚动到二百多个元素时,只渲染了十几个DOM

原理

  1. 将列表的包裹元素高度设为所有项高度的大概总和,撑起滚动条
  2. 只渲染视口和视口上下一段缓冲区内的元素,渲染最少的DOM
  3. 单项使用absolute定位+translateY来定位到正确位置
  4. 滚动时,切换数据,根据数据项类型尽量复用DOM,若无可复用DOM,追加新DOM
    原理流程图

意外

在我开发完自己的版本后,在测试性能的过程中,我发现在元素数量达到2000个时我的组件性能明显低于官方版,滚动时CPU一度飙升至80%、90%,导致出现白屏(掉帧)。而官方版在同样的条件下CPU使用率还是风轻云淡的20%~30%,且十分流程(FPS稳如狗),所以我想找出我的组件性能瓶颈在哪。

分析

Chrome Performance

先上Chrome中Performance看板的概览图:

performance profile

这滚烫的CPU和惨淡的FPS...
再选择一小段CPU跑满的时间段,看看在干啥:

Call Stack

itemsWithSize是一个计算属性,返回一个数组,数组中的每一项与用户传入的数据项一一对应,只不过加上了此项的真实DOM高度。由图可知,这个计算属性的计算耗时巨大,于是我肉眼diff了一下我的版本和官方版的区别:

Diff

我少了上图标红的两行,每一次都从this上直接取值,那么在元素个数2000个时,这样做性能会掉多少呢:

Compare

接近两百倍

优化原理

为什么先将变量存储起来读取会比经过vue读取更快,我还没有找到原因,实验后只知道与是否为const关系不大,const与var的区别应该是另一个维度的事情了。这200倍的差距比较吓人,但定量的话每次计算差200ms左右,这个计算属性一次计算会循环2000次,每次从this中读取5次变量的值,也就是10000次读取操作慢200ms,一次读取的计算速度慢了20μs,差别也并不大。只是在这个场景下暴露出来而已,具体原因可能是vue内部一些步骤额外耗时了

一些想法

在开(chao)发(xi)这个轮子的过程中,我遇到了一个vue框架内部的报错,卡了很长时间。这不是第一次遇到这种问题了,在之前写其他组件时也会遇到框架内部一些看不懂、很难追溯的错误,运气好的话可以从堆栈信息中找到自己认识的函数去debug。但往往这种错误嵌套的很深,逻辑上与看上去直观的网页相悖,根本无从下手。

我不禁想目前前端开发都在向使用框架管理的方向演进,这样开发一个业务逻辑简单的网站时很方便也很快。但是一方面,框架也引入了更大的复杂度,如果业务逻辑本身就已经很复杂了,在开发时还要考虑框架内部的逻辑就很费劲了。另一方面,当我想搭建一个简单的网站——比如自己的博客时,起手一个vue create就是一个上百兆的项目诞生了...这些开发体验让我觉得在前端“模块化”、“工程化”的路上或许还有一些小坑待填。