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:
parent
934c7aa804
commit
2367d87db6
28
web/package-lock.json
generated
28
web/package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "picobot-web",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
@ -1195,6 +1196,33 @@
|
||||
"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": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import type { ChatMessage } from '../../types/protocol';
|
||||
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
|
||||
@ -20,7 +21,6 @@ export function MessageList({
|
||||
viewKey,
|
||||
highlightedMessageId,
|
||||
}: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const prevShowBottomRef = useRef(false);
|
||||
@ -39,14 +39,35 @@ export function MessageList({
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
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 ----
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
isAtBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
setNewMessageCount(0);
|
||||
bottomRef.current?.scrollIntoView({ behavior });
|
||||
}, []);
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = 'smooth') => {
|
||||
isAtBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
setNewMessageCount(0);
|
||||
if (messages.length > 0) {
|
||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
|
||||
}
|
||||
},
|
||||
[virtualizer, messages.length],
|
||||
);
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@ -109,7 +130,7 @@ export function MessageList({
|
||||
}
|
||||
// First time viewing this view: scroll to bottom
|
||||
isAtBottomRef.current = true;
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -119,7 +140,7 @@ export function MessageList({
|
||||
prevMessageCountRef.current = messages.length;
|
||||
|
||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||||
// 用户自己发消息或已在底部时,不需要计数
|
||||
if (newCount > 0) {
|
||||
setNewMessageCount(0);
|
||||
@ -128,7 +149,7 @@ export function MessageList({
|
||||
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
||||
setNewMessageCount((prev) => prev + newCount);
|
||||
}
|
||||
}, [messages, viewKey]);
|
||||
}, [messages, viewKey, virtualizer]);
|
||||
|
||||
// ---- mount: always scroll to bottom if messages already loaded ----
|
||||
|
||||
@ -143,19 +164,23 @@ export function MessageList({
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId) return;
|
||||
const idx = messageIdToIndex.current.get(highlightedMessageId);
|
||||
if (idx === undefined) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||
|
||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
||||
if (!targetElement) return;
|
||||
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
targetElement.classList.add('todo-highlight');
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('todo-highlight');
|
||||
}, 2000);
|
||||
}, [highlightedMessageId]);
|
||||
// 高亮 class 需等 DOM 渲染后操作
|
||||
requestAnimationFrame(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
||||
if (!targetElement) return;
|
||||
targetElement.classList.add('todo-highlight');
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('todo-highlight');
|
||||
}, 2000);
|
||||
});
|
||||
}, [highlightedMessageId, virtualizer]);
|
||||
|
||||
// ---- 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 ----
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-6 space-y-6"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
onNavigateToSubAgent={onNavigateToSubAgent}
|
||||
showThinking={showThinking}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
<div ref={containerRef} onScroll={handleScroll} className="h-full overflow-y-auto p-6">
|
||||
{/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */}
|
||||
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
|
||||
{virtualItems.map((vi) => {
|
||||
const message = messages[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
data-index={vi.index}
|
||||
data-message-id={message.id}
|
||||
ref={virtualizer.measureElement}
|
||||
className="pb-6"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<MessageBubble
|
||||
message={message}
|
||||
onNavigateToSubAgent={onNavigateToSubAgent}
|
||||
showThinking={showThinking}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浮动导航按钮 — 底部居中并排 */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user