You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

TanStack Virtualized动态高度行窗口缩放时滚动跳变问题求助

解决TanStack Virtualized动态网格滚动跳变问题

核心问题分析

窗口缩放时列数动态变化导致行高改变,但虚拟器依赖固定estimateSize预估行高,无法匹配真实行高,进而引发滚动跳变、不流畅。

解决方案步骤

1. 存储真实行高

创建响应式对象记录每行实际测量高度,避免重复预估:

const rowHeights = ref<Record<number, number>>({});

2. 实现行高测量函数

通过行索引定位DOM元素,获取真实高度并更新虚拟器尺寸:

const measureRowHeight = (rowIndex: number) => {
  if (!containerRef.value) return;
  const rowElement = containerRef.value.querySelector(`[data-row-index="${rowIndex}"]`);
  if (rowElement) {
    const height = rowElement.getBoundingClientRect().height;
    rowHeights.value[rowIndex] = height;
    rowVirtualizer.value.updateSize(rowIndex, height);
  }
};

3. 动态更新预估尺寸

修改virtualizerOptions的estimateSize,优先使用已测量的真实高度,未测量时回退到预估值:

const virtualizerOptions = computed(() => ({
  count: rowCount.value,
  getScrollElement: () => containerRef.value,
  estimateSize: (index) => rowHeights.value[index] ?? 480,
  overscan: 5,
}));

4. 监听列数变化与窗口resize

在列数切换或窗口缩放后,重新测量可见行高度并通知虚拟器重新计算总尺寸:

// 监听列数变化
watch(columnCount, () => {
  rowHeights.value = {};
  nextTick(() => {
    virtualRows.value.forEach(row => measureRowHeight(row.index));
    rowVirtualizer.value.recomputeSizes();
  });
});

// 监听窗口resize
useEventListener('resize', () => {
  nextTick(() => {
    virtualRows.value.forEach(row => measureRowHeight(row.index));
    rowVirtualizer.value.recomputeSizes();
  });
});

5. 模板标记行索引

为虚拟行添加data-row-index属性,方便测量函数定位DOM:

<template v-for="row in virtualRows" :key="row.key" :data-row-index="row.index">
  <template v-for="colIndex in columnCount" :key="colIndex">
    <template v-if="(row.index * columnCount + colIndex - 1) < totalCardCount">
      <CardComponent
          :key="cardIDs[row.index * columnCount + colIndex - 1]"
          v-if="cardMap.has(cardIDs[row.index * columnCount + colIndex - 1])"
          :item="cardMap.get(cardIDs[row.index * columnCount + colIndex - 1])!"
      />
    </template>
  </template>
</template>

6. 处理卡片异步加载(可选)

如果卡片内容异步加载,在卡片渲染完成后触发对应行的高度测量:
在CardComponent中添加自定义事件:

// CardComponent.vue
defineEmits(['loaded']);

onMounted(() => {
  emit('loaded');
});

父组件监听事件并触发测量:

<CardComponent
    :key="cardIDs[row.index * columnCount + colIndex - 1]"
    v-if="cardMap.has(cardIDs[row.index * columnCount + colIndex - 1])"
    :item="cardMap.get(cardIDs[row.index * columnCount + colIndex - 1])!"
    @loaded="measureRowHeight(row.index)"
/>

修改后的完整代码

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useBreakpoints, useEventListener } from '@vueuse/core';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { totalCardCount, cardIDs, cardMap } from '../data/generate';
import CardComponent from './CardComponent.vue';

defineProps<{
  isVaultLoaded: boolean;
}>();

const containerRef = ref<HTMLElement | null>(null);
const rowHeights = ref<Record<number, number>>({});

const breakpoints = useBreakpoints({ sm: 740, md: 1020, lg: 1340, xl: 1640 });
const columnCount = computed(() => {
  if (breakpoints.smaller('sm').value) return 1;
  if (breakpoints.between('sm', 'md').value) return 2;
  if (breakpoints.between('md', 'lg').value) return 3;
  if (breakpoints.between('lg', 'xl').value) return 4;
  return 5;
});

const rowCount = computed(() => Math.ceil(totalCardCount / columnCount.value));

const measureRowHeight = (rowIndex: number) => {
  if (!containerRef.value) return;
  const rowElement = containerRef.value.querySelector(`[data-row-index="${rowIndex}"]`);
  if (rowElement) {
    const height = rowElement.getBoundingClientRect().height;
    rowHeights.value[rowIndex] = height;
    rowVirtualizer.value.updateSize(rowIndex, height);
  }
};

const virtualizerOptions = computed(() => ({
  count: rowCount.value,
  getScrollElement: () => containerRef.value,
  estimateSize: (index) => rowHeights.value[index] ?? 480,
  overscan: 5,
}));
const rowVirtualizer = useVirtualizer(virtualizerOptions);

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

// 监听列数变化
watch(columnCount, () => {
  rowHeights.value = {};
  nextTick(() => {
    virtualRows.value.forEach(row => measureRowHeight(row.index));
    rowVirtualizer.value.recomputeSizes();
  });
});

// 监听窗口resize
useEventListener('resize', () => {
  nextTick(() => {
    virtualRows.value.forEach(row => measureRowHeight(row.index));
    rowVirtualizer.value.recomputeSizes();
  });
});
</script>

<template>
  <div v-if="isVaultLoaded" ref="containerRef" class="grid-container hide-scrollbar">
    <div :style="{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
          class="grid-window @container"
          :style="{
          '--column-count': columnCount,
          transform: `translateY(${virtualRows[0]?.start ?? 0}px)`,
        }"
      >
        <template v-for="row in virtualRows" :key="row.key" :data-row-index="row.index">
          <template v-for="colIndex in columnCount" :key="colIndex">
            <template v-if="(row.index * columnCount + colIndex - 1) < totalCardCount">
              <CardComponent
                  :key="cardIDs[row.index * columnCount + colIndex - 1]"
                  v-if="cardMap.has(cardIDs[row.index * columnCount + colIndex - 1])"
                  :item="cardMap.get(cardIDs[row.index * columnCount + colIndex - 1])!"
                  @loaded="measureRowHeight(row.index)"
              />
            </template>
          </template>
        </template>
      </div>
    </div>
  </div>
  <div v-else class="placeholder-text">
    <p>Please select and load a vault to view cards.</p>
  </div>
</template>

<style scoped>
.grid-container {
  width: 100%;
  height: 100%;
  overflow-y: auto;
  contain: strict;
}

.grid-window {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  display: grid;
  grid-template-columns: repeat(var(--column-count), 1fr);
  gap: 1rem;
  padding: 1rem;
  grid-template-rows: repeat(auto-fill, auto);
}

.placeholder-text {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: #888;
  font-style: italic;
}

.hide-scrollbar {
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.hide-scrollbar::-webkit-scrollbar {
  display: none;
}
</style>

内容的提问来源于stack exchange,提问作者Andrew

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 11:14:51