定高的虚拟列表会了,那不定高的...... 哈,我也会!看我详解一波!🤪🤪🤪

扯皮

之前用原生 JS 实现过定高的虚拟列表,本文会引用其中的部分思路以及变量名称等,文中不再过多解释其含义,建议提前阅读 👇:

面试官:能否用原生JS手写一个虚拟列表...啊?你还真能写啊? - 掘金 (juejin.cn)

因为定高的虚拟列表没有过多的计算,所以原生 JS 手写不是多大问题。这次我们来实现一个不定高的虚拟列表,主要使用 Vue3 + TS 来将其封装为组件,并利用 Vue3.3 的泛型组件以及 defineSlots 新特性来让我们组件类型更加规范

参考实现 👉:花三个小时,完全掌握分片渲染和虚拟列表~ - 掘金 (juejin.cn)

由于原作者使用的是 React,并且不定高的实现只是文章中的一部分,因此我来实现一个 Vue 版的并详解文中不定高的实现 🤪

当然本文主要还是介绍实现原理,默认读者已熟悉虚拟列表相关概念

废话不多说,我们直接开始~

正文

分析不定高的问题

在敲代码之前我们先来看看不定高与定高的虚拟列表相比带来了哪些问题,不定高顾名思义就是指列表 item 的高度不固定

思考一下在定高的虚拟列表中 item 的高度在哪些地方用到了🤔?

我们从最开始定高的虚拟列表的实现来看:

item 高度 、 container 高度 => 最大容纳量 、 startIndex => endIndex => renderList

item 高度 、dataSource 数据源 => 列表高度以及滚动时偏移量样式设置

item 高度 、scrollTop => startIndex

你会发现 item 的高度贯穿了整个虚拟列表🤣,相当于整个实现的基座,如果没有 item 的高度就不可能实现虚拟列表🧐

所以说现在即使要实现不定高的 item,我们也需要内部确定 item 高度

DOM 基本结构和样式

和原生虚拟列表的实现一样,只不过使用了 Vue 开发,后续会用 v-for 遍历 renderList 创建 item 项,之后利用 slot 让父组件决定 item 的结构

我们这里依旧先搭建基本结构 container、list、item:

html 复制代码
<template>
  <div class="fs-estimated-virtuallist-container">
    <div class="fs-estimated-virtuallist-list">
      <div class="fs-estimated-virtuallist-list-item"></div>
    </div>
  </div>
</template>

样式设置也很简单,container 需要设置 overflow 展示出滚动条,list 会动态设置样式, item 由于不定高这次连高度的设置都省了:

scss 复制代码
.fs-estimated-virtuallist {
  &-container {
    width: 100%;
    height: 100%;
    overflow: auto;
  }
  
  &-list-item {
    width: 100%;
    box-sizing: border-box;
  }
}

预测 item 高度的设置

这里需要引入一个预测的 item 高度值,定高的虚拟列表实现要求用户传入固定的 item 高度,而现在我们不需要传入固定值,只需传入预测的 item 高度,而这里的高度值是有一定要求的:

需要保证预测的 item 高度尽量比真实的每一项 item 的高度要小或者接近所有 item 高度的平均值

为什么这样设置是因为我们内部需要根据预测的 item 高度来计算整个虚拟列表的最大容纳量,假设如果你的预测 item 高度过大,就会出现真实 item 渲染到视图上时出现留白的情况,为了方便画图我先使用固定高度,如下图所示的留白情况:

而如果预测高度比真实 item 高度都要小,那就能保证预测计算出的最大容纳量一定会大于真实 item 渲染视图列表的最大容纳量,这样就不会出现留白的情况

当然也不能过小,具体需要看真实 items 的高度情况,如果出现最小 item 和 最大 item 相差较大那也会造成最大容纳量设置过大的问题。恰当的设置是所有 item 高度的平均值,这样计算更符合真实渲染,如果无法拿捏就设置一个最小值

props 和 初始化状态

由于我们现在封装的是一个组件,所以这里的预测高度以及数据源都让父组件通过 props 传入,其它功能或优化自由扩展即可:

dataSource 里的 item 类型可以让父组件决定,因此我们内部封装其为泛型传入,之后再利用泛型组件限制该类型

xml 复制代码
<script setup lang="ts" generic="T extends {id:number}">
 interface IEstimatedListProps<T> {
  estimatedHeight: number; // 预测高度
  dataSource: T[]; // 数据源
}
  
const props = defineProps<IEstimatedListProps<T>>();
</script>

内部状态和之前定高的实现大差不差,因为虚拟列表核心的几个变量就这些,这里的 endIndex 和 renderList 通过 computed 计算得出,只要有了最大容纳量就能够计算:

js 复制代码
// container DOM
const containerRef = ref<HTMLDivElement>();
// list DOM
const listRef = ref<HTMLDivElement>();

const state = reactive({
  viewHeight: 0, // 容器高度
  listHeight: 0, // 列表高度
  startIndex: 0, // 起始索引
  maxCount: 0, // 最大容纳量
  preLen: 0, // ...(该值用于缓存上次计算长度,放到后面再讲)
});

// 末尾索引
const endIndex = computed(() => Math.min(props.dataSource.length, state.startIndex + state.maxCount));

// 渲染列表
const renderList = computed(() => props.dataSource.slice(state.startIndex, endIndex.value));

当然由于不定高的因素牵扯到很多计算,所以还会有额外的状态变量,我们后面会慢慢介绍

计算最大容纳量

在一开始我们就提到了传入的预测高度,而现在我们通过 ref 又可以获取到 container DOM,因此通过 container height 以及 estimated height 就可以计算出最大容纳量,我们把这部分操作封装到组件 mounted 的生命周期里

js 复制代码
const init = () => {
  state.viewHeight = containerRef.value ? containerRef.value.offsetHeight : 0;
  state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
};

onMounted(() => {
  init();
});

现在最大容纳量 maxCount 有了,我们上面通过 computed 计算的 endIndex 和 renderList 就能够拿到了

初始化 item 位置信息

计算出了最大容纳量就解决了我们最开始提出的第一个大问题,下面考虑第二个:如何计算出列表的高度和滚动偏移量

考虑之前定高的实现我们可以无脑用 数据源长度 * item 高度,但如今 item 高度不固定,那肯定就需要我们手动获取 DOM 信息再计算,我们可以直接通过 list DOM 来获取高度,但是偏移量怎么办🤔?滚动时的偏移量根据每滚动出去一项然后进行计算的,如果单单只获取 list 高度好像并不能解决这个问题

因此将引入一个新的变量:positions ,它用来存储每个 item 的信息, item 的信息如下:

typescript 复制代码
interface IPosInfo {
  // 当前pos对应的元素索引
  index: number;
  // 元素顶部所处位置
  top: number;
  // 元素底部所处位置
  bottom: number;
  // 元素高度
  height: number;
  // 自身对比高度差:判断是否需要更新
  dHeight: number;
}

可能看注释还不太明白,我们来画图展示这几个字段的含义:

图中解释了四个字段,注意这里的 topbottom 数值的参照物是 list

还有一个 dHeight 字段,它主要起到后面计算的辅助作用,我们用到它时再进行解释

这样做有什么好处呢?我们来看这些信息给我们来带哪些作用

列表的高度:positions 最后一项 item 的 bottom 值

滚动偏移量:当 scrollTop >= 某一个 item 的 bottom 值时,表明该 item 已经滚动出视图

OK,现在我们知道了 positions 代表的含义,下一步就是考虑对它进行初始化

实际上在真实 DOM 渲染到视图之前我们已经有了预测高度以及数据源,因此可以先根据这两个信息初始化 positions:

注意这里数据源中每一项的 id 字段至关重要,要求它必须是数据源的索引值(0 ~ dataSource.length - 1),文中为了方便直接使用 id 设置,真实场景如果 id 并不是索引值则需要前端自己额外添加一个字段进行标识

typescript 复制代码
function initPosition() {
  const pos: IPosInfo[] = [];
  for(let i = 0; i < props.dataSource.length; i++) {
    pos.push({
      index: item.id,
      height: props.estimatedHeight, // 使用预测高度先填充 positions
      top: item.id * props.estimatedHeight,
      bottom: (item.id + 1) * props.estimatedHeight,
      dHeight: 0,
    })
  }
  positions.value = pos;
}

关于 topbottom 的计算其实看过前面的一些介绍就知道怎么做了,不难发现 top 值与 bottom 值中间就差了一个 item height

到此 positions 数组就初始化完毕了

真实 DOM 列表挂载,更新 item 信息

上面的 positions 只是用预测高度替代,当 DOM 挂载后我们就需要获取 DOM 真实高度来更新 item

怎么保证 DOM 挂载后这个时机呢? 我们把更新操作放到 nextTick 中即可

但还有一个问题,就是 DOM 元素与 positions 之间的关联,我们能够获取 list 里的 children 来拿到视图上的每一个 item,但是怎么保证视图上的 item 与 positions 里的 item 能够对应上呢?

这就需要在 DOM item 上做一些手脚了,我们在 template 模板里不能简单只通过 v-for 遍历出 item,还需要给 item 添加一个额外属性进行标识

html 复制代码
<template>
<div class="fs-estimated-virtuallist-container" ref="containerRef">
  <div class="fs-estimated-virtuallist-list" ref="listRef">
    <!-- 添加一个 id 属性用来标识当前 item 是在 positions 数组中的哪个位置,它的值直接使用 item.id -->
    <div class="fs-estimated-virtuallist-list-item" v-for="i in renderList" :id="String(i.id)" :key="i.id" >
      <slot name="item" :item="i"></slot>
    </div>
  </div>
</div>
</template>

现在就好办了,我们实现一个 setPosition 方法来进行更新,我们分步骤来看,先来看前两步:

  1. 通过 ref 获取到 list DOM,进而获取到它的 children
  2. 遍历 children,针对于每一个 DOM item 获取其高度信息,通过其 id 属性找到 positions 中对应的 item,更新该 item 信息
typescript 复制代码
function setPosition() {
  const nodes = listRef.value?.children;
  if (!nodes || !nodes.length) return;
  [...nodes].forEach((node) => {
    const rect = node.getBoundingClientRect();
    const item = positions.value[+node.id]; // string => number
    const dHeight = item.height - rect.height; // 预测 item 高度与真实 item 高度的差值
    if (dHeight) {
      item.height = rect.height;
      item.bottom = item.bottom - dHeight;
      item.dHeight = dHeight;
    }
  });

}

可以看到这里 dHeight 字段就起到作用了,它代表着当前 item 高度的差值,初始状态时我们使用的 estimated height 与真实 item height 可能会有偏差,如果存在差值则将该值保存更新,还要更新之前的 bottom 值

注意现在只更新了视图上 item 的 bottom 值, top 值还没有更新,并且 positions 还有剩余 item 没有更新完毕

比如 dataSource 长度为 20,而视图上渲染的 item 数量为 10,那还有剩余的 10 个 item 位置信息没有更新

由于视图上 item 中的 height 发生了改变,那也会导致剩余的 item 位置信息错乱,因此还要考虑剩余 item 的更新

也就是说我们现在要从起始位置到 positions 末尾进行遍历更新,这里的末尾不用说,但起始位置是渲染在视图里所有 item 项中的第一项,并非是 positions[0],因为我们还要考虑滚动的操作,所以会出现下图的场景:

更新的范围是 start item ~ end item,而不是 item1 ~ item10,我们可以通过 id 属性找到 positions 对应的 start item

确定范围后我们就要考虑更新的策略了,实际上第一项的 top 值为 0,bottom 值在上轮也更新过了,所以遍历的时候我们从第二项开始,但我们需要用到第一项的 dHeight 来更新第二项的 bottom 值

我们来看下图就能理解了:

我们发现有这样的关系:

  1. item2.top = item1.bottom
  2. item2.bottom = item2.bottom - item1.dHeight

注意之前我们 dHeight 的计算是以初始化的 height 作为被减数,也就是说如果预测高度比真实高度大,它是正值,反之是负值

有了这样的规律那遍历后续的节点也是一样的操作,比如第三个 item 就需要利用前两个 item.dHeight 的累计值计算 bottom,所以每轮循环我们做三件事:

  1. 更新当前 item 的 top 值(利用上一个 item 的 bottom 值)
  2. 更新当前 item 的 bottom 值(利用 dHeight 累计值)
  3. 将当前 item 的 dHeight 进行累计,之后再重置为 0 (更新后就不再存在高度差了)

下面我们补充完整的 setPosition 逻辑,别忘了更新后的 positions 已经包含了真实 item 的信息,我们可以大胆的设置 list 的高度了:

typescript 复制代码
const setPosition = () => {
  const nodes = listRef.value?.children;
  if (!nodes || !nodes.length) return;
  [...nodes].forEach((node) => {
    const rect = node.getBoundingClientRect();
    const item = positions.value[+node.id];
    const dHeight = item.height - rect.height;
    if (dHeight) {
      item.height = rect.height;
      item.bottom = item.bottom - dHeight;
      item.dHeight = dHeight;
    }
  });
  // start item ~ end item 处理
  const startId = +nodes[0].id;
  const len = positions.value.length;
  // startHeight 作为 dHeight 的累计值
  let startHeight = positions.value[startId].dHeight; 
  positions.value[startId].dHeight = 0;
  for (let i = startId + 1; i < len; i++) {
    const item = positions.value[i];
    item.top = positions.value[i - 1].bottom;
    item.bottom = item.bottom - startHeight;
    if (item.dHeight !== 0) {
      startHeight += item.dHeight;
      item.dHeight = 0;
    }
  }
  // 设置 list 高度
  state.listHeight = positions.value[len - 1].bottom;
};

我们千辛万苦去设置每一个 item 的信息,终于拿到了我们最开始想要的东西:list height, 现在就可以设置样式了

typescript 复制代码
const offsetDis = computed(() => (state.startIndex > 0 ? positions.value[state.startIndex - 1].bottom : 0));

const scrollStyle = computed(
  () =>
    ({
      height: `${state.listHeight - offsetDis.value}px`,
      transform: `translate3d(0, ${offsetDis.value}px, 0)`,
    } as CSSProperties)
);

这里的 offsetDis 偏移量还需要再解释一下,之前固定高度直接通过 itemHeight * startIndex 即可,而现在由于 itemHeight 不固定,但是我们有了 positions 信息,只需要找到 startIndex 所处 item 的 上一个 item 的 bottom 值,或者所处 item 的 top 值 即可,这就是我们费这么大劲搞出一个 positions 的好处:

最后在 template 模板上给 list 绑定对应的样式:

html 复制代码
<template>
<div class="fs-estimated-virtuallist-container" ref="containerRef">
  <!-- 绑定 scrollStyle 样式 -->
  <div class="fs-estimated-virtuallist-list" ref="listRef" :style="scrollStyle">
    <div class="fs-estimated-virtuallist-list-item" v-for="i in renderList" :id="String(i.id)" :key="i.id" >
      <slot name="item" :item="i"></slot>
    </div>
  </div>
</div>
</template>

滚动事件和 startIndex 计算

终于来到了最后的 startIndex 计算,不管是定高还是不定高 startIndex 必然是与滚动事件相关联的,只不过这次不太容易,毕竟 itemHeight 不固定就不能简简单单用 scrollTop / itemHeight 计算了

好在我们有 positions!可以发现它在整个不定高虚拟列表中起到了很大的作用,实际上 startIndex 值无非就是滚动出去了几项

如何判断一个 item 滚出视图?这个问题在最早就提到过了,只需要看它的 bottom <= scrollTop

现在就好办了,我们可以遍历 positions 数组找到第一个 item.bottom >= scrollTop 的 item,它就是 startIndex 所对应的 item,那 startIndex 就拿到了

这里再补充一个细节,在 positions 数组中 item.bottom 一定是递增的,而我们现在想要做的是查找操作,有序递增 + 查找 = 二分查找

所以我们可以借助二分查找来找到对应的 startIndex,直接上代码:

typescript 复制代码
const binarySearch = (list: IPosInfo[], value: number) => {
  let left = 0,
    right = list.length - 1,
    templateIndex = -1;
  while (left < right) {
    const midIndex = Math.floor((left + right) / 2);
    const midValue = list[midIndex].bottom;
    if (midValue === value) return midIndex + 1;
    else if (midValue < value) left = midIndex + 1;
    else if (midValue > value) {
      if (templateIndex === -1 || templateIndex > midIndex) templateIndex = midIndex;
      right = midIndex;
    }
  }
  return templateIndex;
};

如果找到了就用找到的索引 + 1 作为 startIndex,因为找到的 item 是它的 bottom 与 scrollTop 相等,即该 item 已经滚出去了

但也可能存在找不到的情况,说明 startIndex 的 item 滚出去了一部分,这时候我们应该取到的是其 right 的索引位置作为 startIndex

现在我们就可以添加滚动事件了,只需要在每次滚动时计算对应的 startIndex 即可,顺便使用之前封装的 raf 进行节流:

typescript 复制代码
const handleScroll = rafThrottle(() => {
  const { scrollTop } = containerRef.value!;
  state.startIndex = binarySearch(positions.value, scrollTop);
});

const init = () => {
  state.viewHeight = containerRef.value ? containerRef.value.offsetHeight : 0;
  state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
  // 补充滚动事件绑定
  containerRef.value && containerRef.value.addEventListener("scroll", handleScroll);
};

而每次 startIndex 改变,不仅会改变 renderList 的计算,我们还需要重新计算 item 信息,这里直接使用 watch 即可:

typescript 复制代码
watch(
  () => state.startIndex,
  () => {
    setPosition();
  }
);

既然有了事件监听,我们也可以考虑移除的操作,只需在其卸载时移除对应的事件处理函数即可:

typescript 复制代码
const destory = () => {
  containerRef.value && containerRef.value.removeEventListener("scroll", handleScroll);
};

onUnmounted(() => {
  destory();
});

触底加载更多

触底这个操作在之前定高虚拟列表中是在计算 endIndex 时处理的,这次我们在滚动事件中处理,那无非就是 scrollHeight、clientHeight、scrollTop 比较即可

触底数值可以扩展进行配置,这里方便起见就直接硬编码了,触底之后抛出触底事件,让父组件获取更多数据即可:

typescript 复制代码
const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
  state.startIndex = binarySearch(positions.value, scrollTop);
  // 补充触底操作
  const bottom = scrollHeight - clientHeight - scrollTop;
  if (bottom <= 20) {
    emit("getMoreData");
  }
});

而一旦数据源进行了更新,我们也需要重新执行之前设置 item 信息的一系列操作,而这部分也可以通过 watch 来实现,注意我们之前提到的 nextTick 的使用:

typescript 复制代码
watch(
  () => props.dataSource.length,
  () => {
    initPosition();
    nextTick(() => {
      setPosition();
    });
  }
);

当然这样并没有结束,我们需要回到之前初始化 item 信息即 initPosition 的时候:

typescript 复制代码
function initPosition() {
  const pos: IPosInfo[] = [];
  for(let i = 0; i < props.dataSource.length; i++) {
    pos.push({
      index: item.id,
      height: props.estimatedHeight, // 使用预测高度先填充 positions
      top: item.id * props.estimatedHeight,
      bottom: (item.id + 1) * props.estimatedHeight,
      dHeight: 0,
    })
  }
  positions.value = pos;
}

我们可以看到当前实现的方式是直接从头遍历 dataSource 去初始化 positions 数组,如果代入到加载更多的场景显然是不合适的,只有新增的数据需要进行初始化操作,之前 positions 里已保存的信息无需再重新初始化,也就是说我们每一次执行 initPosition 后都要保存一下当前 positions.length,第二次调用时我们跳过这些项即可

并且如果之前项都存在话,那后续初始化每一项的 top 值和 bottom 值都需要累计上之前 positions 里最后一项的 top 以及 bottom

而最早我们初始化状态里的 state.preLen 就是保存上一次的 length,还对这个字段有印象吗?我们说到后面会讲解它的含义,没想到吧直接到最后了!

现在我们就要修改 initPosition 的实现:

typescript 复制代码
const initPosition = () => {
  const pos: IPosInfo[] = [];
  // 获取后续项的长度
  const disLen = props.dataSource.length - state.preLen;
  const currentLen = positions.value.length;
  const preTop = positions.value[currentLen - 1] ? positions.value[currentLen - 1].top : 0;
  const preBottom = positions.value[currentLen - 1]
    ? positions.value[currentLen - 1].bottom
    : 0;
  for (let i = 0; i < disLen; i++) {
    const item = props.dataSource[state.preLen + i];
    pos.push({
      index: item.id,
      height: props.estimatedHeight,
      top: preTop ? preTop + i * props.estimatedHeight : item.id * props.estimatedHeight,
      bottom: preBottom ? preBottom + (i + 1) * props.estimatedHeight : (item.id + 1) * props.estimatedHeight,
      dHeight: 0,
    });
  }
  positions.value = [...positions.value, ...pos];
  // 每次初始化后保存当前的 length
  state.preLen = props.dataSource.length;
};

到此我们整个不定高的虚拟列表实现就完成了!

模拟数据展示效果

最后我们来用 Mock 模拟一些不定高的数据,使用我们封装的组件:

xml 复制代码
<template>
  <div class="test-estimated-list-container">
    <FsEstimatedVirtualList :data-source="dataSource" :estimated-height="120" @getMoreData="addData">
      <template #item="{ item }">
        <div class="list-item">{{ item.id }} - {{ item.content }}</div>
      </template>
    </FsEstimatedVirtualList>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import Mock from "mockjs";
import FsEstimatedVirtualList from "./FsEstimatedVirtualList.vue";

const dataSource = ref<
  Array<{
    id: number;
    content: string;
  }>
>([]);

const addData = () => {
  setTimeout(() => {
    const newData = [];
    for (let i = 0; i < 20; i++) {
      const len: number = dataSource.value.length + newData.length;
      newData.push({
        id: len,
        content: Mock.mock("@csentence(40, 100)"), // 内容
      });
    }
    dataSource.value = [...dataSource.value, ...newData];
  }, 0);
};

onMounted(() => {
  addData();
});
</script>

<style scoped lang="scss">
.test-estimated-list-container {
  width: 100%;
  height: 100%;
}
.list-item {
  border: 1px solid #000;
  padding: 10px;
  letter-spacing: 0.1em;
}
</style>

不定高虚拟列表组件:

xml 复制代码
<template>
  <div class="fs-estimated-virtuallist-content" ref="containerRef">
    <div class="fs-estimated-virtuallist-list" ref="listRef" :style="scrollStyle">
      <div class="fs-estimated-virtuallist-list-item" v-for="i in renderList" :key="i.id" :id="String(i.id)">
        <slot name="item" :item="i"></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts" generic="T extends {id:number}">
import { type CSSProperties, computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import type { IEstimatedListProps, IPosInfo } from "./type";
import { rafThrottle } from "./tool";

const props = defineProps<IEstimatedListProps<T>>();

const emit = defineEmits<{
  getMoreData: [];
}>();

defineSlots<{
  item(props: { item: T }): any;
}>();

const containerRef = ref<HTMLDivElement>();

const listRef = ref<HTMLDivElement>();

const positions = ref<IPosInfo[]>([]);

const state = reactive({
  viewHeight: 0,
  listHeight: 0,
  startIndex: 0,
  maxCount: 0,
  preLen: 0,
});

const endIndex = computed(() => Math.min(props.dataSource.length, state.startIndex + state.maxCount));

const renderList = computed(() => props.dataSource.slice(state.startIndex, endIndex.value));

const offsetDis = computed(() => (state.startIndex > 0 ? positions.value[state.startIndex - 1].bottom : 0));

const scrollStyle = computed(
  () =>
    ({
      height: `${state.listHeight - offsetDis.value}px`,
      transform: `translate3d(0, ${offsetDis.value}px, 0)`,
    } as CSSProperties)
);

watch(
  () => props.dataSource.length,
  () => {
    initPosition();
    nextTick(() => {
      setPosition();
    });
  }
);

watch(
  () => state.startIndex,
  () => {
    setPosition();
  }
);

// 初始化:拿到数据源初始化 pos 数组
const initPosition = () => {
  const pos: IPosInfo[] = [];
  const disLen = props.dataSource.length - state.preLen;
  const currentLen = positions.value.length;
  const preTop = positions.value[currentLen - 1] ? positions.value[currentLen - 1].top : 0;
  const preBottom = positions.value[currentLen - 1] ? positions.value[currentLen - 1].bottom : 0;
  for (let i = 0; i < disLen; i++) {
    const item = props.dataSource[state.preLen + i];
    pos.push({
      index: item.id,
      height: props.estimatedHeight,
      top: preTop ? preTop + i * props.estimatedHeight : item.id * props.estimatedHeight,
      bottom: preBottom ? preBottom + (i + 1) * props.estimatedHeight : (item.id + 1) * props.estimatedHeight,
      dHeight: 0,
    });
  }
  positions.value = [...positions.value, ...pos];
  state.preLen = props.dataSource.length;
};

// 数据 item 渲染完成后,更新数据item的真实高度
const setPosition = () => {
  const nodes = listRef.value?.children;
  if (!nodes || !nodes.length) return;
  [...nodes].forEach((node) => {
    const rect = node.getBoundingClientRect();
    const item = positions.value[+node.id];
    const dHeight = item.height - rect.height;
    if (dHeight) {
      item.height = rect.height;
      item.bottom = item.bottom - dHeight;
      item.dHeight = dHeight;
    }
  });

  const startId = +nodes[0].id;
  const len = positions.value.length;
  let startHeight = positions.value[startId].dHeight;
  positions.value[startId].dHeight = 0;
  for (let i = startId + 1; i < len; i++) {
    const item = positions.value[i];
    item.top = positions.value[i - 1].bottom;
    item.bottom = item.bottom - startHeight;
    if (item.dHeight !== 0) {
      startHeight += item.dHeight;
      item.dHeight = 0;
    }
  }
  state.listHeight = positions.value[len - 1].bottom;
};

const init = () => {
  state.viewHeight = containerRef.value ? containerRef.value.offsetHeight : 0;
  state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1;
  containerRef.value && containerRef.value.addEventListener("scroll", handleScroll);
};

const destory = () => {
  containerRef.value && containerRef.value.removeEventListener("scroll", handleScroll);
};

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
  state.startIndex = binarySearch(positions.value, scrollTop);
  const bottom = scrollHeight - clientHeight - scrollTop;
  if (bottom <= 20) {
    emit("getMoreData");
  }
});

const binarySearch = (list: IPosInfo[], value: number) => {
  let left = 0,
    right = list.length - 1,
    templateIndex = -1;
  while (left < right) {
    const midIndex = Math.floor((left + right) / 2);
    const midValue = list[midIndex].bottom;
    if (midValue === value) return midIndex + 1;
    else if (midValue < value) left = midIndex + 1;
    else if (midValue > value) {
      if (templateIndex === -1 || templateIndex > midIndex) templateIndex = midIndex;
      right = midIndex;
    }
  }
  return templateIndex;
};

onMounted(() => {
  init();
});

onUnmounted(() => {
  destory();
});
</script>

<style scoped lang="scss">
.fs-estimated-virtuallist {
  &-container {
    width: 100%;
    height: 100%;
    overflow: auto;
  }

  &-list-item {
    width: 100%;
    box-sizing: border-box;
  }
}
</style>

最终效果如下:

End

到此不定高的虚拟列表实现就全部结束了,可以看到万变不离其宗,尽管是不定高但虚拟列表的几个核心:startIndex、滚动、偏移量等这些内容依旧是通用的,只不过这里确实需要使用一些手段去重新计算这些变量罢了

不过也能看出不定高虚拟列表实现过程中需要的计算要比定高大的多,自然性能比不上定高的虚拟列表

比如每次滚动都需要进行一次二分查找,尽管二分的时间复杂度都较低,但也比不过定高的 O(1) 呀

以及 positions 计算的操作更是需要获取每一个 item 的真实高度,比定高的虚拟列表多了很多 DOM 操作

所以我们使用不定高虚拟列表时一定要做好取舍,使用虚拟列表技术是希望做优化,如果到最后因为大量计算影响了虚拟列表的性能那反而是事倍功半

最后放上演示链接(PC 访问):virtual-demo.fasyncsy.com.cn/

源码:github.com/DrssXpro/vi...

(PS:后续会讲解当中的瀑布流以及瀑布流虚拟列表~😎😎😎)

相关推荐
工业互联网专业13 分钟前
毕业设计选题:基于springboot+vue+uniapp的驾校报名小程序
vue.js·spring boot·小程序·uni-app·毕业设计·源码·课程设计
J不A秃V头A42 分钟前
Vue3:编写一个插件(进阶)
前端·vue.js
司篂篂1 小时前
axios二次封装
前端·javascript·vue.js
姚*鸿的博客2 小时前
pinia在vue3中的使用
前端·javascript·vue.js
宇文仲竹2 小时前
edge 插件 iframe 读取
前端·edge
Kika写代码2 小时前
【基于轻量型架构的WEB开发】【章节作业】
前端·oracle·架构
天下无贼!3 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr3 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林3 小时前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider4 小时前
爬虫----webpack
前端·爬虫·webpack