perf(chat): 对话区 virtua 窗口化渲染,解决挤压卡顿与全量渲染
- v-for 全量渲染改为 virtua Virtualizer 窗口化,只挂载可视区+buffer 消息块 - 追底引擎 resize 弹簧动画改 instant:拖拽挤压时逐帧瞬时吸底,内容向上生长 - 消息按对象身份(WeakMap)分配稳定 key,支撑 virtua 逐项高度缓存 - 用户气泡随滚动重挂载时补测量/补观察,保证折叠状态正确恢复 - 滚动追踪日志默认关闭(逐scroll事件日志本身是每帧开销),排障可显式开启 - 修复 2 处依赖 DOM 层级的 CSS 结构选择器 - 新增依赖 virtua(~3kB)
This commit is contained in:
parent
d891ccf08f
commit
2b84a7adf8
31
package-lock.json
generated
31
package-lock.json
generated
@ -35,6 +35,7 @@
|
||||
"sherpa-onnx": "^1.13.2",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"unified": "^11.0.5",
|
||||
"virtua": "^0.49.3",
|
||||
"vue": "^3.4.15",
|
||||
"vue-stick-to-bottom": "^1.0.0",
|
||||
"xterm": "^5.3.0"
|
||||
@ -7463,6 +7464,36 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.49.3",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.3.tgz",
|
||||
"integrity": "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0",
|
||||
"solid-js": ">=1.0",
|
||||
"svelte": ">=5.0",
|
||||
"vue": ">=3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"sherpa-onnx": "^1.13.2",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"unified": "^11.0.5",
|
||||
"virtua": "^0.49.3",
|
||||
"vue": "^3.4.15",
|
||||
"vue-stick-to-bottom": "^1.0.0",
|
||||
"xterm": "^5.3.0"
|
||||
|
||||
@ -5,15 +5,24 @@
|
||||
ref="scrollRef"
|
||||
>
|
||||
<div class="messages-flow" ref="contentRef">
|
||||
<!-- 窗口化渲染:virtua 只挂载可视区+buffer 的消息块,未挂载项按实测高度缓存占位 -->
|
||||
<Virtualizer
|
||||
v-if="stickScrollElement"
|
||||
:data="filteredMessages || []"
|
||||
:scroll-ref="stickScrollElement"
|
||||
:buffer-size="400"
|
||||
:keep-mounted="keepMountedIndexes"
|
||||
>
|
||||
<template #default="{ item: msg, index }">
|
||||
<div
|
||||
v-for="(msg, index) in filteredMessages || []"
|
||||
:key="index"
|
||||
:key="getMessageKey(msg, index)"
|
||||
class="message-block"
|
||||
:class="{
|
||||
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
|
||||
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
|
||||
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
|
||||
'message-block--multi-agent': isMultiAgentMessage(msg),
|
||||
'message-block--first': index === 0,
|
||||
'message-block--last': index === (filteredMessages || []).length - 1
|
||||
}"
|
||||
>
|
||||
@ -635,18 +644,7 @@
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="system-message"
|
||||
@vue:mounted="
|
||||
() =>
|
||||
console.log('[ChatArea] 渲染系统消息:', {
|
||||
index,
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})
|
||||
"
|
||||
>
|
||||
<div v-else class="system-message">
|
||||
<div
|
||||
class="collapsible-block system-block"
|
||||
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
||||
@ -674,6 +672,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
<div class="messages-bottom-spacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
@ -682,6 +682,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||
import StackedBlocks from './StackedBlocks.vue';
|
||||
@ -773,6 +774,16 @@ const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => {
|
||||
const key = getUserBubbleKey(msg, index);
|
||||
if (el instanceof HTMLElement) {
|
||||
userBubbleRefs.set(key, el);
|
||||
// 虚拟列表下气泡随滚动挂载/卸载:挂载时补做观察与高度测量,保证折叠状态正确恢复
|
||||
if (userBubbleResizeObserver) {
|
||||
userBubbleResizeObserver.observe(el);
|
||||
}
|
||||
const state = userBubbleFoldStates.value[key];
|
||||
if (state && state.foldHeight === 0) {
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected) measureUserBubbles();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
userBubbleRefs.delete(key);
|
||||
}
|
||||
@ -1128,14 +1139,39 @@ const {
|
||||
scrollToBottom,
|
||||
stopScroll
|
||||
} = useStickToBottom({
|
||||
resize: {
|
||||
damping: 0.5,
|
||||
stiffness: 0.28,
|
||||
mass: 0.75
|
||||
},
|
||||
// 宽度拖拽挤压时不再弹簧追底:逐帧瞬时吸附底部,
|
||||
// 视觉上内容向上生长(底部锚定),消除“变高→追底”的反复计算循环
|
||||
resize: 'instant',
|
||||
initial: 'instant'
|
||||
});
|
||||
|
||||
// —— 虚拟列表支撑 ——
|
||||
// 消息对象没有后端 id,这里按对象身份(WeakMap)分配稳定 key,
|
||||
// 供 virtua 的逐项高度缓存与列表项复用,避免改动 store / 后端消息结构
|
||||
const messageKeyCache = new WeakMap<object, string>();
|
||||
let messageKeySeq = 0;
|
||||
const getMessageKey = (msg: any, index: number): string => {
|
||||
if (msg && typeof msg === 'object') {
|
||||
let key = messageKeyCache.get(msg as object);
|
||||
if (!key) {
|
||||
const rawId = (msg as any).id;
|
||||
key = rawId != null ? `msg-${String(rawId)}` : `mk-${++messageKeySeq}`;
|
||||
messageKeyCache.set(msg as object, key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
return `idx-${index}`;
|
||||
};
|
||||
// Virtualizer 需要显式拿到滚动容器(其直接父元素 .messages-flow 并非滚动容器),
|
||||
// 等模板 ref 就绪后再挂载 Virtualizer,确保其内部滚动监听一次到位
|
||||
const stickScrollElement = computed<HTMLElement | undefined>(() => scrollRef.value ?? undefined);
|
||||
// 流式期间保持最后一条消息常驻,避免用户上翻时流式块反复卸载/重挂载
|
||||
const keepMountedIndexes = computed<number[]>(() => {
|
||||
if (!props.streamingMessage) return [];
|
||||
const last = latestMessageIndex.value;
|
||||
return last >= 0 ? [last] : [];
|
||||
});
|
||||
|
||||
// 公式渲染后如果已经在底部附近,主动追底,避免 KaTeX 渲染导致的高度跳变让 stick-to-bottom 锁不住
|
||||
provide('mathRenderedCallback', () => {
|
||||
if (isNearBottom.value && stickScrollToBottom) {
|
||||
@ -1168,7 +1204,9 @@ let wheelListener: ((event: WheelEvent) => void) | null = null;
|
||||
let traceAttachLogged = false;
|
||||
|
||||
function isScrollBounceTraceEnabled() {
|
||||
if (typeof window === 'undefined') return true;
|
||||
// 默认关闭滚动追踪日志(逐 scroll 事件的高频日志本身就是性能开销),
|
||||
// 排障时通过 window.__SCROLL_BOUNCE_TRACE__ = true 或 localStorage.scrollBounceTrace = '1' 显式打开
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
|
||||
if (explicit === false || explicit === '0') return false;
|
||||
@ -1176,9 +1214,9 @@ function isScrollBounceTraceEnabled() {
|
||||
const localFlag = window.localStorage?.getItem('scrollBounceTrace');
|
||||
if (localFlag === '0' || localFlag === 'false') return false;
|
||||
if (localFlag === '1' || localFlag === 'true') return true;
|
||||
return true;
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1282,15 +1320,20 @@ function attachBounceListener() {
|
||||
lastObservedHeight = height;
|
||||
|
||||
if (heightDelta !== 0) {
|
||||
console.log('[ChatAreaScrollDebug] height-change', {
|
||||
heightDelta,
|
||||
scrollHeight: height,
|
||||
top,
|
||||
delta,
|
||||
escapedFromLock: escapedFromLock.value,
|
||||
isAtBottom: isAtBottom.value,
|
||||
isNearBottom: isNearBottom.value,
|
||||
});
|
||||
bounceTraceLog(
|
||||
'height-change',
|
||||
{
|
||||
heightDelta,
|
||||
scrollHeight: height,
|
||||
top,
|
||||
delta,
|
||||
escapedFromLock: escapedFromLock.value,
|
||||
isAtBottom: isAtBottom.value,
|
||||
isNearBottom: isNearBottom.value
|
||||
},
|
||||
'height-change',
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
// 大幅高度增长检测:当 scrollHeight 突增(内容大幅展开/渲染),
|
||||
@ -1303,15 +1346,20 @@ function attachBounceListener() {
|
||||
heightDelta > largeGrowthThreshold &&
|
||||
!escapedFromLock.value
|
||||
) {
|
||||
console.log('[ChatAreaScrollDebug] TRIGGER:large-growth-scroll', {
|
||||
heightDelta,
|
||||
threshold: largeGrowthThreshold,
|
||||
top,
|
||||
delta,
|
||||
escapedFromLock: escapedFromLock.value,
|
||||
isAtBottom: isAtBottom.value,
|
||||
isNearBottom: isNearBottom.value,
|
||||
});
|
||||
bounceTraceLog(
|
||||
'large-growth:trigger',
|
||||
{
|
||||
heightDelta,
|
||||
threshold: largeGrowthThreshold,
|
||||
top,
|
||||
delta,
|
||||
escapedFromLock: escapedFromLock.value,
|
||||
isAtBottom: isAtBottom.value,
|
||||
isNearBottom: isNearBottom.value
|
||||
},
|
||||
'large-growth:trigger',
|
||||
0
|
||||
);
|
||||
isHandlingLargeGrowth = true;
|
||||
markProgrammaticHint('ChatArea.largeGrowth');
|
||||
suppressUserIntentUntil = Date.now() + 900;
|
||||
@ -1408,7 +1456,7 @@ function attachBounceListener() {
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', scrollListener, { passive: true });
|
||||
if (!traceAttachLogged) {
|
||||
if (!traceAttachLogged && isScrollBounceTraceEnabled()) {
|
||||
traceAttachLogged = true;
|
||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
|
||||
hasElement: !!el,
|
||||
@ -1550,15 +1598,6 @@ async function stickScrollToBottom(
|
||||
) {
|
||||
const el = scrollRef.value;
|
||||
if (el) {
|
||||
console.log('[ChatAreaScrollDebug] CALL:stickScrollToBottom', {
|
||||
behavior: options.behavior || 'auto',
|
||||
force: !!options.force,
|
||||
preserveScrollPosition: !!options.preserveScrollPosition,
|
||||
isAtBottom: isAtBottom.value,
|
||||
isNearBottom: isNearBottom.value,
|
||||
escapedFromLock: escapedFromLock.value,
|
||||
...getScrollMetrics(el),
|
||||
});
|
||||
bounceTraceLog(
|
||||
'programmatic:scrollToBottom:before',
|
||||
{
|
||||
@ -1999,9 +2038,11 @@ onMounted(() => {
|
||||
});
|
||||
nextTick(() => observeUserBubbles());
|
||||
}
|
||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
||||
hasScrollRef: !!scrollRef.value
|
||||
});
|
||||
if (isScrollBounceTraceEnabled()) {
|
||||
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
|
||||
hasScrollRef: !!scrollRef.value
|
||||
});
|
||||
}
|
||||
timerHandle = window.setInterval(() => {
|
||||
nowMs.value = Date.now();
|
||||
}, 1000);
|
||||
|
||||
@ -122,7 +122,8 @@
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.messages-flow > .message-block.message-block--last {
|
||||
/* 窗口化后 message-block 外层有 virtua 列表项包装,不再是 messages-flow 直接子元素,改用后代选择器 */
|
||||
.messages-flow .message-block--last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@ -214,7 +215,8 @@
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
.message-block:first-child {
|
||||
/* 窗口化后每个 message-block 都是包装项的首子,:first-child 会匹配所有块,改用显式 class */
|
||||
.message-block--first {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user