高性能渲染海量列表数据

前言

在某种特殊场景下,我们需要将 大量数据 使用不分页的方式渲染到列表上,这种列表叫做长列表。
长列表直接渲染会造成页面阻塞给用户带来不好的体验,一般有两种解决方式:时间分片 和 虚拟列表。

时间分片的缺陷

  • 效率低时间分片相当于代码替用户去触发懒加载,伴随着 事件循环 逐次的渲染DOM,渲染消耗的总时间肯定比一次渲染所有DOM多不少。
  • 不直观因为页面是逐渐渲染的,如果用户直接把滚动条拖到底部看到的并不是最后的数据,需要等待整体渲染完成。
  • 性能差实际开发出的代码不是一个<tr> or <li>标签加数据绑定这么简单,随着 dom 结构的复杂(事件监听、样式、子节点...)和 dom 数量的增加,占用的内存也会更多,不可避免的影响页面性能。

分析真实业务场景,用户正常情况下是不会去浏览全部数据的。因此除特殊情况外,将全部数据渲染到列表中是无用且浪费资源的行为,我们只需要根据用户的视窗进行部分渲染即可,而这就要用到下文的虚拟列表。

虚拟列表概念

虚拟列表是上述问题的一种解决方案,是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗。
虚拟列表将完整的列表分为三个区域:虚拟区 / 缓冲区 / 可视区。

  • 虚拟区为非可见区域不进行渲染。
  • 缓冲区为后续优化滚动白屏使用,暂不渲染。
  • 可视区为用户视窗内的数据,需要渲染对应的列表项。

虚拟列表实现

假设列表 可见区域 的高度为 500px,列表项高度为 50px。
初始化时列表里有1w条数据本来需要同时渲染,但列表区域中最多只能显示 500 / 50 = 10 条数据,那么在首次渲染列表时只需要加载前10条。
当列表发生滚动,计算 视窗偏移量 获得 开始索引,再根据索引获得此时可见区域内用于渲染的列表数据范围。
例如当前滚动条距离顶部150px,那么可见区域内的列表项为第 4(1 + 150 / 50) 项 至 第 13(10 + 3) 项。
无论滚动到什么位置,浏览器只需要渲染可见区域内的节点。
本文代码基于Vue,实现虚拟列表的关键点主要分为 1.模拟完整列表的页面结构调整 2.总结过程中的参数和计算公式 3.添加滚动回调时的操作。

页面结构

container:列表容器,监听phantom元素的滚动条,判断当前用于渲染的列表数据范围。
phantom:占位元素,为了保持列表容器的 真正高度 并使滚动能够正常触发,我们专门使用一个div来占位生成滚动条。
content:渲染区域,用户真正看到的页面内容,一般由 缓冲区 + 可视区 组成。

<!-- 可视区域的容器 -->
<div class="container" ref="virtualList">
    <!-- 占位,用于形成滚动条 -->
    <div class="phantom"></div>
    <!-- 列表项的渲染区域 -->
    <div class="content">
        <!-- item-1 --> 
        <!-- item-2 --> 
        <!-- ...... --> 
        <!-- item-n -->
    </div>
</div>

参数与计算

已知数据:
● 假定可视区域高度固定,称为 screenHeight;
● 假定列表每项高度固定,称为 itemSize;
● 假定列表数据称为 listData;
● 假定当前距离顶部偏移量称为 scrollTop;
可推算出:
● 列表总高度 listHeight = listData.length * itemSize;
● 可见列表项数 visibleCount = Math.ceil(screenHeight / itemSize);
● 数据的起始索引 start = Math.ceil(scrollTop / itemSize);
● 数据的结束索引 end = startIndex + visibleCount;
● 列表显示数据为 visibleData = listData.slice(start, end);
export default {
  ......
  props: {
    listData:{
      type:Array,
      default: () => []
    },
    itemSize: {
      type: Number,
      default: 200
    }
  },
  computed:{
    // 列表总高度
    listHeight() {
      return this.listData.length * this.itemSize;
    },
    // 可显示的列表项数
    visibleCount() {
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    // 获取真实显示列表数据
    visibleData() {
      return this.listData.slice(this.start, this.end);
    }
  },
  mounted() {
    // 初始化数据
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      screenHeight:0, // 可视区域高度
      start:0,        // 起始索引
      end:null,       // 结束索引
    };
  },
};

窗口滚动

容器滚动绑定监听事件,当滚动后,我们要获取 距离顶部的高度scrollTop ,然后计算 开始索引start 和 结束索引end ,根据他们截取数据,并计算 当前偏移量currentOffset 用于将渲染区域偏移至可见区域中 。

export default {
  ...
  mounted() {
      ...
      this.$refs.virtualList.addEventListener('scroll', event => this.scrollEvent(event.target))
  },  
  data() {
    return {
      ...
      curretnOffset: 0, // 当前偏移量
    };
  },
  ...
  methods: {
    scrollEvent(target) {
      //当前滚动位置
      let scrollTop = target.scrollTop;
      //此时的开始索引
      this.start = ~~(scrollTop / this.itemSize);
      //此时的结束索引
      this.end = this.start + this.visibleCount;
      //此时的偏移量
      this.currentOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
  ...
}

完整代码实现

<template>
  <div class="container" ref="virtualList">
    // 占位元素
    <div class="phantom" :style="{ height: listHeight + 'px' }"></div>
    // 渲染区域
    <div
      class="content"
      :style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
        class="list-item"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      listData: [],
      itemSize: 50,
      screenHeight: 0,
      currentOffset: 0,
      start: 0,
      end: 0,
    };
  },
  mounted() {
    for (let i = 1; i <= 1000; i++) {
      this.listData.push({id: i, value: '字符内容' + i})
    }
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
    this.$refs.virtualList.addEventListener("scroll", (event) =>
      this.scrollEvent(event.target)
    );
  },
  computed: {
    listHeight() {
      return this.listData.length * this.itemSize;
    },
    // 渲染区域元素数量
    visibleCount() {
      return Math.ceil(this.screenHeight / this.itemSize);
    },
    visibleData() {
      return this.listData.slice(this.start, this.end);
    },
  },
  methods: {
    scrollEvent(target) {
      const scrollTop = target.scrollTop;
      this.start = ~~(scrollTop / this.itemSize);
      this.end = this.start + this.visibleCount;
      this.currentOffset = scrollTop - (scrollTop % this.itemSize);
    },
  },
};
</script>

<style scoped>
.container {
  position: relative;
  height: 90vh;
  overflow: auto;
}
.phantom {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
}
.content {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  text-align: center;
}
.list-item {
  padding: 10px;
  border: 1px solid #999;
}
</style>

优化

滚动发生后,scroll回调会频繁触发,但并不是每一次回调都是有效的。很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。(滚动一下会触发几十次)。

防抖与节流

我们通过 节流函数 来限制触发频率,通过 防抖函数 保证最后一次滚动的回调正确的进行。

export default {
  ...
  mounted() {
    ...
    // 绑定滚动事件
    let target = this.$refs.virtualList
    let scrollFn = (event) => this.scrollEvent(event.target)
    let debounce_scroll = lodash.debounce(scrollFn, 320)
    let throttle_scroll = lodash.throttle(scrollFn, 160)
    target.addEventListener("scroll",  debounce_scroll);
    target.addEventListener("scroll",  throttle_scroll);
  },
  ....
 }

更好的API

也可以使用IntersectionObserver替换监听scroll事件。
IntersectionObserver可以监听目标元素是否出现在可视区域内,并异步触发监听回调,不随着目标元素的滚动而触发,性能消耗极低。

// 调用构造函数 IntersectionObserver 生成观察器  
const myObserver = new IntersectionObserver(callback, options);  

构造函数的返回值是一个 观察器实例 。
IntersectionObserver 接收两个参数。
callback: 可见性发生变化时触发的回调函数。
options: 配置对象(可选,不传时会使用默认配置)。

  // 开始观察 元素是否到可视区
  myObserver.observe(document.getElementByIdx_x('example'));

  // 停止观察
  myObserver.unobserve(element);

  // 关闭观察器
  myObserver.disconnect();

遗留的问题

  • 动态高度
    多行文本、图片之类的可变内容,会导致列表项的高度并不相同。
    解决方法: 以预估高度先行渲染,然后获取真实高度并缓存。
  • 白屏闪烁
    回调执行也有执行耗时,如果滑动过快会出现白屏/闪烁的情况。为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,给滚动回调一些缓冲时间。
  • 内存占用
    一次性请求大量数据可能会使后端处理时间增加,过大的响应体也会导致请求中Content Download 耗时增加,建议通过请求接口分片获取渲染数据。

版权声明:
作者:Lei钟意
链接:https://leixf.cn/archives/870
来源:跃动指尖
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>
文章目录
关闭
目 录