剖析无限滚动虚拟列表的实现原理

1,357 阅读2分钟

对于长列表的渲染,一般是才采用分页或者懒加载的方式,下拉到底部又向后端请求数据,每次只加载一部分数据,但是随着加载的数据越来越多。页面的Dom在无限增加中,给浏览器带来负担,整个滑动也会出现卡顿。

解决方案:虚拟列表

虚拟列表其实是按需显示的一种体现。只对可视区进行渲染,对于非可视区数据不渲染或部分渲染,减轻浏览器负担,提升渲染性能。

对于首次渲染,可根据可视区高度 ÷ 单个列表项高度 = 一屏需要渲染的列表个数。
当滚动发生时,记录滚动距离,根据滚动距离和单个列表项高度,可知道当前可视区域开始索引。同时,为了营造出滚动效果,列表区域,设置transform属性的translate的Y值为 scrollTop - (scrollTop % itemSize) (当滚动到某数据项的中间时,transform的y值不包括该数据项)

总结:虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。Dom不变,数据改变。规避了分页和懒加载会让Dom无限增加的缺点。

两种场景的具体实现:

1. 定高场景

监听滚动事件拿到scrollTop,计算监听拿到start,end的index,用this.listData.slice( this.start, Math.min(this.end, this.listData.length) 去切换可视区域的数据,动画偏移量startOffset: translate3d(0,${this.startOffset}px,0);


<template>
  <div class="container" ref="list" @scroll="handleScroll()">
    <div class="phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="list" :style="{ transform: getTransform }">
      <div
        ref="items"
        class="list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>
<script>
// 需要接收listData以及每个列表项的高度
export default {
  name: "VirtualList",
  props: {
    listData: {
      type: Array,
      default: () => [],
    },
    itemSize: {
      type: Number,
      default: 200,
    },
  },
  data() {
    // 使用return是因为一个组件可以被多次实例化,data如果是对象形式,则该组件所有实例的data都指向同一地址,一个实例对data的修改会影响所有实例。
    return {
      // 可视区域高度
      screenHeight: 0,
      // 偏移量
      startOffset: 0,
      // 开始索引
      start: 0,
      // 结束索引
      end: null,
    };
  },
  computed: {
    // 列表总高度
    listHeight() {
      return this.listData.length * this.itemSize;
    },
    // 可显示的列表数目
    visibleCount() {
      // Math.ceil向上取整
      return Math.ceil(this.screenHeight / this.itemSize);
    },
    // 获取渲染区数据
    visibleData() {
      // 兼容数据不足一屏的情况
      return this.listData.slice(
        this.start,
        Math.min(this.end, this.listData.length)
      );
    },
    // 偏移量对应的style
    getTransform() {
      return `translate3d(0,${this.startOffset}px,0)`;
    },
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },

  methods: {
    // 监听scroll,获取滚动位置scrollTop
    handleScroll() {
      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);
      console.log("scrollTop", scrollTop);
      console.log("startOffset", this.startOffset);
    },
  },
};
</script>
<style scoped>
.container {
  width: 100vw;
  height: 100%;
  overflow: auto;
  position: relative;
}
.phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}
.list-item {
  padding: 10px;
  box-sizing: border-box;
  border-bottom: 1px solid black;
}
</style>

2.不定高场景

  • 随心所欲的scrol,存在性能问题
  • 可以通过active和定时器的方式去限流,存在不改变,200ms后也触发,还要清空
if(active===false){
   active=true;
   setTimeout(()=>{},200) 
}
  • 采用IntersectionObserver

如何获得元素的动态高度?

现阶段 ECMA DOM 规范下,有两个 API 可以达到这个目的:MutationObserver和 ResizeObserver

这两个 API 都存在一定的兼容性问题,caniuse#ResizeObserver | caniuse#MutationObserver,可以使用对应的polyfill进行解决,因为ResizeObserver可以更直观地达到监听元素高度变动的目的,所以这里选择使用ResizeObserverResizeObserver的 polyfill

如何模拟「可滚动高度」?

当前已渲染的元素高度+剩下没渲染的列表数*元素平均高度

  // ...
  data() {
    return {
      // ...
      // scrollRunwayEnd: 0,
    };
  },
  computed: {
    scrollRunwayEnd() {
      // 根据当前已渲染的元素高度,求得当前所有元素总高度
      const maxScrollY = this.cachedHeight.reduce((sum, h) => (sum += h || ESTIMATED_HEIGHT), 0);
      // 根据当前所有元素总高度,求得元素平均高度
      const currentAverageH = maxScrollY / this.cachedHeight.length;
      // 返回估算高度
      return maxScrollY + (this.listData.length - this.cachedHeight.length) * currentAverageH;
    },
  },
  // ...

如何计算每一个元素的「scrollY」?

参考: lkangd.com/post/virtua…

参考: zhuanlan.zhihu.com/p/34585166

参考:react-virtualized、react-window