导航


HTML

CSS

JavaScript

浏览器 & 网络

版本管理

框架

构建工具

TypeScript

性能优化

软实力

算法

UI、组件库

Node

冷门技能

针对性攻坚(TODO)


方案介绍

长列表常见的3种处理方式:

  1. 懒加载
  2. 时间分片
  3. 虚拟列表

懒加载

懒加载的原理在于:只有视口内的内容会被加载,其他内容在用户滚动到视口时才会被加载。这可以显著减少初次加载的时间,提高页面响应速度。

时间分片

时间分片的本质是通过 requestAnimationFrame,由浏览器来决定回调函数的执行时机。大量的数据会被分多次渲染,每次渲染对应一个片段。在每个片段中处理定量的数据后,会将主线程还给浏览器,从而实现快速呈现页面内容给用户。

总结:无论是懒加载还是时间分片,最终都是将完整数量的列表项渲染出来,这在面对列表项非常非常多的时候,页面性能是比较低的。

虚拟列表

原理

设置一个可视区域,然后用户在滚动列表的时候,本质上是动态修改可视区域里面的内容。

例如,一开始渲染前面 5 个项目

image.png

之后用户进行滚动,就会动态的修改可视区域里面的内容,如下图所示:

image.png

也就是说,始终渲染的只有可视区的那5个项目,这样能够极大的保障页面性能。

实现(固定高度列表)

实现定高的虚拟列表,这里所指的定高是说列表项的每一项高度相同。

DOM 结构:

<template>
  <!-- 外层容器 -->
  <div class="viewport relative overflow-y-auto h-full" ref="viewportRef" ...>
    <!-- 列表占位区域 -->
    <div class="scroll-bar" ref="scrollBarRef" ...></div>
    <!-- 真实渲染的可见区域 -->
     <div class="scroll-list absolute left-0 top-0" ref="scrollListRef" ...>
       <!-- 列表项 1 -->
       <!-- 列表项 2 -->
       <!-- 列表项 n -->
     </div>
  </div>
</template>

image.png

涉及到的变量:

  1. 外层容器高度 containerHeight (固定高度)
  2. 列表总高 listHeight (所有列表项加起来的高度,即滚动区域高度)
  3. 可视区域起始数据索引 start
  4. 可视区域结束数据索引 end
  5. 可视区域的列表项个数 visibleCount
  6. 可视区域的数据 visibleData
  7. 整个列表中的偏移位置 offset 如下图所示:

image.png

接下来监听 viewportscroll 事件,获取滚动位置的 scrollTop,因为回头需要设置可视区域 scroll-list 向下位移的距离

那么我们能够计算出这么一些信息:

  1. 列表总高度:listHeight = items.length * size
  2. 可显示的列表项数:visibleCount = Math.ceil(containerHeight / size)
  3. 数据的起始索引:start = Math.floor(scrollTop / size)
  4. 数据的结束索引:end = start + visibleCount
  5. 渲染区域中列表显示数据:visibleData = items.slice(start, end)

当发生滚动后,由于渲染区域相对于可视区域发生了偏移,因此我们需要计算出这个偏移量,然后使用 transform 重新偏移回可视区域。

偏移量的计算:offset = start * size

思考?:偏移量的计算方式

答案:向上滑过了多少个完整的列表项,可视区域就要往下偏移多少 列表项 的高度,以此来抵消向上滑动带来的偏移。

完整代码

App.vue

<template>
  <div class="container w-[586px] h-[80vh] border mx-auto">
    <virtual-list :size="80" :items="items">
      <template #default="{ item }">
        <Item :item="item"/>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
import { onMounted, ref } from "vue";
import { fetchData } from "./utils/helpers";
import VirtualList from "./components/VirtualList/index.vue";
import Item from './components/Item/index.vue';

const items = ref([]);

const fetchList = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(fetchData());
    }, 800);
  });
}

onMounted(async () => {
  items.value = await fetchList();
});
</script>

<style scoped>
.container {}
</style>

utils/helpers.js

import { faker } from '@faker-js/faker';

export function createRandomUser() {
  return {
    userId: faker.string.uuid(),
    username: faker.internet.username(), // before version 9.1.0, use userName()
    email: faker.internet.email(),
    avatar: faker.image.avatar(),
    password: faker.internet.password(),
    birthdate: faker.date.birthdate(),
    registeredAt: faker.date.past(),
    phone: faker.phone.number(),
    desc: faker.lorem.sentences(),
  };
}

export const users = faker.helpers.multiple(createRandomUser, {
  count: 100,
});

export function fetchData(count = 30) {
  return users;
}

components/VirtualList/index.vue

<template>
  <!-- 外层容器 -->
  <div class="viewport relative overflow-y-auto h-full" ref="viewportRef" @scroll="handleScroll">
    <!-- 列表占位区域 -->
    <div class="scroll-bar" ref="scrollBarRef" :style="{ height: listHeight + 'px' }"></div>
    <!-- 真实渲染的可见区域 -->
     <div class="scroll-list absolute left-0 top-0 w-full" ref="scrollListRef" :style="{ transform: `translate3d(0, ${scrollState.offset}px, 0)` }">
      <template v-for="item of visibleData">
        <slot :item="item"></slot>
      </template>
     </div>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive, computed } from "vue";
const props = defineProps({
  size: Number, // 当前每一项的高度
  items: Array, // 列表
});
const viewportRef = ref(null); // 容器 ref
const scrollBarRef = ref(null); // 占位 ref
const scrollListRef = ref(null); // 渲染 ref

const scrollState = reactive({
  start: 0, // 可视区域头索引
  end: 0,   // 可视区域尾索引
  offset: 0,// 渲染区域回调距离
});

// 容器高度
const containerHeight = ref(0);
// 可视区域个数
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.size));
// 列表总高度
const listHeight = computed(() => props.items.length * props.size);
// 可视区域列表
const visibleData = computed(() => {
  return props.items.slice(scrollState.start, scrollState.end);
});

onMounted(() => {
  // 获取容器高度
  containerHeight.value = viewportRef.value.clientHeight;
  // 头索引
  scrollState.start = 0;
  // 尾索引 = 头索引 + 渲染个数
  scrollState.end = scrollState.start + visibleCount.value;
});

const handleScroll = () => {
  // 计算当前滚了多少个 item 到上边, 然后计算出当前应该从第几个开始显示
  const scrollTop = viewportRef.value.scrollTop;
  // 获取当前应该从第几个开始渲染
  scrollState.start = Math.floor(scrollTop / props.size); // 譬如向上滑动列表时,scrollTop = 170, size = 80 ,那么已经完全滑过的列表项就是 2 个(2 * 80), 第三个列表项的高度滑过了 10px, 但剩下的还能看到,所以 start 应该就是这个没有完全消失的列表项的索引
  scrollState.end = scrollState.start + visibleCount.value;
  // 定位当前的可视区域(滚过去多少个完整的item,可视区域就要往下偏移多少 item 的高度抵消偏移)
  scrollState.offset = scrollState.start * props.size;
}
</script>

<style lang="scss" scoped></style>

components/Item/index.vue

<template>
  <div class="h-[80px] w-full border">
    {{  item.userId }}
  </div>
</template>

<script setup>
const props = defineProps({
  item: Object,
});
</script>

效果

Shottr 2025-02-20 22.10.53.png

实现(列表缓冲区)

现在还有个体验不太好的地方,虽然我们的 visibleCount 已经向上取整保证了初始化时列表项个数铺满了整个可视区域,但如果向上滚动会发现,如果当前 start 起始位置的列表项快要消失之前, start 是不会变的,这就导致底部最后一个 end 列表项已经完全展示在可视区域并且 end 的下一项的“头”也该露出了,但由于可视区域的列表项只到 end 导致底部会有空白:

image.png

所以我们需要给当前可视区域的列表项前后都添加缓冲,补齐这些空白。我们来添加两个必要的计算属性参数:

components/VirtualList/index.vue

<template>
  <!-- 容器 -->
  <div class="viewport relative overflow-y-auto h-full" ref="viewportRef" @scroll="handleScroll">
    <!-- 列表占位总高 -->
    <div class="scroll-bar" ref="scrollBarRef" :style="{ height: listHeight + 'px' }"></div>
    <!-- 真实渲染的可见区域 -->
     <div class="scroll-list absolute left-0 top-0 w-full" ref="scrollListRef" :style="{ transform: `translate3d(0, ${scrollState.offset}px, 0)` }">
      <template v-for="item of visibleData">
        <slot :item="item"></slot>
      </template>
     </div>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive, computed } from "vue";
const props = defineProps({
  size: Number, // 当前每一项的高度
  items: Array, // 列表
});
const viewportRef = ref(null); // 容器 ref
const scrollBarRef = ref(null); // 占位 ref
const scrollListRef = ref(null); // 渲染 ref

const scrollState = reactive({
  start: 0, // 可视区域头索引
  end: 0,   // 可视区域尾索引
  offset: 0,// 渲染区域回调距离
});

// 容器高度
const containerHeight = ref(0);
// 可见区域个数
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.size));
// 列表总高度
const listHeight = computed(() => props.items.length * props.size);
// start 前面预加载数量
const prevCount = computed(() => {
  return Math.min(scrollState.start, visibleCount.value); // 前面预缓存一屏的个数
});
// end 后面预加载数量
const nextCount = computed(() => {
  return Math.min(props.items.length - scrollState.end, visibleCount.value); // 后面预缓存一屏的个数
});
// 可见区域列表
const visibleData = computed(() => {
  let start = scrollState.start - prevCount.value;
  let end = scrollState.end + nextCount.value;
  return props.items.slice(start, end);
});

onMounted(() => {
  // 获取容器高度
  containerHeight.value = viewportRef.value.clientHeight;
  // 头索引
  scrollState.start = 0;
  // 尾索引 = 头索引 + 渲染个数
  scrollState.end = scrollState.start + visibleCount.value;
});

const handleScroll = () => {
  // 计算当前滚了多少个 item 到上边, 然后计算出当前应该从第几个开始显示
  const scrollTop = viewportRef.value.scrollTop;
  // 获取当前应该从第几个开始渲染
  scrollState.start = Math.floor(scrollTop / props.size); // 譬如 scrollTop = 170, size = 80 那么就是完全滚没了 2 个(2 * 80), 第三个滚没了 10px, 但第三个由于没有完全消失,所以 start 应该就是这个没有完全消失的 item 的索引
  scrollState.end = scrollState.start + visibleCount.value;
  // 定位当前的可视区域(滚过去多少个完整的item,可视区域就要往下偏移多少 item 的高度抵消偏移)
  // 如果有预渲染,就应该把这个位置再向上移动预加载总高度的距离,因为多的这些预渲染的列表项,会导致让原本定位准确的可视范围列表区域向下坍塌预渲染的列表项的总高度的距离,所以为了抵消得减去这段距离
  scrollState.offset = scrollState.start * props.size - prevCount.value * props.size;
}
</script>

<style lang="scss" scoped></style>

现在底部不会有空白了:

image.png

实现(动态高度列表)

上面其实已经算是完整的实现了虚拟列表的功能。

但在实际开发中,往往会遇到列表高度不固定的情况,例如朋友圈列表、订单列表等。这种不固定高度的列表项会导致我们没办法计算列表总高,所以想到一种办法就是让用户先传入一个预估的列表高度做初次渲染,然后存储一份列表的位置信息。再当用户滑动列表时,实施的根据已渲染的列表高度重新计算列表项的位置信息和列表总高,做到实时更新最新最准确的列表高度。

布局调整

首先我们先来对布局重新调整,修改固定高度的列表项为根据自身内容撑开的高度:

components/Item/index.vue

<template>
  <div class="h-full w-full border flex flex-nowrap p-4">
    <img :src="item.avatar" alt="avatar" class="w-[60px] h-[60px] rounded-full overflow-hidden mr-4">
    <div class="flex flex-col flex-1">
      <div><span class="font-bold">username:</span> {{ item.username }}</div>
      <div><span class="font-bold">email:</span> {{ item.email }}</div>
      <div><span class="font-bold">desc:</span> {{ item.desc }}</div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  item: Object,
});
</script>

组件添加动态高度的标识

再给 VirtualList 组件添加一个 variable 属性用来标记当前要渲染的列表是个不定高度的列表:

components/VirtualList/index.vue

<template>
  ...
</template>

<script setup>
import { onMounted, ref, reactive, computed } from "vue";
const props = defineProps({
  size: Number, // 当前每一项的高度
  items: Array, // 列表
  variable: Boolean, // 动态高度
});
...
</script>

App.vue

<template>
  <div class="container w-[586px] h-[80vh] border mx-auto">
    <virtual-list :size="80" :items="items" :variable="true">
      <template #default="{ item }">
        <Item :item="item"/>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
...
</script>

缓存列表项几何信息topheightbottom

一旦页面加载完毕,就缓存每一项的位置信息(topheightbottom),这样就知道每一项元素在什么位置以及具体高度了:

components/VirtualList/index.vue

<template>
  <!-- 容器 -->
  <div class="viewport relative overflow-y-auto h-full" ref="viewportRef" @scroll="handleScroll">
    <!-- 列表占位总高 -->
    <div class="scroll-bar" ref="scrollBarRef" :style="{ height: listHeight + 'px' }"></div>
    <!-- 真实渲染的可见区域 -->
     <div class="scroll-list absolute left-0 top-0 w-full" ref="scrollListRef" :style="{ transform: `translate3d(0, ${scrollState.offset}px, 0)` }">
      <template v-for="item of visibleData">
        <slot :item="item"></slot>
      </template>
     </div>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive, computed, watch } from "vue";
const props = defineProps({
  size: Number, // 当前每一项的高度
  items: Array, // 列表
  variable: Boolean, // 动态高度
});
const viewportRef = ref(null); // 容器 ref
const scrollBarRef = ref(null); // 占位 ref
const scrollListRef = ref(null); // 渲染 ref

const scrollState = reactive({
  start: 0, // 可视区域头索引
  end: 0,   // 可视区域尾索引
  offset: 0,// 渲染区域回调距离
});

let positions = ref([]); // 缓存列表项的位置和高度信息
// 容器高度
const containerHeight = ref(0);
// 可见区域个数
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.size));
// 列表总高度
const listHeight = computed(() => props.items.length * props.size);
// start 前面预加载数量
const prevCount = computed(() => {
  return Math.min(scrollState.start, visibleCount.value); // 前面预缓存一屏的个数
});
// end 后面预加载数量
const nextCount = computed(() => {
  return Math.min(props.items.length - scrollState.end, visibleCount.value); // 后面预缓存一屏的个数
});
// 可见区域列表
const visibleData = computed(() => {
  let start = scrollState.start - prevCount.value;
  let end = scrollState.end + nextCount.value;
  return props.items.slice(start, end);
});

// 缓存列表项的高度
// 缓存每一项的位置信息( top、height、bottom),这样就知道每一项元素在什么位置以及具体高度了
const cacheList = () => {
  positions.value = props.items.map((item, idx) => ({
    top: idx * props.size,
    height: props.size,
    bottom: (idx + 1) * props.size,
  }));
  console.log(positions.value);
}
watch(() => props.items, (newVal) => {
  // 先记录一次,等一会滚动的时候,去渲染页面时获取真实 DOM 的高度,来更新缓存的信息; 最后再重新计算占位区域的总高度
  cacheList();
});

onMounted(() => {
  // 获取容器高度
  containerHeight.value = viewportRef.value.clientHeight;
  // 头索引
  scrollState.start = 0;
  // 尾索引 = 头索引 + 渲染个数
  scrollState.end = scrollState.start + visibleCount.value;
});

const handleScroll = () => {
  // 计算当前滚了多少个 item 到上边, 然后计算出当前应该从第几个开始显示
  const scrollTop = viewportRef.value.scrollTop;
  // 获取当前应该从第几个开始渲染
  scrollState.start = Math.floor(scrollTop / props.size); // 譬如 scrollTop = 170, size = 80 那么就是完全滚没了 2 个(2 * 80), 第三个滚没了 10px, 但第三个由于没有完全消失,所以 start 应该就是这个没有完全消失的 item 的索引
  scrollState.end = scrollState.start + visibleCount.value;
  // 定位当前的可视区域(滚过去多少个完整的item,可视区域就要往下偏移多少 item 的高度抵消偏移)
  // 如果有预渲染,就应该把这个位置再向上移动预加载总高度的距离,因为多的这些预渲染的列表项,会导致让原本定位准确的可视范围列表区域向下坍塌预渲染的列表项的总高度的距离,所以为了抵消得减去这段距离
  scrollState.offset = scrollState.start * props.size - prevCount.value * props.size;
}
</script>

<style lang="scss" scoped></style>

利用二分查找查找起始索引

在固定高度的列表中,可以通过公式 start = Math.floor(scrollTop / size) 计算起始索引。

但高度不定时,每个元素的高度差异导致无法直接通过滚动偏移量推导出对应的索引。所以我们只能通过从 position 中记录的值里一个个遍历去查找当前的可视区域中的列表的起始索引应该从哪儿开始。而一个个遍历又特别耗费性能,所以我们通过 二分查找 的算法来快速定位 start

components/VirtualList/index.vue

<template>
  <!-- 容器 -->
  <div class="viewport relative overflow-y-auto h-full" ref="viewportRef" @scroll="handleScroll">
    <!-- 列表占位总高 -->
    <div class="scroll-bar" ref="scrollBarRef" :style="{ height: listHeight + 'px' }"></div>
    <!-- 真实渲染的可见区域 -->
     <div class="scroll-list absolute left-0 top-0 w-full" ref="scrollListRef" :style="{ transform: `translate3d(0, ${scrollState.offset}px, 0)` }">
      <template v-for="item of visibleData">
        <slot :item="item"></slot>
      </template>
     </div>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive, computed, watch } from "vue";
const props = defineProps({
  size: Number, // 当前每一项的高度
  items: Array, // 列表
  variable: Boolean, // 动态高度
});
const viewportRef = ref(null); // 容器 ref
const scrollBarRef = ref(null); // 占位 ref
const scrollListRef = ref(null); // 渲染 ref

const scrollState = reactive({
  start: 0, // 可视区域头索引
  end: 0,   // 可视区域尾索引
  offset: 0,// 渲染区域回调距离
});

let positions = ref([]); // 缓存列表项的位置和高度信息
// 容器高度
const containerHeight = ref(0);
// 可见区域个数
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.size));
// 列表总高度
const listHeight = computed(() => props.items.length * props.size);
// start 前面预加载数量
const prevCount = computed(() => {
  return Math.min(scrollState.start, visibleCount.value); // 前面预缓存一屏的个数
});
// end 后面预加载数量
const nextCount = computed(() => {
  return Math.min(props.items.length - scrollState.end, visibleCount.value); // 后面预缓存一屏的个数
});
// 可见区域列表
const visibleData = computed(() => {
  let start = scrollState.start - prevCount.value;
  let end = scrollState.end + nextCount.value;
  return props.items.slice(start, end);
});

// 缓存列表项的高度
// 缓存每一项的位置信息( top、height、bottom),这样就知道每一项元素在什么位置以及具体高度了
const cacheList = () => {
  positions.value = props.items.map((item, idx) => ({
    top: idx * props.size,
    height: props.size,
    bottom: (idx + 1) * props.size,
  }));
  console.log(positions.value);
}
watch(() => props.items, (newVal) => {
  // 先记录一次,等一会滚动的时候,去渲染页面时获取真实 DOM 的高度,来更新缓存的信息; 最后再重新计算占位区域的总高度
  cacheList();
});

onMounted(() => {
  // 获取容器高度
  containerHeight.value = viewportRef.value.clientHeight;
  // 头索引
  scrollState.start = 0;
  // 尾索引 = 头索引 + 渲染个数
  scrollState.end = scrollState.start + visibleCount.value;
});

const getStartIndex = value => {
  let start = 0; // 左边界
  let end = props.items.length - 1; // 右边界
  let temp = null; // 作用:用来在没有找到完全匹配时,记录一个最接近目标滚动位置 value 的索引。当二分查找结束时,如果没有精确匹配的项,temp 就是一个合适的起始索引。

  while(start < end) {
    let middleIdx = parseInt((start + end) / 2); // 拿到中间索引
    let middleVal = positions.value[middleIdx].bottom; // 找到当前列表中间的那一项的结尾
    if (middleVal === value) { // 如果滚动距离正好等于中间列表项的底部, 那么直接取这一项的下一项为 start 起始索引
      return middleIdx + 1;
    } else if (middleVal > value) { // 证明目标项在 positions[middleIdx] 的左边
      if (temp === null || temp > middleIdx) {
        // note: 有可能 scrollTop 是 125.5 这样的小数,就可能不命中 middleVal === value 的情况
        // 如果没有找到精确匹配的项,那么二分查找最终会导致 start 和 end 重叠或越过。
        // 此时,temp 就保存了一个在查找过程中最接近的索引。
        // 在很多情况下,middleVal 会比较接近 value,但由于浮动或小数的原因,可能无法精确匹配。这时 temp 就起到了容错作用。
        temp = middleIdx; // 找到最近索引的上一个
      }
      end = middleIdx - 1;
    } else if (middleIdx < value) { // 证明目标项在 positions[middleIdx] 的右边
      start = middleIdx + 1;
    }
  }
  return temp;
}

const handleScroll = () => {
  // 计算当前滚了多少个 item 到上边, 然后计算出当前应该从第几个开始显示
  const scrollTop = viewportRef.value.scrollTop;

  if (props.variable) {
    // 如果列表项是动态高度,就用二分查找去查询起始索引
    scrollState.start = getStartIndex(scrollTop);
    scrollState.end = scrollState.start + visibleCount.value;
    scrollState.offset = positions.value[scrollState.start - prevCount.value] ? positions.value[scrollState.start - prevCount.value].top : 0;
  } else {
    // 获取当前应该从第几个开始渲染
    scrollState.start = Math.floor(scrollTop / props.size); // 譬如 scrollTop = 170, size = 80 那么就是完全滚没了 2 个(2 * 80), 第三个滚没了 10px, 但第三个由于没有完全消失,所以 start 应该就是这个没有完全消失的 item 的索引
    scrollState.end = scrollState.start + visibleCount.value;
    // 定位当前的可视区域(滚过去多少个完整的item,可视区域就要往下偏移多少 item 的高度抵消偏移)
    // 如果有预渲染,就应该把这个位置再向上移动预加载总高度的距离,因为多的这些预渲染的列表项,会导致让原本定位准确的可视范围列表区域向下坍塌预渲染的列表项的总高度的距离,所以为了抵消得减去这段距离
    scrollState.offset = scrollState.start * props.size - prevCount.value * props.size;
  }
}
</script>

<style lang="scss" scoped></style>

用真实DOM信息替换预估列表项位置

现在我们使用二分查找计算出了 startend ,但是这个position还是我们在初始化时预估的列表项位置和高度,并不是实际各个列表项的信息。所以我们还需要在每次页面更新后用实际DOM的几何信息替换掉预估的列表项的几何信息(topheightbottom)。

更新真实 DOM 信息的操作我们放到 onUpdated 中去做。

components/VirtualList/index.vue