fix(web): 回到顶部改用 rAF 自驱动动画,修复消息多时平滑滚动被 virtualizer 纠正写入打断停在半路

This commit is contained in:
oudecheng 2026-08-17 16:36:24 +08:00
parent af344b7087
commit 27160ef560

View File

@ -27,6 +27,7 @@ export function MessageList({
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);
const scrollTopRafRef = useRef(0);
const prevViewKeyRef = useRef(viewKey); const prevViewKeyRef = useRef(viewKey);
const viewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey);
viewKeyRef.current = viewKey; viewKeyRef.current = viewKey;
@ -65,8 +66,16 @@ export function MessageList({
// ---- scroll helpers ---- // ---- scroll helpers ----
const stopScrollTopAnimation = useCallback(() => {
if (scrollTopRafRef.current !== 0) {
cancelAnimationFrame(scrollTopRafRef.current);
scrollTopRafRef.current = 0;
}
}, []);
const scrollToBottom = useCallback( const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => { (behavior: ScrollBehavior = 'smooth') => {
stopScrollTopAnimation();
isAtBottomRef.current = true; isAtBottomRef.current = true;
setShowScrollToBottom(false); setShowScrollToBottom(false);
setNewMessageCount(0); setNewMessageCount(0);
@ -74,12 +83,30 @@ export function MessageList({
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior }); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
} }
}, },
[virtualizer, messages.length], [virtualizer, messages.length, stopScrollTopAnimation],
); );
// 长距离滚回顶部不能用原生 behavior:'smooth':滚动途中上方行首次被测量时,
// virtualizer 会写 scrollTop 做偏移纠正resizeItem → applyScrollAdjustment
// 程序化 scrollTop 写入会直接打断浏览器平滑滚动动画,消息越多越容易停在半路。
// 改为 rAF 自驱动动画,每帧覆写 scrollTop纠正写入下一帧即被覆盖必达顶部。
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); const el = containerRef.current;
}, []); if (!el) return;
stopScrollTopAnimation();
const from = el.scrollTop;
if (from <= 0) return;
const duration = Math.min(800, 250 + from / 8);
const start = performance.now();
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const step = (now: number) => {
const p = Math.min(1, (now - start) / duration);
el.scrollTop = from * (1 - easeInOutCubic(p));
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0;
};
scrollTopRafRef.current = requestAnimationFrame(step);
}, [stopScrollTopAnimation]);
// ---- scroll event: track whether user is at bottom ---- // ---- scroll event: track whether user is at bottom ----
@ -126,6 +153,7 @@ export function MessageList({
if (viewChanged) { if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position // View switched (e.g. breadcrumb navigation): restore saved scroll position
stopScrollTopAnimation();
prevMessageCountRef.current = messages.length; prevMessageCountRef.current = messages.length;
const key = viewKey ?? ''; const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key); const savedPos = scrollPositionsRef.current.get(key);
@ -148,6 +176,7 @@ export function MessageList({
prevMessageCountRef.current = messages.length; prevMessageCountRef.current = messages.length;
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
stopScrollTopAnimation();
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' }); virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
// 用户自己发消息或已在底部时,不需要计数 // 用户自己发消息或已在底部时,不需要计数
if (newCount > 0) { if (newCount > 0) {
@ -157,7 +186,7 @@ export function MessageList({
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数) // 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
setNewMessageCount((prev) => prev + newCount); setNewMessageCount((prev) => prev + newCount);
} }
}, [messages, viewKey, virtualizer]); }, [messages, viewKey, virtualizer, stopScrollTopAnimation]);
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----
@ -175,6 +204,7 @@ export function MessageList({
const idx = messageIdToIndex.get(highlightedMessageId); const idx = messageIdToIndex.get(highlightedMessageId);
if (idx === undefined) return; if (idx === undefined) return;
stopScrollTopAnimation();
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
// 高亮 class 需等 DOM 渲染后操作 // 高亮 class 需等 DOM 渲染后操作
@ -188,7 +218,25 @@ export function MessageList({
targetElement.classList.remove('todo-highlight'); targetElement.classList.remove('todo-highlight');
}, 2000); }, 2000);
}); });
}, [highlightedMessageId, messageIdToIndex, virtualizer]); }, [highlightedMessageId, messageIdToIndex, virtualizer, stopScrollTopAnimation]);
// ---- 滚顶动画生命周期 ----
// 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权
const hasMessages = messages.length > 0;
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true });
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true });
return () => {
el.removeEventListener('wheel', stopScrollTopAnimation);
el.removeEventListener('touchstart', stopScrollTopAnimation);
};
}, [hasMessages, stopScrollTopAnimation]);
// 卸载时取消未完成的动画
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)---- // ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度; // 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;