Tips & Tricks (更新: 2026/6/2)

Claude Code 与 React 虚拟滚动实战指南

用 Claude Code 实现 React/TypeScript 虚拟滚动,覆盖 TanStack Virtual、可变高度、无障碍和 Playwright 检查。

Claude Code 与 React 虚拟滚动实战指南

为什么需要虚拟滚动

虚拟滚动的核心思想很简单:列表有一万行时,不要把一万行全部挂到DOM里,只渲染当前视口附近的行。DOM可以理解为浏览器用来表示页面结构的树。行数变多以后,问题不只是初次渲染慢,布局计算、绘制、事件处理和辅助技术读取都会变重。用户看到的可能只有二十行,但浏览器却在维护几千个看不见的节点。

Claude Code可以帮我们快速写出React组件,但如果只说“做一个virtual scroll”,通常只会得到演示级代码。真实项目还要考虑行高是否固定、快速滚动是否出现空白、键盘能否移动、屏幕阅读器能否知道总数、从详情页返回后位置是否恢复、图片加载后高度是否变化、移动端是否出现横向溢出。本文把这些点整理成一个可以交给Claude Code实现和审查的流程。

适合使用虚拟滚动的场景至少有五类。第一是日志查看器,比如部署日志、爬虫日志、错误追踪记录。第二是客户列表,CRM或SaaS后台经常有上万条客户、订单或团队成员记录。第三是聊天历史,长会话和AI回复会让DOM快速膨胀。第四是搜索结果,筛选条件改变后需要快速重绘。第五是管理后台表格,列宽、选择状态、固定表头和权限字段都需要保持稳定。如果列表只有几十条,或者页面需要被搜索引擎完整抓取,普通分页可能更合适。加载更多数据的设计可以继续看无限滚动实现,整体性能优化可以参考性能优化指南

先给 Claude Code 明确任务

虚拟滚动最容易出问题的地方,是代码看起来能跑,但产品细节没有被覆盖。因此在让Claude Code写代码前,先把库、数据规模、无障碍、移动端宽度和验证条件写清楚。

请用 React 18 + TypeScript 实现一个虚拟化日志查看器。

要求:
- 使用 @tanstack/react-virtual
- 支持 10000 行以上,但不要把所有行都挂载到 DOM
- 默认固定行高为 44px
- 添加 role、aria-label、aria-posinset、aria-setsize
- 在 390px 宽度下不能产生页面级横向滚动
- 说明 overscan 的取值理由
- 添加 Playwright 测试,检查滚动后行可见和横向溢出
- 最后按 TanStack Virtual 官方文档审查 API 用法

这个提示词会让Claude Code从“能显示列表”转向“能交付功能”。在客户列表中,可以把日志行换成客户姓名、状态和最后联系时间;在搜索结果中,可以换成标题、摘要和标签;在聊天记录中,可以换成作者、正文和附件。关键是把验收标准写出来,而不是让模型猜。

固定高度日志列表

React项目里,@tanstack/react-virtual是一个现实的默认选择。它不是带样式的组件,而是负责计算可见项、偏移量和总高度的headless工具。样式、语义结构和布局仍然由我们控制。官方资料可以看TanStack Virtual docsVirtualizer API

npm install @tanstack/react-virtual

下面是固定高度日志列表。外层元素负责滚动,内层元素提供总高度,每一行通过translateY放到应该出现的位置。

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

type LogRow = {
  id: string;
  level: "info" | "warn" | "error";
  message: string;
  createdAt: string;
};

export function VirtualLogViewer({ rows }: { rows: LogRow[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 12,
    getItemKey: (index) => rows[index]?.id ?? index,
  });

  return (
    <section aria-labelledby="log-heading">
      <h2 id="log-heading">Application logs</h2>
      <div
        ref={parentRef}
        data-testid="virtual-log-viewport"
        role="list"
        aria-label={`Application logs, ${rows.length} rows`}
        style={{
          height: 520,
          overflow: "auto",
          border: "1px solid #d4d4d8",
          borderRadius: 6,
        }}
      >
        <div
          style={{
            height: rowVirtualizer.getTotalSize(),
            position: "relative",
            width: "100%",
          }}
        >
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = rows[virtualRow.index];
            if (!row) return null;

            return (
              <div
                key={virtualRow.key}
                role="listitem"
                aria-posinset={virtualRow.index + 1}
                aria-setsize={rows.length}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  width: "100%",
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`,
                  display: "grid",
                  gridTemplateColumns: "92px 72px minmax(0, 1fr)",
                  gap: 12,
                  alignItems: "center",
                  padding: "0 12px",
                  boxSizing: "border-box",
                  borderBottom: "1px solid #eee",
                }}
              >
                <time dateTime={row.createdAt}>{row.createdAt}</time>
                <strong>{row.level.toUpperCase()}</strong>
                <span style={{ overflowWrap: "anywhere" }}>{row.message}</span>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

overscan表示视口外额外渲染的行数。太小会在快速滚动时看到空白,太大又会让隐藏DOM变多。日志行比较轻,可以从8到16之间试;如果每行包含头像、菜单、代码高亮或图表,就应该先降低数值,再用性能工具确认。

可变高度聊天记录

聊天历史、评论区、AI输出记录通常不是固定高度。文本长短、头像、图片、附件、翻译内容都会改变一行的实际高度。此时用estimateSize给出估计值,再用measureElement测量真实DOM。

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

type Message = {
  id: string;
  author: string;
  body: string;
  avatarUrl?: string;
};

export function VirtualChatHistory({ messages }: { messages: Message[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 96,
    overscan: 8,
    getItemKey: (index) => messages[index]?.id ?? index,
  });

  return (
    <div
      ref={parentRef}
      role="log"
      aria-label="Chat history"
      style={{ height: 520, overflow: "auto" }}
    >
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const message = messages[virtualItem.index];
          if (!message) return null;

          return (
            <article
              key={virtualItem.key}
              data-index={virtualItem.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
                padding: "12px 16px",
                boxSizing: "border-box",
              }}
            >
              {message.avatarUrl ? (
                <img
                  src={message.avatarUrl}
                  alt=""
                  width={32}
                  height={32}
                  loading="lazy"
                  onLoad={() => virtualizer.measure()}
                />
              ) : null}
              <p style={{ margin: 0, fontWeight: 700 }}>{message.author}</p>
              <p style={{ margin: "4px 0 0", overflowWrap: "anywhere" }}>
                {message.body}
              </p>
            </article>
          );
        })}
      </div>
    </div>
  );
}

图片加载后高度变化是常见陷阱。给图片预留widthheight,并在加载完成后重新测量,可以减少滚动跳动。聊天场景还要决定一个细节:用户在底部时新消息是否自动贴底,用户正在看旧消息时是否保持当前位置。这不是库能自动替你决定的产品规则。

无障碍、键盘和位置恢复

虚拟列表不是所有行都存在于DOM中,所以更要清楚表达总数、当前位置和操作方式。客户列表可以用上下键移动当前客户,Enter打开详情。搜索结果可以让用户快速跳到下一条。管理后台表格还要保留选中状态。

import type { KeyboardEvent } from "react";

type KeyboardParams = {
  activeIndex: number;
  rowCount: number;
  setActiveIndex: (index: number) => void;
  scrollToIndex: (index: number) => void;
};

export function handleVirtualListKeyDown(
  event: KeyboardEvent,
  { activeIndex, rowCount, setActiveIndex, scrollToIndex }: KeyboardParams,
) {
  const lastIndex = Math.max(0, rowCount - 1);
  let nextIndex = activeIndex;

  if (event.key === "ArrowDown") nextIndex = Math.min(lastIndex, activeIndex + 1);
  if (event.key === "ArrowUp") nextIndex = Math.max(0, activeIndex - 1);
  if (event.key === "PageDown") nextIndex = Math.min(lastIndex, activeIndex + 10);
  if (event.key === "PageUp") nextIndex = Math.max(0, activeIndex - 10);
  if (event.key === "Home") nextIndex = 0;
  if (event.key === "End") nextIndex = lastIndex;

  if (nextIndex !== activeIndex) {
    event.preventDefault();
    setActiveIndex(nextIndex);
    scrollToIndex(nextIndex);
  }
}

如果把焦点直接放在行元素上,滚动时该行被卸载,焦点可能消失。更稳定的做法是让外层容器持有焦点,再用aria-activedescendant表示当前行。详情页返回时还要恢复scrollTop,并且把筛选条件和排序条件包含在存储key里,否则用户可能回到错误的记录。更多可访问性检查可以接着看无障碍实现指南

Playwright 检查和审查提示

仮想滚动不能只靠“感觉很顺”。至少要检查移动端宽度、滚动后目标行是否出现、页面是否产生横向溢出、控制台是否报错。

import { expect, test } from "@playwright/test";

test("virtual log viewer scrolls without horizontal overflow", async ({ page }) => {
  const errors: string[] = [];
  page.on("console", (message) => {
    if (message.type() === "error") errors.push(message.text());
  });

  await page.setViewportSize({ width: 390, height: 844 });
  await page.goto("/debug/virtual-log-viewer");

  const viewport = page.getByTestId("virtual-log-viewport");
  await expect(viewport).toBeVisible();

  const before = await viewport.boundingBox();
  await viewport.evaluate((node) => {
    node.scrollTop = 2400;
  });
  await expect(page.getByText("Log #250")).toBeVisible();
  const after = await viewport.boundingBox();

  expect(after?.width).toBe(before?.width);
  expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
    await page.evaluate(() => document.documentElement.clientWidth),
  );
  expect(errors).toEqual([]);
});

给Claude Code的最终审查提示可以这样写:

请审查这个 React 虚拟滚动实现。

重点检查:
- 是否符合 TanStack Virtual 官方 API
- 固定高度和可变高度逻辑是否混在一起
- overscan 是否过小导致快速滚动空白
- role、aria 属性和键盘操作是否一致
- 图片加载后是否重新测量高度
- 从详情页返回时是否恢复滚动位置
- SSR 或 hydration 是否会造成初始高度跳动
- Playwright 是否覆盖移动端宽度和滚动后的目标行

常见陷阱和下一步

最常见的陷阱有八个。可变高度当固定高度处理,会导致行重叠或滚动跳动。overscan不足会出现白屏,overscan过大又会让DOM变多。键盘支持缺失会让鼠标以外的用户无法使用。屏幕阅读器缺少总数和位置,会让结果列表难以理解。没有滚动位置恢复,用户从详情页返回时会丢失工作上下文。图片加载后高度变化,会让聊天记录或商品卡片跳动。SSR差异会在hydration后改变高度。长字符串没有换行策略,会在手机上撑破页面。

实现前可以把流程画成这样:scrollTop -> 可见范围 -> overscan范围 -> 只渲染虚拟行 -> translateY放置 -> measureElement修正高度。这个概念图比“优化列表”更容易让团队理解,也方便在代码审查中逐项确认。

如果你的团队要把这种模式放进真实的日志平台、客户后台、搜索结果页或聊天产品,可以通过Claude Code 培训与咨询一起整理需求、提示词、CLAUDE.md规则、无障碍检查和Playwright证据。本文的相关资料包括TanStack Virtual docs性能优化无限滚动

实际尝试后的结果

我按本文流程测试后,固定高度日志列表的DOM节点数明显减少,滚动时也更容易定位问题。可变高度聊天记录则暴露了图片加载后的高度问题:没有预留尺寸和重新测量时,滚动位置会轻微跳动。最终最有效的发布前检查是三件事:用真实数据调整estimateSize,在390px宽度下跑Playwright,滚动到中间某条固定测试数据并确认页面没有横向溢出。

#Claude Code #虚拟滚动 #performance #React #windowing
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。