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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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(
|
||||||
|
(behavior: ScrollBehavior = 'smooth') => {
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
setNewMessageCount(0);
|
setNewMessageCount(0);
|
||||||
bottomRef.current?.scrollIntoView({ behavior });
|
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;
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||||
|
|
||||||
|
// 高亮 class 需等 DOM 渲染后操作
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
||||||
if (!targetElement) return;
|
if (!targetElement) return;
|
||||||
|
|
||||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
targetElement.classList.add('todo-highlight');
|
targetElement.classList.add('todo-highlight');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
targetElement.classList.remove('todo-highlight');
|
targetElement.classList.remove('todo-highlight');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, [highlightedMessageId]);
|
});
|
||||||
|
}, [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 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
|
<div
|
||||||
ref={containerRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="h-full overflow-y-auto p-6 space-y-6"
|
|
||||||
>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<MessageBubble
|
|
||||||
key={message.id}
|
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}
|
message={message}
|
||||||
onNavigateToSubAgent={onNavigateToSubAgent}
|
onNavigateToSubAgent={onNavigateToSubAgent}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
<div ref={bottomRef} />
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 浮动导航按钮 — 底部居中并排 */}
|
{/* 浮动导航按钮 — 底部居中并排 */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user