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

React-Query+虚拟列表组件内滚动抖动及卡片消失问题求助

React Query无限加载+React Virtual虚拟化滚动卡顿闪烁问题修复

问题描述

使用react-query实现无限加载信息流,结合@tanstack/react-virtual对MUI卡片做虚拟化处理。需求是应用高度固定为100vh,禁止全局滚动,仅让承载信息流的组件局部滚动,因此给该组件添加了overflowY: 'scroll'属性。

添加属性后出现异常:滚动卡顿、频繁闪烁,滚动位置计算错乱,最终所有卡片消失。移除overflowY: 'scroll'允许全局滚动后,问题消失。

相关代码

LogCards组件(承载信息流)

const LogCards = ({ pageLimit }: LogCardsProps) => {
  const {
    data: logs,
    fetchNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteLogsQuery(pageLimit);
  const { ref, inView } = useInView();

  const allLogs = useMemo(() => logs?.pages.flatMap((log) => log.results), [logs]) || [];
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: allLogs.length,
    getScrollElement: useCallback(() => parentRef.current, []),
    estimateSize: useCallback(() => 300, []),
    overscan: 2,
    gap: 8,
  });

  useEffect(() => {
    if (inView) fetchNextPage();
  }, [fetchNextPage, inView]);

  return !isLoading ? (
    <Box ref={parentRef} sx={{ overflowY: 'scroll' }}>
      <div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
        {allLogs &&
          rowVirtualizer.getVirtualItems().map((log, index) => {
            const LogCardData = allLogs[log.index];
            console.log(LogCardData);

            if (!LogCardData) return <span key={`no card index ${index}`}>No cards</span>;
            return (
              <LogCard
                key={LogCardData.id}
                data-index={log.index}
                ref={rowVirtualizer.measureElement}
                log={LogCardData}
              />
            );
          })}
      </div>
      <Box sx={{ height: '20px' }} ref={ref}>
        {isFetchingNextPage && <LinearProgress sx={{ m: '2px' }} />}
      </Box>
    </Box>
  ) : (
    <LinearLoader />
  );
};

LogsList组件(承载LogCards)

const LogsList = () => {
  const [pageLimit, setPageLimit] = useState(25);

  return (
    <Paper
      sx={{
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
        px: 1,
        borderRadius: { xs: '4px', md: 0 },
      }}
    >
      <Typography variant="h5" textAlign="center" sx={{ py: 2 }}>
        List of unsolved logs
      </Typography>
      <LogsLimitButtons pageLimit={pageLimit} setPageLimit={setPageLimit} />
      <LogCards pageLimit={pageLimit} />
    </Paper>
  );
};

应用整体布局

const LaunchDesktop = () => {
  return (
    <Grid2 container spacing={1}>
      <Grid2
        size={{ xs: 12, md: 8 }}
        sx={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <SosForm />
      </Grid2>
      <Grid2 size={{ xs: 12, md: 4 }}>
        <LogsList />
      </Grid2>
    </Grid2>
  );
};

问题原因与修复方案

核心原因

  1. 滚动容器无固定高度:LogCards的滚动Box仅设置了overflowY: 'scroll',未限制高度。父组件是flex布局,滚动容器会被无限拉伸,导致虚拟滚动器无法正确计算视口范围和滚动位置。
  2. 虚拟项未正确定位:虚拟滚动的子元素缺少绝对定位,无法被虚拟滚动器精确控制位置,导致布局错乱。
  3. 无限加载触发逻辑适配问题:useInView监听的是全局视口,而非局部滚动容器的视口,导致加载时机判断错误,进一步加剧滚动异常。

修复步骤

1. 给滚动容器设置固定高度

修改LogCards中的滚动Box样式,利用flex布局特性让它占据父容器剩余空间:

<Box ref={parentRef} sx={{ overflowY: 'scroll', flex: 1, height: 0 }}>

flex:1让组件占据LogsList中除标题和按钮外的剩余空间,height:0强制组件使用flex分配的空间,避免高度计算异常。

2. 给虚拟项添加定位

修改内部容器为相对定位,给每个LogCard添加绝对定位和位置样式,让虚拟滚动器控制卡片位置:

<div 
  style={{ 
    height: `${rowVirtualizer.getTotalSize()}px`,
    position: 'relative',
    width: '100%',
  }}
>
<LogCard
  key={LogCardData.id}
  data-index={log.index}
  ref={rowVirtualizer.measureElement}
  log={LogCardData}
  style={{
    position: 'absolute',
    top: `${log.start}px`,
    left: 0,
    right: 0,
    height: `${log.size}px`,
  }}
/>

3. 优化无限加载触发逻辑

改用虚拟滚动器的虚拟项位置判断加载时机,替代useInView,适配局部滚动场景:

useEffect(() => {
  const lastItem = rowVirtualizer.getVirtualItems().pop();
  if (lastItem && !isFetchingNextPage && lastItem.index >= allLogs.length - 5) {
    fetchNextPage();
  }
}, [rowVirtualizer.getVirtualItems(), allLogs.length, fetchNextPage, isFetchingNextPage]);

当最后一个可见虚拟项的索引接近总数据量时(提前5个),触发下一页加载,避免视口判断错误。

修复后的完整LogCards代码

const LogCards = ({ pageLimit }: LogCardsProps) => {
  const {
    data: logs,
    fetchNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteLogsQuery(pageLimit);

  const allLogs = useMemo(() => logs?.pages.flatMap((log) => log.results), [logs]) || [];
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: allLogs.length,
    getScrollElement: useCallback(() => parentRef.current, []),
    estimateSize: useCallback(() => 300, []),
    overscan: 2,
    gap: 8,
  });

  useEffect(() => {
    const lastItem = rowVirtualizer.getVirtualItems().pop();
    if (lastItem && !isFetchingNextPage && lastItem.index >= allLogs.length - 5) {
      fetchNextPage();
    }
  }, [rowVirtualizer.getVirtualItems(), allLogs.length, fetchNextPage, isFetchingNextPage]);

  return !isLoading ? (
    <Box ref={parentRef} sx={{ overflowY: 'scroll', flex: 1, height: 0 }}>
      <div 
        style={{ 
          height: `${rowVirtualizer.getTotalSize()}px`,
          position: 'relative',
          width: '100%',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((log) => {
          const LogCardData = allLogs[log.index];
          if (!LogCardData) return null;
          return (
            <LogCard
              key={LogCardData.id}
              data-index={log.index}
              ref={rowVirtualizer.measureElement}
              log={LogCardData}
              style={{
                position: 'absolute',
                top: `${log.start}px`,
                left: 0,
                right: 0,
                height: `${log.size}px`,
              }}
            />
          );
        })}
      </div>
      <Box sx={{ height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        {isFetchingNextPage && <LinearProgress sx={{ width: '90%' }} />}
      </Box>
    </Box>
  ) : (
    <LinearLoader />
  );
};

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 13:04:57