浅谈虚拟列表

前言

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

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

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

虚拟列表

其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用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 使用教程 - 阮一峰的网络日志

相关推荐
深蓝AI8 小时前
TypeScript 7.0 原生编译器实战:8—12 倍提速,迁移前的 7 个检查
javascript
mayaairi8 小时前
JS BOM完全指南(二):事件轮询、三大尺寸家族与浏览器对象详解
开发语言·javascript·ecmascript
avi91119 小时前
【】js不同颜色(Vue 框架)今时今日2026年学编程入门(10月1日)
前端·vue.js·vue·vue框架·前端入门·vue入门·html上传
Fluxart.ai9 小时前
商品多角度图怎么做?Flux Art 从白底图到规格图、包装图的 10 步教程
开发语言·前端·javascript
程序员小八77710 小时前
后端开发初学TypeScript
前端·javascript·typescript
单线程1213812 小时前
从案例分析 Vue3 Tokenizer 源码
前端·javascript·vue.js
GIS数据转换器12 小时前
智慧林草“一张图“平台
java·大数据·服务器·前端·javascript·数据库·人工智能
烂蜻蜓12 小时前
Flask入门教程(十):静态文件——CSS、JS与图片的正确管理方式
javascript·css·flask
Mr数据杨13 小时前
【Codex】用学生支持周报模块跟踪个性化支持进展
android·java·javascript·django·codex·项目开发
独立开发之道13 小时前
【three.js教程】Three.js 材质详解:从“不反光“到“物理级真实“怎么选
开发语言·javascript·材质