Lzh on GitHub
<template>
  <div>
    <div
      ref="parentRef"
      class="List"
      style="height: 300px; width: 400px; overflow: auto"
    >
      <div
        :style="{
          height: `${totalSize}px`,
          width: '100%',
          position: 'relative'
        }"
      >
        <div
          v-for="virtualRow in virtualRows"
          :key="virtualRow.index"
          :class="['ListItem', { Sticky: isSticky(virtualRow.index) }]"
          :style="{
            ...(isSticky(virtualRow.index)
              ? {
                background: '#fff',
                borderBottom: '1px solid #ddd',
                zIndex: 1
              }
              : {}),
            ...(isActiveSticky(virtualRow.index)
              ? { position: 'sticky' }
              : {
                position: 'absolute',
                transform: `translateY(${virtualRow.start}px)`
              }),
            top: 0,
            left: 0,
            width: '100%',
            height: `${virtualRow.size}px`
          }"
        >
          {{ rows[virtualRow.index] }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { faker } from '@faker-js/faker'
import { findIndex, groupBy } from 'lodash'
import { useVirtualizer, defaultRangeExtractor } from '@tanstack/vue-virtual'

// 使用 faker 生成1000个随机名字,并按字母顺序排序后按首字母分组
// groupBy 会创建一个对象,键为首字母,值为对应首字母的名字数组
const groupedNames = groupBy(
  Array.from({ length: 1000 }) // 创建长度为1000的数组
    .map(() => faker.person.firstName()) // 生成1000个随机名字
    .sort(), // 按字母顺序排序
  (name: string[]) => name[0] // 按名字的首字母分组
)

// 获取所有分组的首字母键(如 ['A', 'B', 'C', ...])
const groups = Object.keys(groupedNames)

// 将分组数据扁平化为一维数组,格式为:[分组标题, 分组内名字1, 分组内名字2, ..., 下一个分组标题, ...]
// 例如: ['A', 'Alice', 'Amy', 'Andrew', 'B', 'Bob', 'Ben', ...]
const rows = groups.reduce<string[]>(
  (acc, k) => [...acc, k, ...groupedNames[k]], // 将每个分组的标题和内容依次添加到数组中
  []
)

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

// 创建用于存储当前激活的粘性头部索引的响应式变量
// 初始值为0,表示第一个粘性头部处于激活状态
const activeStickyIndexRef = ref(0)

// 计算属性:获取所有粘性头部的索引位置
// 通过在 rows 数组中查找每个分组名称的位置来确定粘性头部的索引
const stickyIndexes = computed(() =>
  groups.map(gn => findIndex(rows, (n: string) => n === gn))
)

// 判断指定索引是否为粘性头部
// @param index - 要检查的行索引
// @returns 如果是粘性头部返回 true,否则返回 false
const isSticky = (index: number) => stickyIndexes.value.includes(index)

// 判断指定索引是否为当前激活的粘性头部
// @param index - 要检查的行索引
// @returns 如果是当前激活的粘性头部返回 true,否则返回 false
const isActiveSticky = (index: number) => activeStickyIndexRef.value === index

const rowVirtualizer = useVirtualizer({
  // 数据总条数,等于处理后的行数组长度
  count: rows.length,
  // 估算每行的高度为50像素
  estimateSize: () => 50,
  // 获取滚动容器元素的回调函数
  getScrollElement: () => parentRef.value,
  // 自定义范围提取器,用于确保粘性头部始终可见
  rangeExtractor: (range) => {
    // 从后往前查找,找到第一个 startIndex 大于等于当前索引的粘性头部
    // 并将其设置为当前激活的粘性头部
    activeStickyIndexRef.value = [...stickyIndexes.value]
      .reverse() // 反转数组以便从后往前查找
      .find(index => range.startIndex >= index) // 找到合适的粘性头部索引

    // 创建一个新的集合,包含:
    // 1. 当前激活的粘性头部索引
    // 2. 默认范围提取器返回的索引列表
    const next = new Set([
      activeStickyIndexRef.value,
      ...defaultRangeExtractor(range)
    ])

    // 将索引按升序排列并返回
    return [...next].sort((a, b) => a - b)
  }
})

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

const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
</script>

<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>