Lzh on GitHub
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchServerPage = async (
  limit: number,
  offset: number = 0,
): Promise<{ rows: string[]; nextOffset: number }> => {
  const rows = new Array(limit)
    .fill(0)
    .map((e, i) => `Async loaded row #${i + offset * limit}`)

  await new Promise((r) => setTimeout(r, 500))

  return { rows, nextOffset: offset + 1 }
}

const {
  status,
  data,
  error,
  isFetching,
  isFetchingNextPage,
  fetchNextPage,
  hasNextPage,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: (ctx) => fetchServerPage(10, ctx.pageParam),
  getNextPageParam: (_lastGroup, groups) => groups.length,
})

const allRows = computed(() =>
  data.value ? data.value.pages.flatMap((d) => d.rows) : [],
)

const parentRef = ref<HTMLElement | null>(null)

const rowVirtualizerOptions = computed(() => {
  return {
    count: hasNextPage ? allRows.value.length + 1 : allRows.value.length,
    getScrollElement: () => parentRef.value,
    estimateSize: () => 100,
    overscan: 5,
  }
})

const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)

const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())

const totalSize = computed(() => rowVirtualizer.value.getTotalSize())

watchEffect(() => {
  const [lastItem] = [...virtualRows.value].reverse()

  if (!lastItem) {
    return
  }

  if (
    lastItem.index >= allRows.value.length - 1 &&
    hasNextPage.value &&
    !isFetchingNextPage.value
  ) {
    fetchNextPage()
  }
})
</script>

<template>
  <div>
    <p>
      这个无限滚动示例使用 Vue Query 的 useInfiniteScroll 组合式函数来从一个帖子 API 端点获取无限数据,然后使用 rowVirtualizer 和放置在列表底部的加载行来触发下一页的加载。
    </p>

    <br />
    <br />
    <p v-if="status === 'loading'">Loading...</p>
    <p v-else-if="status === 'error'">Error: {{ (error as Error).message }}</p>
    <div
      v-else
      ref="parentRef"
      class="List"
      style="height: 500px; width: 100%; overflow: auto"
    >
      <div
        :style="{
          height: `${totalSize}px`,
          width: '100%',
          position: 'relative',
        }"
      >
        <div
          v-for="virtualRow in virtualRows"
          :key="virtualRow.key"
          :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'"
          :style="{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: `${virtualRow.size}px`,
            transform: `translateY(${virtualRow.start}px)`,
          }"
        >
          <template v-if="virtualRow.index > allRows.length - 1">
            {{ hasNextPage ? 'Loading more...' : 'Nothing more to load' }}
          </template>
          <template v-else>
            {{ allRows[virtualRow.index] }}
          </template>
        </div>
      </div>
    </div>
    <div v-if="isFetching && !isFetchingNextPage">Background Updating...</div>
  </div>
</template>

<style scoped>
.List {
  border: 1px solid #e6e4dc;
  max-width: 100%;
}

.ListItemEven,
.ListItemOdd {
  display: flex;
  align-items: center;
  justify-content: center;
}

.ListItemEven {
  background-color: #e6e4dc;
}

button {
  border: 1px solid gray;
}
</style>