Claude Code 与 React 虚拟滚动实战指南
用 Claude Code 实现 React/TypeScript 虚拟滚动,覆盖 TanStack Virtual、可变高度、无障碍和 Playwright 检查。
为什么需要虚拟滚动
虚拟滚动的核心思想很简单:列表有一万行时,不要把一万行全部挂到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 docs和Virtualizer 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>
);
}
图片加载后高度变化是常见陷阱。给图片预留width和height,并在加载完成后重新测量,可以减少滚动跳动。聊天场景还要决定一个细节:用户在底部时新消息是否自动贴底,用户正在看旧消息时是否保持当前位置。这不是库能自动替你决定的产品规则。
无障碍、键盘和位置恢复
虚拟列表不是所有行都存在于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,滚动到中间某条固定测试数据并确认页面没有横向溢出。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。