下拉加载和虚拟滚动

3,050 阅读5分钟

1. 无限加载

我们在平常开发的时候有时间会遇到上拉加载的需求,比如我们在浏览手机上的文章或者购物的时候都有类似的使用场景。不管是在移动端还是在PC端,其实对于开发者来说都有很多现成的库或者组件去使用,我们只需要按照文档接入就可以实现这样的需求。但是在现在如此“卷”的背景下,我们怎么能不去了解它的实现原理呢!

1. 分析

  • 示例 2021-12-29 21.46.23.gif

  • 分析

    • 从上面的视频中可以看到,当我们滑动的时候在有数据并且触底的时候(超过一屏),就会去加载新的数据。其实就是分页的另一种表现形式

    • 根据上面的情况我们可以获取两个重要的信息点

      • 下滑触底
      • 加载数据
    • 这样我们就实现了一个下拉加载的功能

    光说不练假把式,下面让我们尝试着实现一下吧

2. 原理

  • 我们先看一下上面两个重要功能点怎么实现

    • 加载数据:就是我们平常请求数据的接口(或者其他的一些callback)

    • 下滑触底:其实这个也是由两个功能点组成的

      • 下滑:addEventListener('scroll'),监听scroll事件。

      • 触底:clientHeight + scrollTop >= scrollHeight,当滚动的距离加上自身的高度大于总高度就算触底了。

        • clientHeight: 它是元素内部的高度(单位像素),包含内边距,但不包括水平滚动条、边框和外边距。
        • scrollTop: 是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。
        • scrollHeight: 只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。
  • 代码实现

    代码使用vue3实现(不管什么框架,道理是一样的)

    • 首先模拟后端返回数据

       // 结果数据
       const resultData = (new Array(100)).fill(1).map((val, index)=>index + 1);
       ​
       // 模拟后端动态返回
       function getLoadMoreList(pageNum, pageSize) {
         let start = (pageNum - 1) * pageSize;
         const end = start + pageSize;
         const list = resultData.slice(start, end);
         return new Promise((resolve) => {
           setTimeout(() => {
             resolve(list);
           }, 1000);
         });
       };
      
    • 判断是否触底

       el.addEventListener('scroll', ()=>{
         if(el.scrollTop + el.clientHeight >= el.scrollHeight - threshold){
           console.log('触底了');
         }
       })
      
    • 整体实现

       <!-- 外部显示的容器 -->
       <div class="scrollWrapper" ref="target">
         <!-- 放整体数据的容器 -->
         <div class="scrollContainer">
           <div class="item" :key="item" v-for="item in listData">
             {{ item }}
           </div>
           <img v-if="!noMore" src="http://www.sucaijishi.com/uploadfile/2013/0527/20130527034920497.gif" alt="">
           <div v-else>没有更多数据了</div>
         </div>
       </div>
       ​
       <script setup>
         import { reactive, onMounted, ref } from 'vue';
         
         // 模拟后端动态返回
         const resultData = (new Array(100)).fill(1).map((val, index)=>index + 1);
         function getLoadMoreList(pageNum, pageSize) {
           let start = (pageNum - 1) * pageSize;
           const end = start + pageSize;
           const list = resultData.slice(start, end);
           return new Promise((resolve) => {
             setTimeout(() => {
               resolve(list);
             }, 1000);
           });
         };
         
         // 一页多少条数据
         const pageSize = 15;
         // 距离底部多少开始请求数据(px)
         const threshold = 100; 
         // 展示的数据
         const listData = reactive([]);
         // dom
         const target = ref(null);
         // 是否加载中
         const loading = ref(false);
         // 是否有更多数据
         const noMore = ref(false);
         // 分页
         const pageNum = ref(1);
       ​
         // 获取数据
         const getData = async (pageNum) => {
           try {
             // loading
             loading.value = true;
             const items = await getLoadMoreList(pageNum, pageSize);
             // 判断还有没有剩余的数据(没有数据返回或者返回的数量小于分页条数)
             if(!items.length || (items.length < pageSize)){
               noMore.value = true
             }
             listData.push(...items);
             loading.value = false;
           } catch (error) {
             console.log(error);
             loading.value = false;
           }
         };
       ​
         onMounted(()=>{
           // 获取对应的dom
           const el = target.value;
           if(!el){
             return;
           }
           // 监听scroll事件
           el.addEventListener('scroll', ()=>{
             // 判断是否触底(减去了缓冲距离)
             if(el.scrollTop + el.clientHeight >= el.scrollHeight - threshold){
               console.log('触底了');
               // 防止多次触发||无意义的触发
               if(loading.value || noMore.value){
                 return;
               }
               // 页数+1
               pageNum.value += 1;
               // 获取数据
               getData(pageNum.value);
             }
           })
         })
       ​
         // 获取数据
         getData(pageNum.value);
         
       </script>
      

2. 虚拟滚动

在我们加载大量数据的时候,由于会生成大量的dom。所以我们的页面在滑动的时候就会变得卡顿。当然解决方案我们可以采用分页去展示或者采用上面的方式,但是如果我们每页的数据很多的话还是会遇到同样的问题。所以我们就会用一种虚拟滚动的方式去减少dom(或者说固定几个dom数量)去渲染到页面上,但是用户在滚动查看的时候是察觉不到的。

1. 分析

  • 示例

2021-12-29 22.05.33.gif

  • 分析

    • 从上面的视频中可以看到,当我们滑动的时候,就会去加载新的数据。

    • 其实还有我们没有看到的,就是其实我们每次只是渲染固定数量的dom或者数据,当我们滑动的时候其实就是在不停的替换显示的数据。当我们滑动的时候,会使用一个“空白”去占据上面的地方来模拟出一个很大数据的假象”。说白了其实都是障眼法。

    • 根据上面的情况我们可以获取两个重要的信息点

      • 下滑:addEventListener('scroll'),监听scroll事件。
      • “空白”:可以使用margin或者padding或者另一个dom
      • 加载数据
    • 提前声明

      • 因为是固定的显示大小,所以我们需要定义几个变量
        • 我们需要两个dom容器,一个是滚动用的,一个是显示数据使用的(跟上面类似)
        • container: 外部容器dom
        • wrapper: 内部容器dom
        • containerHeight: 容器的高度
        • itemHeight: 每个元素的高度(我们先定高)
        • overscan:上下几个缓冲(因为如果渲染固定个的话,用户在上下滑动的时候可能会显示一部分空白)
        • visibleCount: 渲染多少个元素(visibleCount = Math.ceil(containerHeight / itemHeight)
        • offsetCount: 向上滑动过了多少个(offsetCount = Math.ceil(container.scrollTop / itemHeight)
    • 这样我们就实现了一个虚拟滚动的功能

    光说不练假把式,下面让我们尝试着实现一下吧

2. 代码实现

代码使用vue3实现(不管什么框架,道理是一样的)

  • 计算渲染数据的开始和结束索引

     // 开始索引(保障最大和最小值在范围之内)
     // offsetCount - overscan代表需要算上缓冲
     // 开始是减去。结尾是加上
     const start = Math.max(0, offsetCount - overscan);
     // 结束索引
     const end = Math.min(resultData.length, offsetCount + visibleCount + overscan);
     // 截取数据
     listData.value = resultData.slice(start, end);
    
  • 整体实现

     <div class="scrollContainer" ref="container">
       <div class="scrollWrapper" ref="wrapper">
         <div class="item" :key="item" v-for="item in listData">
           {{ item }}
         </div>
         <div>没有更多数据了</div>
       </div>
     </div>
     ​
     <script setup>
       import { onMounted, ref } from 'vue';
     ​
       // 总数据
       const resultData = (new Array(10000)).fill(1).map((val, index)=>index + 1);
       // 缓冲个数
       const overscan = 5;
       // 展示的数据
       let listData = ref([]);
       // 内部容器dom
       const wrapper = ref(null);
       // 外部容器dom
       const container = ref(null);
       // 外部容器高度
       const containerHeight = ref(0);
       // 显示数量
       const visibleCount = ref(0);
       // item高度(px)
       const itemHeight = 50;
     ​
       // 截取数据
       const calculateRangeData = () => {
         // 计算偏移的数量
         const offsetCount = Math.floor(container.value.scrollTop / itemHeight) + 1;
         // 总高度
         const totalHeight = resultData.length * itemHeight;
         
         // 开始索引
         const start = Math.max(0, offsetCount - overscan);
         // 结束索引
         const end = Math.min(resultData.length, offsetCount + visibleCount.value + overscan);
         
         // 向上偏移量
         const offsetTop = start * itemHeight; '这里有个坑'
         // 设置”空白“为了可滚动
         wrapper.value.style.marginTop = offsetTop + 'px';
         // 为了显示一个”正常“的进度条
         wrapper.value.style.height = totalHeight - offsetTop + 'px';
         
         listData.value = resultData.slice(start, end);
       };
     ​
       onMounted(()=>{
         const el = container.value;
         if(!el){
           return;
         }
         // 获取外部容器的高度
         containerHeight.value = el.clientHeight;
         // 计算可显示数量
         visibleCount.value = Math.floor(containerHeight.value / itemHeight);
         el.addEventListener('scroll', ()=>{
           calculateRangeData();
         })
         
         calculateRangeData();
       })
       
     </script>
    

我们来说一下上面的那个"坑”

  • 事情是这样的,一开始我是这样计算的offsetTop = offsetCount * itemHeight;直观的理解偏移量就是偏移的数量*每个高度嘛。但是会发生这样的现象,一开始不仅有偏移并且在一开始滚动的时候因为有偏移量的计算就导致数据显示的不正确。 image-20211229180005846.png
  • 所以后面改成了使用start就避免了这个问题。原因是:
    • 根本原因是缓冲的存在
    • start其实是视觉上的偏移量(因为有缓冲的存在)
    • offsetCount是真实的偏移量(滚动了多少距离数)
    • 根据上面的startend的计算规则就可以知道,一开始滚动还不是虚拟滚动而是真实的滚动。因为一开始有visibleCount + (下)overscan个dom,滚动增加到visibleCount + overscan * 2个之后才开始虚拟滚动)