Skip to content

Virtual List 虚拟列表

简易虚拟列表泛型组件。

组件源码
vue
<script lang="ts" generic="T" setup>
import { throttle } from '@utils/common';
import { useVirtualList } from './hooks/use-virtual-list';
import { computed, onMounted, useTemplateRef, watch } from 'vue';
import { useResizeObserver } from './hooks/use-resize-observer';

const {
  dataList,
  itemHeight,
  overscan = 5,
  hiddenScrollbar = false,
  scrollThrottleTime = 16
} = defineProps<{
  dataList: T[];
  getItemKey: (item: T) => string | number | void;
  containerHeight: string;
  itemHeight: number;
  overscan?: number;
  containerStyle?: string;
  itemStyle?: string;
  hiddenScrollbar?: boolean;
  scrollThrottleTime?: number;
}>();
const $emits = defineEmits<{
  contextmenu: [e: MouseEvent];
  rowClick: [data: T, index: number];
  scroll: [e: Event];
  change: [data: T[], beginIndex: number, endIndex: number];
}>();

const virtualListRef = useTemplateRef('virtual-list');
const handleScroll = (options?: ScrollToOptions) => {
  virtualListRef.value?.scroll(options);
};
const handleScrollTo = (index: number) => {
  if (!virtualListRef.value) {
    return;
  }

  const height = virtualListRef.value.clientHeight;
  const scrollHeight = virtualListRef.value.scrollHeight;
  if (height < scrollHeight) {
    let top = itemHeight * (index - 1);
    if (top < 0) {
      top = 0;
    }

    const scrollTop = virtualListRef.value.scrollTop;
    if (top <= scrollTop || top >= scrollTop + height) {
      virtualListRef.value.scrollTop = top;
    }
  }
};

const scrollHeight = computed(() => {
  return dataList.length * itemHeight;
});

const { renderRange, topHeight, setRenderRange, pagesize } = useVirtualList(
  virtualListRef,
  {
    itemHeight,
    overscan
  }
);

const visibleList = computed(() => {
  return dataList.slice(
    renderRange.value.beginIndex,
    renderRange.value.endIndex + 1
  );
});

watch(
  renderRange,
  (range) => {
    $emits(
      'change',
      dataList.slice(range.beginIndex, range.endIndex + 1),
      range.beginIndex,
      range.endIndex
    );
  },
  { deep: true, immediate: true }
);

watch(
  () => dataList,
  (data) => {
    if (dataList.length >= pagesize.value) {
      setRenderRange(data.length);
      return;
    }

    virtualListRef.value?.scrollTo({ top: 0, behavior: 'instant' });
  },
  { immediate: true, deep: true }
);

const handleContextmenu = (event: MouseEvent) => {
  event.preventDefault();
  $emits('contextmenu', event);
};
const handleRowClick = (data: T, index: number) => {
  $emits('rowClick', data, index);
};
const handleVirtualListScroll = throttle<Event>(
  (e) => {
    setRenderRange();
    $emits('scroll', e);
  },
  scrollThrottleTime,
  { leading: true, trailing: true }
);

onMounted(() => {
  if (virtualListRef.value) {
    pagesize.value = Math.floor(virtualListRef.value.clientHeight / itemHeight);
  }

  setRenderRange();
});

useResizeObserver(virtualListRef, () => {
  if (!virtualListRef.value) {
    return;
  }

  const size = Math.floor(virtualListRef.value.clientHeight / itemHeight);
  if (size === pagesize.value) {
    return;
  }
  pagesize.value = size;
  setRenderRange();
});

defineExpose({
  handleScroll,
  handleScrollTo
});
</script>

<template>
  <div
    ref="virtual-list"
    class="virtual-list"
    :class="{ 'virtual-list--hidden-scrollbar': hiddenScrollbar }"
    :style="`height: ${containerHeight};` + containerStyle"
    @contextmenu="handleContextmenu"
    @scroll="handleVirtualListScroll">
    <div :style="`height: ${scrollHeight}px; min-height: ${containerHeight};`">
      <div v-if="visibleList.length" :style="`height: ${topHeight}px;`"></div>
      <template
        v-for="(item, itemIndex) in visibleList"
        :key="getItemKey(item) ?? itemIndex">
        <div
          :style="`height: ${itemHeight}px; ${itemStyle}`"
          class="virtual-list__item"
          @click="handleRowClick(item, itemIndex + renderRange.beginIndex)">
          <slot :item="item" :itemIndex="itemIndex + renderRange.beginIndex" />
        </div>
      </template>
      <template v-if="!visibleList.length">
        <slot name="empty" />
      </template>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.virtual-list {
  overflow-y: auto;
  width: 100%;

  .virtual-list__item {
    position: relative;
    box-sizing: border-box;
  }
}

.virtual-list--hidden-scrollbar {
  scrollbar-width: none; /* 适用于 Firefox */

  &::-webkit-scrollbar {
    display: none;
  }
}
</style>
hooks 源码
ts
import { computed, ref, type ShallowRef } from 'vue';

export const useVirtualList = (
  element: ShallowRef<HTMLDivElement | null>,
  {
    itemHeight,
    overscan = 5
  }: {
    itemHeight: number;
    overscan?: number;
  }
) => {
  const pagesize = ref(0);
  const renderRange = ref({
    beginIndex: 0,
    endIndex: 0
  });

  /**
   * 内容盒(渲染列表)上面的空容器高度
   */
  const topHeight = computed(() => {
    return renderRange.value.beginIndex * itemHeight;
  });

  const setRenderRange = (itemCount?: number) => {
    if (!element.value) {
      return;
    }

    const height = element.value.clientHeight;
    pagesize.value = Math.floor(height / itemHeight);

    const scrollTop = element.value.scrollTop;
    if (scrollTop === 0) {
      renderRange.value = {
        beginIndex: 0,
        endIndex: pagesize.value + overscan
      };
      return;
    }

    // 处理超出最大滚动高度的情况
    const maxScrollTop = (itemCount ?? 0) * itemHeight - height;
    if (itemCount && scrollTop >= maxScrollTop) {
      element.value?.scrollTo({ top: maxScrollTop, behavior: 'instant' });
      return;
    }

    const currentLine = Math.floor(scrollTop / itemHeight);
    renderRange.value.beginIndex = currentLine - overscan;
    if (renderRange.value.beginIndex < 0) {
      renderRange.value.beginIndex = 0;
    }
    renderRange.value.endIndex = currentLine + pagesize.value + overscan;
  };

  return {
    pagesize,
    renderRange,
    topHeight,
    setRenderRange
  };
};

基本用法

传入每项高度、容器总高度、每一项的 key 以及数据列表即可。

1
示例代码
vue
<script lang="ts" setup>
import VirtualList from '@/VirtualList/VirtualList.vue';
import { ref } from 'vue';

const list = ref<number[]>([]);
for (let i = 0; i < 50; i++) {
  list.value.push(i);
}
</script>

<template>
  <div class="container">
    <virtual-list
      :item-height="30"
      container-height="210px"
      :data-list="list"
      :get-item-key="(v) => v"
      container-style="background-color: #f0f0f0;"
      item-style="color: #333; border-bottom: 1px solid #999; text-align: center;">
      <template #default="{ item }">
        {{ item + 1 }}
      </template>
    </virtual-list>
  </div>
</template>

<style lang="scss" scoped>
.container {
  display: flex;
  gap: 40px;
  height: 210px;
  box-sizing: border-box;
  border: 1px dotted #000;
}
</style>

API

Props

参数名说明类型默认值
dataList渲染列表数据T[]-
itemHeight每项高度number-
containerHeight容器高度string-
getItemKey每一项的 key(item: T) => string | number | void-
overscan预渲染的额外项目数量,提升滚动流畅度number5
containerStyle容器样式string-
itemStyle每项样式string-
hiddenScrollbar是否隐藏滚动条booleanfalse
scrollThrottleTime滚动节流时间number16

Events

事件名说明参数
contextmenu鼠标右键事件e: MouseEvent
rowClick行点击事件data: T, index: number
scroll滚动事件e: Event
change可视区域数据变化时触发visibleData: T[], beginIndex: number, endIndex: number

Slots

插槽名说明参数
default行内容{ item: T, itemIndex: number }
empty空状态内容_

Methods

方法名说明参数
handleScroll滚动到指定位置options?: ScrollToOptions
handleScrollTo滚动到指定行index: number

Released under the MIT License.