不定高虚拟列表

在前端开发中,处理海量数据列表是一个常见的性能挑战。当列表项成千上万时,直接渲染所有 DOM 节点会导致页面卡顿甚至崩溃。虚拟列表(Virtual List) 是解决这一问题的最佳实践,它只渲染可视区域内的列表项,大大减少了 DOM 节点数量,从而实现丝滑般的滚动体验。

本文将手把手教你如何从零开始,实现一个功能完备、支持可变高度的高性能虚拟列表组件。

核心思想:只渲染"看得见"的部分

虚拟列表的核心思想非常直观: "用空间换时间"

它通过计算和只渲染当前用户可见区域内的列表项,同时利用一个大高度的空白占位元素(empty-block)来模拟完整列表的滚动条,从而欺骗浏览器,让用户以为整个列表都在页面上。

我们的实现将包含以下几个关键步骤:

  1. 确定可视区域:通过监听滚动事件,动态计算当前可视区域的起始和结束索引。
  2. 数据裁剪与渲染:根据可视区域的索引,从完整数据中截取出一部分,并将其渲染到页面上。
  3. 动态定位 :利用 transform: translateY() 属性,将渲染的列表块精确地定位到正确的位置。
  4. 可变高度处理:这是难点,我们需要一个数据结构来动态存储和更新每个列表项的实际高度和位置。

代码实现与核心逻辑剖析

以下是虚拟列表组件的完整代码。我将通过注释和分段讲解,带你深入理解每一个细节。

📜 组件模板 (template)

组件的 DOM 结构非常简洁,主要由三个部分组成:

  • .virtual-content:承载所有内容的容器,负责监听滚动事件。
  • .empty-block:一个巨大的空白占位元素,它的高度等于所有列表项的总高度,用于撑起滚动条。
  • .virtual-list:实际渲染列表项的容器,通过 transform: translateY() 实现精准定位。

HTML

ini 复制代码
<template>
  <div class="virtual-content" ref="screenRef" @scroll="scrollEvent">
    <div
      class="empty-block"
      :style="{ height: virtualTotalHeight + 'px' }"
    ></div>

    <div
      class="virtual-list"
      ref="listRef"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div v-for="item in visibleList" :key="item._index" :id="item._index">
        <slot :item="item.item"></slot>
      </div>
    </div>
  </div>
</template>

🧩 组件逻辑 (script)

核心数据管理

我们使用几个 refcomputed 变量来管理组件的状态。

  • virtualList:完整的列表数据,我们为每个列表项添加一个 _index 属性,用于唯一标识。
  • positions这是实现可变高度的关键 。它是一个数组,记录了每个列表项的预估/实际高度 (height)、顶部距离 (top) 和底部距离 (bottom)。
  • visibleCount:可视区域内可以容纳的列表项数量。
  • startIndex / endIndex:当前可视区域的起始和结束索引。
  • offsetY:列表容器的 transform: translateY 偏移量。

JavaScript

ini 复制代码
// ... 省略部分代码

interface VirtualListItem {
  _index: number;
  item: any;
}
interface PositionItem {
  height: number;
  top: number;
  bottom: number;
}

const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);

// 监听父组件传入的列表,初始化虚拟列表和位置信息
watch(
  () => props.list,
  (value: any[]) => {
    virtualList.value = value.map((item, index) => {
      return {
        _index: index,
        item,
      };
    });
    // 初始化 positions 数组,使用预估高度
    positions.value = initPositions(virtualList.value);
  },
  { immediate: true, deep: true }
);

// 计算所有列表项的总高度
const virtualTotalHeight = ref(0);

// 计算当前应渲染的可见列表,并加上缓冲区域
const visibleList = computed(() => {
  return virtualList.value.slice(
    startIndex.value - aboveCount.value,
    endIndex.value + belowCount.value
  );
});

// 计算底部缓冲区的数量
const belowCount = computed(() => {
  return Math.min(
    virtualList.value.length - endIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

// 计算顶部缓冲区的数量
const aboveCount = computed(() => {
  return Math.min(
    startIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

核心函数解析

  1. initPositions:初始化预估位置

    • 在初次渲染时,我们不知道每个列表项的实际高度。
    • 这个函数根据 estimatedItemSize(预估高度)来初始化 positions 数组,为每个列表项计算一个预估的 topbottom 值。
  2. binarySearch:二分查找优化

    • 这是一个非常重要的优化点。
    • 当滚动时,我们需要快速找到当前可视区域的第一个列表项的索引。
    • binarySearch 函数通过二分查找,根据当前的 scrollTop 值,在 positions 数组中高效地找到对应的 startIndex。这比线性遍历快得多。
  3. updateItemsSize:动态更新实际高度

    • visibleList 渲染到 DOM 后,我们可以获取每个列表项的实际高度
    • 这个函数在 onUpdated 生命周期钩子中被调用。
    • 它会遍历所有可见的 DOM 节点,获取它们的 clientHeight
    • 然后,它会比较实际高度和 positions 中记录的高度,如果存在差异,就会更新该列表项的 heighttopbottom,并级联更新它之后的所有列表项的位置信息。
  4. scrollEvent:滚动事件处理

    • 当用户滚动时,这个函数被触发。
    • 它获取 scrollTop,并使用 binarySearch 找到 startIndex
    • 然后,计算 endIndex,最后调用 setOffsetY 来定位列表。
  5. setOffsetY:设置列表偏移量

    • 这个函数根据 startIndex 和缓冲区的数量,计算出 offsetY 的值。
    • offsetY 决定了 .virtual-list 容器的 transform: translateY 偏移量,从而确保列表项能够准确地显示在可视区域。
  6. 生命周期钩子 (onMounted, onUpdated)

    • onMounted:组件挂载后,获取容器的实际高度,并计算 visibleCount 和初始的 endIndex
    • onUpdated:在数据更新后(例如 visibleList 变化导致 DOM 重新渲染),nextTick 确保 DOM 更新完毕,然后调用 updateItemsSize 来更新列表项的实际高度和位置信息,并重新计算总高度和偏移量。

完整代码

ini 复制代码
import { ref, watch, onMounted, computed, onUpdated, nextTick } from "vue";
import { cloneDeep } from "lodash-es";
const props = defineProps({
  list: {
    type: Array,
    default: () => [],
  },
  estimatedItemSize: {
    type: Number,
    default: 100,
  },
  bufferScale: {
    type: Number,
    default: 0.5,
  },
});

const emits = defineEmits(["loadmore"]);

interface VirtualListItem {
  _index: number;
  item: any;
}
interface PositionItem {
  height: number;
  top: number;
  bottom: number;
}

const virtualList = ref<VirtualListItem[]>([]);
const screenHeight = ref(0);
const screenRef = ref();
const listRef = ref();
const positions = ref<PositionItem[]>([]);
const visibleCount = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const offsetY = ref(0);
watch(
  () => props.list,
  (value: any[]) => {
    virtualList.value = value.map((item, index) => {
      return {
        _index: index,
        item,
      };
    });
    positions.value = initPositions(virtualList.value);
  },
  { immediate: true, deep: true }
);

const virtualTotalHeight = ref(0);

const visibleList = computed(() => {
  return virtualList.value.slice(
    startIndex.value - aboveCount.value,
    endIndex.value + belowCount.value
  );
});

const belowCount = computed(() => {
  return Math.min(
    virtualList.value.length - endIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

const aboveCount = computed(() => {
  return Math.min(
    startIndex.value,
    Math.floor(props.bufferScale * visibleCount.value)
  );
});

function initPositions(list: any[]) {
  return list.map((item: VirtualListItem) => {
    return {
      height: props.estimatedItemSize,
      top: item._index * props.estimatedItemSize,
      bottom: (item._index + 1) * props.estimatedItemSize,
    };
  });
}

const binarySearch = (list: PositionItem[], target: number) => {
  let left = 0;
  let right = list.length - 1;
  let tempIndex = null;

  while (left <= right) {
    let midIndex = Math.floor((left + right) / 2);
    let midValue = list[midIndex].bottom;
    if (midValue === target) {
      return midIndex + 1;
    } else if (midValue < target) {
      left = midIndex + 1;
    } else {
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex;
      }
      right = midIndex - 1;
    }
  }
  return tempIndex as number;
};

function updateItemsSize() {
  const nodes = Array.from(listRef.value.children);
  if (!nodes || !nodes.length) return;
  const clonePosition = cloneDeep(positions.value);
  (nodes as HTMLElement[]).forEach((node) => {
    const height = node.clientHeight;
    const index = Number(node.id);
    const oldHeight = clonePosition[index].height;
    const diff = oldHeight - height;
    if (Math.abs(diff)) {
      clonePosition[index].bottom -= diff;
      clonePosition[index].height = height;
      for (let k = index + 1; k < clonePosition.length; k++) {
        clonePosition[k].top = clonePosition[k - 1].bottom;
        clonePosition[k].bottom -= diff;
      }
    }
  });
  positions.value = clonePosition;
}

async function scrollEvent(e: Event) {
  const scrollTop = (e.target as HTMLElement).scrollTop;
  startIndex.value = binarySearch(positions.value, scrollTop);
  endIndex.value = startIndex.value + visibleCount.value;
  setOffsetY();
}

function setOffsetY() {
  if (startIndex.value >= 1) {
    const size =
      positions.value[startIndex.value - 1].bottom -
      positions.value[startIndex.value - aboveCount.value].top;
    offsetY.value = positions.value[startIndex.value].top - size;
  } else {
    offsetY.value = 0;
  }
}

onUpdated(() => {
  nextTick(() => {
    if (!positions.value.length) return;
    updateItemsSize();
    virtualTotalHeight.value =
      positions.value[positions.value.length - 1].bottom;
    setOffsetY();
  });
});
onMounted(() => {
  screenHeight.value = screenRef.value.clientHeight;
  visibleCount.value = Math.ceil(screenHeight.value / props.estimatedItemSize);
  startIndex.value = 0;
  endIndex.value = startIndex.value + visibleCount.value;
});

🎨 组件样式 (style)

为了保证组件的正确渲染和性能,CSS 样式也至关重要。

  • .virtual-content 容器设置为相对定位,overflow: auto 以便创建滚动条。
  • .empty-block.virtual-list 均采用绝对定位,确保它们可以精准地覆盖在 virtual-content 容器内。
  • z-index 的设置保证了 virtual-listempty-block 之上,同时 empty-block 负责撑开滚动条。

CSS

css 复制代码
.virtual-content {
  height: 100%;
  overflow: auto;
  position: relative;
}
.virtual-list {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 1;
}
.empty-block {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  z-index: -1;
}

使用组件

vue 复制代码
<template>
  <div class="demo">
    <VirtualList :list="list" :loadMore="loadmore">
      <template #default="{ item }">
        <div class="item">{{ item.key }} - {{ item.value }}</div>
      </template>
    </VirtualList>
  </div>
</template>

<script setup lang="ts">
import VirtualList from "./components/virtual-list/index.vue";
import faker from "faker";

import { onMounted, ref } from "vue";
const list = ref<any[]>([]);

const mockData = () => {
  const data = [];
  for (let i = 0; i < 100; i++) {
    data.push({
      value: faker.lorem.sentences(),
      key: i,
    });
  }
  list.value = data;
};

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

<style scoped>
.demo {
  height: 100vh;
}
.item {
  width: 100%;
  background-color: #fff;
  border-bottom: 1px solid red;
  padding: 20px 0;
}
</style>

演示:

结语

通过以上实现,我们成功构建了一个支持可变高度、性能优异的虚拟列表组件。它巧妙地利用了 Proxy 的能力,通过动态计算和定位,解决了长列表的渲染性能瓶颈。希望这份详细的文档能帮助你更好地理解虚拟列表的实现原理,并在你的项目中发挥作用!

相关推荐
前端AK君2 小时前
React组件库如何在vue项目中使用
前端
Moonbit3 小时前
MoonBit 再次走进清华:张宏波受邀参加「思源计划」与「程序设计训练课」
前端·后端·编程语言
RestCloud3 小时前
一站式数据集成:iPaaS 如何让开发者和业务人员都满意?
前端·后端·架构
li35743 小时前
React 核心 Hook 与冷门技巧:useReducer、useEffect、useRef 及 is 属性全解析
前端·javascript·react.js
菜市口的跳脚长颌3 小时前
Web3 基础
前端
快乐是Happy3 小时前
分享一个非常实用的防止重复提交操作
前端·javascript
王蛋1113 小时前
前端工作问题或知识记录
前端·npm·node.js
云枫晖3 小时前
JS核心知识-执行上下文
前端·javascript
麦当_3 小时前
TanStack Router File-Based Router Mask 完全指南
前端·javascript·设计模式