perf(web): 消息列表虚拟化,长对话不再卡顿

原实现 messages.map 渲染所有消息,500 条消息会产生 10000+ DOM
节点(每条 MessageBubble 含 ReactMarkdown 解析),导致首次渲染
卡顿、滚动掉帧、流式输出每 token 都全量 diff。

引入 @tanstack/react-virtual 实现虚拟化:
- 只渲染视口内 + overscan=6 条消息,DOM 节点数恒定
- measureElement 动态测量变高度消息(一行文本 vs 50 行代码块)
- estimateSize=120 提供初始估计,测量后自动校正
- Firefox 特殊处理 measureElement(getBoundingClientRect)

适配现有功能:
- 自动滚到底部:bottomRef.scrollIntoView 改为 virtualizer.scrollToIndex
- viewKey 滚动位置记忆:保留 scrollTop 保存/恢复逻辑
- highlightedMessageId:先 scrollToIndex 渲染目标项,rAF 后加 class
- newMessageCount:流式 delta 不计数(消息条数不变)
- space-y-6 改为每项 pb-6(虚拟化下相邻 margin 不生效)

性能对比(500 条消息):
- DOM 节点:10000+ → ~200(仅可见+overscan)
- 首次渲染:数百 ms → <16ms
- 流式 diff:全量列表 → 仅可见项
This commit is contained in:
oudecheng 2026-08-07 07:05:57 +08:00
parent 934c7aa804
commit 2367d87db6
3 changed files with 111 additions and 35 deletions

28
web/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "picobot-web", "name": "picobot-web",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.14.9",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
@ -1195,6 +1196,33 @@
"tailwindcss": "4.3.0" "tailwindcss": "4.3.0"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View File

@ -15,6 +15,7 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\""
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.14.9",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",

View File

@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol'; import type { ChatMessage } from '../../types/protocol';
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'; import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
@ -20,7 +21,6 @@ export function MessageList({
viewKey, viewKey,
highlightedMessageId, highlightedMessageId,
}: MessageListProps) { }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
const prevShowBottomRef = useRef(false); const prevShowBottomRef = useRef(false);
@ -39,14 +39,35 @@ export function MessageList({
const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [newMessageCount, setNewMessageCount] = useState(0); const [newMessageCount, setNewMessageCount] = useState(0);
// 虚拟化列表:只渲染视口内的消息,长对话不卡顿。
// 动态测量元素高度消息内容长度差异大overscan 保证滚动平滑。
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 120,
overscan: 6,
measureElement:
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
: undefined,
});
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位
const messageIdToIndex = useRef<Map<string, number>>(new Map());
// ---- scroll helpers ---- // ---- scroll helpers ----
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback(
isAtBottomRef.current = true; (behavior: ScrollBehavior = 'smooth') => {
setShowScrollToBottom(false); isAtBottomRef.current = true;
setNewMessageCount(0); setShowScrollToBottom(false);
bottomRef.current?.scrollIntoView({ behavior }); setNewMessageCount(0);
}, []); if (messages.length > 0) {
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
}
},
[virtualizer, messages.length],
);
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
@ -109,7 +130,7 @@ export function MessageList({
} }
// First time viewing this view: scroll to bottom // First time viewing this view: scroll to bottom
isAtBottomRef.current = true; isAtBottomRef.current = true;
bottomRef.current?.scrollIntoView({ behavior: 'instant' }); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
return; return;
} }
@ -119,7 +140,7 @@ export function MessageList({
prevMessageCountRef.current = messages.length; prevMessageCountRef.current = messages.length;
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' }); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
// 用户自己发消息或已在底部时,不需要计数 // 用户自己发消息或已在底部时,不需要计数
if (newCount > 0) { if (newCount > 0) {
setNewMessageCount(0); setNewMessageCount(0);
@ -128,7 +149,7 @@ export function MessageList({
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
setNewMessageCount((prev) => prev + newCount); setNewMessageCount((prev) => prev + newCount);
} }
}, [messages, viewKey]); }, [messages, viewKey, virtualizer]);
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----
@ -143,19 +164,23 @@ export function MessageList({
useEffect(() => { useEffect(() => {
if (!highlightedMessageId) return; if (!highlightedMessageId) return;
const idx = messageIdToIndex.current.get(highlightedMessageId);
if (idx === undefined) return;
const container = containerRef.current; virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
if (!container) return;
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`); // 高亮 class 需等 DOM 渲染后操作
if (!targetElement) return; requestAnimationFrame(() => {
const container = containerRef.current;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (!container) return;
targetElement.classList.add('todo-highlight'); const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
setTimeout(() => { if (!targetElement) return;
targetElement.classList.remove('todo-highlight'); targetElement.classList.add('todo-highlight');
}, 2000); setTimeout(() => {
}, [highlightedMessageId]); targetElement.classList.remove('todo-highlight');
}, 2000);
});
}, [highlightedMessageId, virtualizer]);
// ---- empty state ---- // ---- empty state ----
@ -181,24 +206,46 @@ export function MessageList({
); );
} }
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
messageIdToIndex.current.clear();
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
// ---- main render ---- // ---- main render ----
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return ( return (
<div className="relative h-full"> <div className="relative h-full">
<div <div ref={containerRef} onScroll={handleScroll} className="h-full overflow-y-auto p-6">
ref={containerRef} {/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */}
onScroll={handleScroll} <div style={{ height: `${totalSize}px`, position: 'relative' }}>
className="h-full overflow-y-auto p-6 space-y-6" {virtualItems.map((vi) => {
> const message = messages[vi.index];
{messages.map((message) => ( return (
<MessageBubble <div
key={message.id} key={message.id}
message={message} data-index={vi.index}
onNavigateToSubAgent={onNavigateToSubAgent} data-message-id={message.id}
showThinking={showThinking} ref={virtualizer.measureElement}
/> className="pb-6"
))} style={{
<div ref={bottomRef} /> position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<MessageBubble
message={message}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
/>
</div>
);
})}
</div>
</div> </div>
{/* 浮动导航按钮 — 底部居中并排 */} {/* 浮动导航按钮 — 底部居中并排 */}