Lzh on GitHub
<script setup lang="ts">
import './index.css'
import { computed, ref, h } from 'vue'
import {

  FlexRender,
  useVueTable,
  getCoreRowModel,
  getSortedRowModel
} from '@tanstack/vue-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { useVirtualizer } from '@tanstack/vue-virtual'

import { makeData } from './virtualizedRowsMakeData'
import type { Person } from './virtualizedRowsMakeData'

const search = ref('')

const data = ref<Person[]>(makeData(100))

/**
 * 计算属性:根据搜索关键词过滤后的数据
 * @returns 过滤后的 Person 数组
 */
const filteredData = computed<Person[]>(() => {
  // 获取搜索关键词并转换为小写以便进行不区分大小写的匹配
  const searchValue = search.value.toLowerCase()

  // 如果没有搜索关键词,则返回所有数据
  if (!searchValue) return data.value

  // 根据搜索关键词过滤数据
  return data.value.filter((row) => {
    // 检查行中的每个字段值是否包含搜索关键词
    return Object.values(row).some((value) => {
      // 如果值是日期类型,将其转换为本地字符串进行比较
      if (value instanceof Date) {
        return value.toLocaleString().toLowerCase().includes(searchValue)
      }
      // 将值转换为字符串并检查是否包含搜索关键词
      return `${value}`.toLowerCase().includes(searchValue)
    })
  })
})

// 声明一个变量用于存储防抖定时器的引用
let searchTimeout: NodeJS.Timeout
/**
 * 处理搜索输入的防抖函数
 * 通过延迟更新搜索值来减少频繁的过滤操作
 * @param ev - 输入事件对象
 */
function handleDebounceSearch(ev: Event) {
  // 如果已存在定时器,则清除之前的定时器(重置防抖)
  if (searchTimeout) {
    clearTimeout(searchTimeout)
  }

  // 设置新的定时器,在300ms后更新搜索值
  searchTimeout = setTimeout(() => {
    // 从事件目标中获取输入框的值并更新搜索状态
    search.value = (ev?.target as HTMLInputElement)?.value ?? ''
  }, 300) // 300ms的防抖延迟
}

const columns = computed<ColumnDef<Person>[]>(() => [
  {
    accessorKey: 'id',
    header: 'ID'
  },
  {
    accessorKey: 'firstName',
    cell: info => info.getValue()
  },
  {
    accessorFn: row => row.lastName,
    id: 'lastName',
    cell: info => info.getValue(),
    header: () => h('span', 'Last Name')
  },
  {
    accessorKey: 'age',
    header: () => 'Age'
  },
  {
    accessorKey: 'visits',
    header: () => h('span', 'Visits')
  },
  {
    accessorKey: 'status',
    header: 'Status'
  },
  {
    accessorKey: 'progress',
    header: 'Profile Progress'
  },
  {
    accessorKey: 'createdAt',
    header: 'Created At',
    cell: info => info.getValue<Date>().toLocaleString()
  }
])

const table = useVueTable({
  get data() {
    return filteredData.value
  },
  columns: columns.value,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  debugTable: false
})

// 计算属性:获取表格的行模型数据
const rows = computed(() => table.getRowModel().rows)

// The virtualizer needs to know the scrollable container element
// 创建对表格容器元素的引用,虚拟化器需要知道滚动容器元素
const tableContainerRef = ref<HTMLDivElement | null>(null)

// 计算属性:配置虚拟化器的选项
const rowVirtualizerOptions = computed(() => {
  return {
    // 数据总条数,用于计算滚动条大小
    count: rows.value.length,
    // 估算每行的高度(像素),用于准确的滚动条拖拽
    estimateSize: () => 33, // estimate row height for accurate scrollbar dragging
    // 获取滚动容器元素的回调函数
    getScrollElement: () => tableContainerRef.value,
    // 预渲染的行数,避免滚动时出现空白区域
    overscan: 5
  }
})

// 创建行虚拟化器实例
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)

// 计算属性:获取虚拟化的行项目列表
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
// 计算属性:获取虚拟化内容的总高度
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())

/**
 * 测量元素实际高度的函数
 * @param el - 要测量的DOM元素
 */
function measureElement(el?: Element) {
  // 如果元素不存在,则直接返回
  if (!el) {
    return
  }

  // 调用虚拟化器的measureElement方法来测量元素高度
  rowVirtualizer.value.measureElement(el)

  return undefined
}
</script>

<template>
  <div>
    <p class="text-center">
      对于表格,translate CSS 函数的偏移量是基于行本身的初始位置。正因为如此,我们需要计算 translateY 的像素计数差值,并将其基于索引。
    </p>
    <h1 class="text-3xl font-bold text-center">
      Virtualized Rows
    </h1>
    <div style="margin: 0 auto; width: min-content">
      <input
        :modelValue="search"
        placeholder="Search"
        class="p-2"
        @input="handleDebounceSearch"
      >
      {{ rows.length.toLocaleString() }} results
    </div>
  </div>
  <div
    ref="tableContainerRef"
    class="container"
    :style="{
      overflow: 'auto', //our scrollable table container
      position: 'relative', //needed for sticky header
      height: '800px' //should be a fixed height
    }"
  >
    <div :style="{ height: `${totalSize}px` }">
      <!-- Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights -->
      <table :style="{ display: 'grid' }">
        <thead
          :style="{
            display: 'grid',
            position: 'sticky',
            top: 0,
            zIndex: 1
          }"
        >
          <tr
            v-for="headerGroup in table.getHeaderGroups()"
            :key="headerGroup.id"
            :style="{ display: 'flex', width: '100%' }"
          >
            <th
              v-for="header in headerGroup.headers"
              :key="header.id"
              :colspan="header.colSpan"
              :style="{ width: `${header.getSize()}px` }"
            >
              <div
                v-if="!header.isPlaceholder"
                :class="{
                  'cursor-pointer select-none': header.column.getCanSort()
                }"
                @click="e => header.column.getToggleSortingHandler()?.(e)"
              >
                <FlexRender
                  :render="header.column.columnDef.header"
                  :props="header.getContext()"
                />
                <span v-if="header.column.getIsSorted() === 'asc'"> 🔼</span>
                <span v-if="header.column.getIsSorted() === 'desc'"> 🔽</span>
              </div>
            </th>
          </tr>
        </thead>
        <tbody
          :style="{
            display: 'grid',
            height: `${totalSize}px`, //tells scrollbar how big the table is
            position: 'relative' //needed for absolute positioning of rows
          }"
        >
          <tr
            v-for="vRow in virtualRows"
            :ref="measureElement /*measure dynamic row height*/"
            :key="rows[vRow.index].id"
            :data-index="
              vRow.index /* needed for dynamic row height measurement*/
            "
            :style="{
              display: 'flex',
              position: 'absolute',
              transform: `translateY(${vRow.start}px)`, //this should always be a `style` as it changes on scroll
              width: '100%'
            }"
          >
            <td
              v-for="cell in rows[vRow.index].getVisibleCells()"
              :key="cell.id"
              :style="{
                display: 'flex',
                width: `${cell.column.getSize()}px`
              }"
            >
              <FlexRender
                :render="cell.column.columnDef.cell"
                :props="cell.getContext()"
              />
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>