浅谈虚拟列表

前言

在日常工作中难免会遇到大量数据渲染的情况,刷不到底的新闻,无尽图片瀑布流、超级超级长的排行榜等等。对于这种场景,我们不可能一次性加载完所有数据,同时请求如此多的数据,渲染大量的元素,对用户体验和应用性能都不友好。

对于长列表的优化一般都有以下三种:

  • 分页加载:实现简单直接,但用户使用需频繁切换页码,体验不是最佳。
  • 懒加载:实现难度不大,一定程度上解决首屏压力,但随着长时间加载数据,页面存在大量元素节点(未及时销毁的过期节点),从而影响应用页面性能。
  • 虚拟列表:实现难度较大,通过监听计算滚动位置,每次渲染一定量的元素节点,对于用户来说是无感刷新,所以该方案可以满足上述大部分场景。

虚拟列表

其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用padding或者translate来让渲染的列表偏移到可视区域中,给用户平滑滚动的感觉。

原理

从上图可以发现,实际上用户每次能看到的其实只有item10 - item14 5个元素。所以列表每次总是只渲染 5 + 4(缓冲区元素)个元素,这就是虚拟列表的基本原理。

虚拟列表由虚拟区、缓冲区和可视区组成。其中虚拟区的元素不渲染,缓冲区是为了解决快速滚动时候存在白屏问题。

固定高度

核心步骤

  1. 根据容器的高度,计算出所在可视区展示的元素个数,以及初始化列表高度

    初始化列表高度 = 列表总数据 x 元素高度

    可视区展示的元素个数 = Math.ceil ( 可视区高度 / 元素高度 )

  2. 初始化数据,更新渲染方法,设置缓冲区域

  3. 监听滚动事件,根据滚动后的scrollTop计算出新的开始和结束索引

实现原理 因为元素是定高,所以很容易就得出正常渲染时候列表容器高度。然后把该值赋给列表的外层容器(用于模拟正常滚动的一个容器),然后渲染的元素节点通过设置top值(此处用的是top,也可以通过translate实现)去模拟元素在正常列表的位置,从而实现模拟滚动的效果。

元素top: (循环渲染时的index + 可视区开始索引) x 元素高度

ts 复制代码
import { reactive, watch } from 'vue';
// height 可视区高度  rowHeight 元素高度  bufferSize 缓存个数  allList 数据总列表
// ele 监听滚动的容器的id
// callback 渲染列表数据改变触发回调
export default function useVirtualList({ height, rowHeight, bufferSize, allList }, ele, callback) {
  const virtual = reactive({
    list: [], // 总列表
    total: 0, // 总数量
    limit: 0, // 在可视区展示的元素个数
    originStartIdx: 0, // 原始开始索引
    startIndex: 0, // 开始索引
    endIndex: 0, // 结束索引
  });
  const init = () => {
    virtual.list = allList;
    virtual.total = virtual.list.length;
    virtual.limit = Math.ceil(height / rowHeight);
    virtual.originStartIdx = 0;
    virtual.startIndex = Math.max(virtual.originStartIdx - bufferSize, 0);
    virtual.endIndex = Math.min(virtual.originStartIdx + virtual.limit + bufferSize, virtual.total);
    updateDisplayList(virtual.startIndex, virtual.endIndex);
    document.getElementById(ele)?.addEventListener('scroll', scrollChange);
  };
  const scrollChange = e => {
    const { scrollTop } = e.target;
    const { total, limit, originStartIdx } = virtual;
    //计算当前的startIndex
    const currentIndex = Math.floor(scrollTop / rowHeight);
    if (originStartIdx !== currentIndex) {
      virtual.originStartIdx = currentIndex;
      virtual.startIndex = Math.max(currentIndex - bufferSize, 0);
      virtual.endIndex = Math.min(currentIndex + limit + bufferSize, total);
      updateDisplayList(virtual.startIndex, virtual.endIndex);
    }
  };
  const updateDisplayList = (sIdx, eIdx) => {
    callback(virtual.list.slice(sIdx, eIdx));
  };
  init();
  watch(
    () => allList,
    () => {
      init();
    },
    { deep: true },
  );
  return virtual;
}

在组件调用hook表现

ts 复制代码
<template>
  <div class="more-log">
    <el-dialog
      width="70%"
      style="height: 700px; overflow-y: scroll"
      v-model="visibleRef"
      destroy-on-close
      :close-on-click-modal="false"
    >
      <el-timeline
        id="scrollContainer"
        class="scroll-container"
        :style="{
          height: listDescribe.height + 'px',
        }"
      >
        <div class="wrapper" :style="{ height: virtual.total * listDescribe.rowHeight + 'px' }">
          <el-timeline-item
            v-for="(item, index) in displayListRef"
            class="item"
            :key="item.id"
            placement="top"
            hollow
            hide-timestamp
            :style="{
              height: listDescribe.rowHeight + 'px',
              top:
                index * listDescribe.rowHeight + virtual.startIndex * listDescribe.rowHeight + 'px',
            }"
          >
            <log :info="item" />
          </el-timeline-item>
        </div>
      </el-timeline>
    </el-dialog>
  </div>
</template>
<script lang="ts" setup>
import Log from '@/components/behaviorTrajectory/Log.vue';
import useVirtualList from '@/hooks/useVirtualList';
import { reactive, ref, nextTick } from 'vue';
const visibleRef = ref(false);
const displayListRef = ref();
const listDescribe = reactive({
  list: [] as any,
  height: 600, // 可视区高度
  rowHeight: 400, // 行数据高度
  bufferSize: 2, // 缓存个数
});
const virtual: any = useVirtualList(
  {
    height: listDescribe.height,
    rowHeight: listDescribe.rowHeight,
    bufferSize: listDescribe.bufferSize,
    allList: listDescribe.list,
  },
  'scrollContainer',
  list => {
    displayListRef.value = list;
  },
);

const init = list => {
  visibleRef.value = true;
  nextTick(() => {
    listDescribe.list.push(...list);
  });
};
defineExpose({ init });
</script>

<style lang="less" scoped>
.scroll-container {
  overflow: hidden auto;
}
.wrapper {
  position: relative;
}
.item {
  width: 96%;
  left: 0;
  right: 0;
  position: absolute;
}
</style>

不定高度

实现原理 实现的方法有很多种,这里采用的是利用 IntersectionObserverAPI 和分页,监听列表顶部和底部出现时机进行数据的切换。

前置知识 IntersectionObserverAPI

Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。

这是官方描述,其实他的作用就是用来监听一个元素在容器的显示/隐藏。传统的实现方法是,监听到scroll事件后,调用目标元素(绿色方块)的getBoundingClientRect()方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于scroll事件密集发生,计算量很大,容易造成性能问题

目前有一个新的 IntersectionObserver API,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。

API

js 复制代码
// 创建实例
const observer = new IntersectionObserver(callback, option);
 
// 开始观察
observer.observe(document.getElementById('example'));

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

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

// 上面代码中,`observe`的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。
observer.observe(elementA);
observer.observe(elementB);

callback参数

目标元素的可见性变化时,就会调用观察器的回调函数callback

callback一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。

ini 复制代码
var io = new IntersectionObserver(
  entries => {
    console.log(entries);
  }
);

上面代码中,回调函数采用的是箭头函数的写法。callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。

实现逻辑在列表顶部和底部各添加一个div,然后通过IntersectionObserverAPI监听这两个元素的出现和隐藏,从而判断用户的行为并更改页码,进而改变渲染的列表数据。

列表滚动方向有两个:上和下

  1. 往下
    • 当前显示的数据不是列表最后一页,且当前页码大于2时候,加载下一页数据并删除前面(一个分页大小)的数据
    • 为最后一页数据,不做处理
  2. 往上
    • 当前页码为1或2的时候,不做处理
    • 当前页码为3时候,展示第一页的数据,页码为1(这里是因为当时开发需求时候,展示第二页数据的同时会展示第一页)
    • 当前页码大于3时候,列表数组unshift上一页的数据,最后重置一下scrollTop

代码实践

ts 复制代码
<template>
  <div
    ref="scrollWrapperEle"
    class="scroll-wrapper"
    :style="{ maxHeight: (maxHeight ? maxHeight : 600) + 'px' }"
  >
    <div class="scroll-header" ref="scrollHeaderEle"></div>
    <div ref="scrollContentEle">
      <slot :renderList="virtual.curDisplayList"></slot>
    </div>
    <div class="scroll-loading" ref="scrollLoadingEle" v-show="hasMoreData">
      正在努力加载更多数据中...
    </div>
    <div class="no-more" v-show="virtual.curDisplayList.length && !hasMoreData">全部加载完成~</div>
    <div class="no-data" v-if="!virtual.curDisplayList.length && !error">
      <el-empty description="暂无更多数据" />
    </div>
    <div class="err-warp" v-if="error">
      <div class="err">
        <el-icon :size="180" color="#C0C4CC"><i-ep-FolderDelete /></el-icon>
        <p>出错啦~</p>
      </div>
    </div>
  </div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
const props = defineProps<{
  perPage?: number; // 分页大小
  maxHeight?: number; // 滚动容器的最大高度
  list: any[]; // 总列表
  loading: boolean; // 数据是否加载中
  error: boolean; // 数据是否有问题(接口报错)
}>();
const scrollWrapperEle = ref();
const scrollContentEle = ref();
const emit = defineEmits(['updateLoading']);
// 交叉观察器
const intersectionObserver = new IntersectionObserver(entries => {
  entries.forEach(it => {
    // 触发目标为底部加载更多容器
    if (it.target.className === 'scroll-loading') {
      // 数据加载完毕前不触发
      if (it.isIntersecting && !props.loading) {
        const { displayList, listPage } = virtual;
        const { page, perPage, total } = listPage;
        // 当前显示的数据不是列表最后一页
        if (page < Math.ceil(total / perPage)) {
          virtual.curDisplayList = [
            ...virtual.curDisplayList,
            ...displayList.slice(page * perPage, (page + 1) * perPage),
          ];
          if (page > 2) {
            // 展示数据为第三页时候开始删除前面的数据
            virtual.curDisplayList.splice(0, perPage);
          }
          listPage.page++;
        }
      }
    } else if (it.target.className === 'scroll-header') {
      // 触发目标为顶部空白容器
      if (it.isIntersecting && !props.loading) {
        const { displayList, listPage } = virtual;
        const { page, perPage, total } = listPage;
        if (page === 3) {
          virtual.curDisplayList.splice(-2 * perPage, 2 * perPage);
          listPage.page -= 2;
        }
        if (page > 3) {
          emit('updateLoading', true);
          scrollWrapperEle.value.style.overflowY = 'hidden'; // 禁止滚动
          virtual.curDisplayList = [
            ...displayList.slice((page - 4) * perPage, (page - 3) * perPage),
            ...virtual.curDisplayList,
          ];
          // 当前显示为最后一页数据
          if (page >= Math.ceil(total / perPage)) {
            // 最后一页数据可能小于分页数
            virtual.curDisplayList.splice(-perPage, total - (page - 1) * perPage);
          } else {
            virtual.curDisplayList.splice(-perPage, perPage);
          }
          // 需要计算更新后的dom的真实高度 这里用setTimeout0
          setTimeout(() => {
            let parentNodeType = scrollContentEle.value.children[0].nodeName;
            let nodes;
            if (parentNodeType === 'UL') {
              nodes = scrollContentEle.value.children[0].children;
            } else if (parentNodeType === 'DIV') {
              nodes = scrollContentEle.value.children;
            }
            let nodesHeight = 0;
            for (let i = 0; i < perPage; i++) {
              nodesHeight += nodes[i].offsetHeight;
            }
            scrollWrapperEle.value.scrollTop = nodesHeight;
            setTimeout(() => {
              emit('updateLoading', false);
              scrollWrapperEle.value.style.overflowY = 'auto';
            }, 600);
          }, 0);
          listPage.page--;
        }
      }
    }
  });
});
interface ListPage {
  page: number;
  perPage: number;
  total: number;
}
interface Virtual {
  displayList: any[];
  curDisplayList: any[];
  listPage: ListPage;
}
const virtual: Virtual = reactive({
  displayList: [],
  curDisplayList: [],
  listPage: {
    page: 1,
    perPage: 3,
    total: 0,
  },
});
const hasMoreData = computed(() => {
  return virtual.listPage.page * virtual.listPage.perPage < virtual.listPage.total;
});
const initPagingInfo = () => {
  let { listPage, displayList } = virtual;
  listPage.page = 1;
  listPage.total = displayList.length;
  if (listPage.total <= listPage.perPage) {
    virtual.curDisplayList = [...displayList];
  } else {
    virtual.curDisplayList = [...displayList.slice(0, listPage.perPage)];
  }
};
const scrollLoadingEle = ref();
const scrollHeaderEle = ref();
onMounted(() => {
  if (props.perPage) {
    virtual.listPage.perPage = props.perPage;
  }
  if (props.list.length) {
    virtual.displayList = JSON.parse(JSON.stringify(props.list));
    initPagingInfo();
  }
  // 开始观察
  intersectionObserver.observe(scrollLoadingEle.value);
  intersectionObserver.observe(scrollHeaderEle.value);
});
watch(
  () => props.list,
  list => {
    virtual.displayList = JSON.parse(JSON.stringify(list));
    initPagingInfo();
    scrollWrapperEle.value.scrollTop = 0; // 重置滚动高度,避免出现数据源切换导致滚动条位置错误
  },
);
</script>
<style lang="less" scoped>
.no-more {
  font-size: 16px;
  text-align: center;
  color: #c0c4cc;
}
.err-warp {
  height: 400px;
  display: flex;
  justify-content: center;
  align-items: center;
  .err {
    text-align: center;
    font-size: 24px;
    color: #c0c4cc;
  }
}
.scroll-wrapper {
  width: 100%;
  overflow-x: hidden;
  overflow-y: auto;
}
.scroll-header {
  height: 30px;
}
.scroll-loading {
  height: 30px;
  text-align: center;
  font-size: 24px;
}
</style>

父级调用

js 复制代码
<dynamic-virtual-list
  :list="trajectory.displayList"
  :loading="trajectory.loading"
  :error="trajectory.err"
  v-slot="slotProps"
  @updateLoading="bool => (trajectory.loading = bool)"
>
  {{ slotProps.renderList }}
</dynamic-virtual-list>

缺点 因为滚动高度会重新计算,而快速拖动滚动条可能会导致页面位置错乱。解决方案有两种,一种是直接隐藏滚动条,只开放鼠标滚动,一种则是在加载数据时候添加一个loading,loading时候隐藏滚动条,数据渲染完重新出现。这里采用的是后者。

总结

  1. 本篇文章只是实现了最简单、最基础的虚拟列表(它的玩法有很多)。
  2. 其实虚拟列表的本质就是固定dom数量, 只要你能够分批渲染大数据量的list,并且能够保证dom数量固定,那么你实现的就是虚拟列表。

参考链接

  1. 关于虚拟列表,看这一篇就够了

  2. Intersection Observer API - Web API 接口参考 | MDN

  3. IntersectionObserver API 使用教程 - 阮一峰的网络日志

相关推荐
好名字08213 分钟前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
摇光938 分钟前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
隐形喷火龙14 分钟前
element ui--下拉根据拼音首字母过滤
前端·vue.js·ui
胡西风_foxww36 分钟前
【ES6复习笔记】Class类(15)
javascript·笔记·es6·继承··class·静态成员
布兰妮甜1 小时前
使用 WebRTC 进行实时通信
javascript·webrtc·实时通信
艾斯特_1 小时前
JavaScript甘特图 dhtmlx-gantt
前端·javascript·甘特图
我爱学习_zwj1 小时前
AJAX与Axios
前端·javascript·ajax
Simaoya1 小时前
【vue】css模拟玻璃球体效果(带高光)
前端·css·vue.js
阿卡基YUAN2 小时前
react useCallback
前端·javascript·react.js
传说中胖子2 小时前
在线excel编辑(luckysheet)
javascript