perf(chat): 对话区 virtua 窗口化渲染,解决挤压卡顿与全量渲染

- v-for 全量渲染改为 virtua Virtualizer 窗口化,只挂载可视区+buffer 消息块
- 追底引擎 resize 弹簧动画改 instant:拖拽挤压时逐帧瞬时吸底,内容向上生长
- 消息按对象身份(WeakMap)分配稳定 key,支撑 virtua 逐项高度缓存
- 用户气泡随滚动重挂载时补测量/补观察,保证折叠状态正确恢复
- 滚动追踪日志默认关闭(逐scroll事件日志本身是每帧开销),排障可显式开启
- 修复 2 处依赖 DOM 层级的 CSS 结构选择器
- 新增依赖 virtua(~3kB)
This commit is contained in:
JOJO 2026-07-23 00:21:49 +08:00
parent d891ccf08f
commit 2b84a7adf8
4 changed files with 130 additions and 55 deletions

31
package-lock.json generated
View File

@ -35,6 +35,7 @@
"sherpa-onnx": "^1.13.2", "sherpa-onnx": "^1.13.2",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"unified": "^11.0.5", "unified": "^11.0.5",
"virtua": "^0.49.3",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0", "vue-stick-to-bottom": "^1.0.0",
"xterm": "^5.3.0" "xterm": "^5.3.0"
@ -7463,6 +7464,36 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@ -42,6 +42,7 @@
"sherpa-onnx": "^1.13.2", "sherpa-onnx": "^1.13.2",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"unified": "^11.0.5", "unified": "^11.0.5",
"virtua": "^0.49.3",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0", "vue-stick-to-bottom": "^1.0.0",
"xterm": "^5.3.0" "xterm": "^5.3.0"

View File

@ -5,15 +5,24 @@
ref="scrollRef" ref="scrollRef"
> >
<div class="messages-flow" ref="contentRef"> <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 <div
v-for="(msg, index) in filteredMessages || []" :key="getMessageKey(msg, index)"
:key="index"
class="message-block" class="message-block"
:class="{ :class="{
'message-block--compact-user': getMessageVisibility(msg) === 'compact', 'message-block--compact-user': getMessageVisibility(msg) === 'compact',
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg), 'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index), 'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
'message-block--multi-agent': isMultiAgentMessage(msg), 'message-block--multi-agent': isMultiAgentMessage(msg),
'message-block--first': index === 0,
'message-block--last': index === (filteredMessages || []).length - 1 'message-block--last': index === (filteredMessages || []).length - 1
}" }"
> >
@ -635,18 +644,7 @@
</template> </template>
</template> </template>
</div> </div>
<div <div v-else class="system-message">
v-else
class="system-message"
@vue:mounted="
() =>
console.log('[ChatArea] 渲染系统消息:', {
index,
role: msg.role,
content: msg.content
})
"
>
<div <div
class="collapsible-block system-block" class="collapsible-block system-block"
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
@ -674,6 +672,8 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</Virtualizer>
</div> </div>
<div class="messages-bottom-spacer" aria-hidden="true"></div> <div class="messages-bottom-spacer" aria-hidden="true"></div>
</div> </div>
@ -682,6 +682,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom'; import { useStickToBottom } from 'vue-stick-to-bottom';
import { Virtualizer } from 'virtua/vue';
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor'; import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
import ToolAction from '@/components/chat/actions/ToolAction.vue'; import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue'; import StackedBlocks from './StackedBlocks.vue';
@ -773,6 +774,16 @@ const registerUserBubbleRef = (msg: any, index: number, el: Element | null) => {
const key = getUserBubbleKey(msg, index); const key = getUserBubbleKey(msg, index);
if (el instanceof HTMLElement) { if (el instanceof HTMLElement) {
userBubbleRefs.set(key, el); 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 { } else {
userBubbleRefs.delete(key); userBubbleRefs.delete(key);
} }
@ -1128,14 +1139,39 @@ const {
scrollToBottom, scrollToBottom,
stopScroll stopScroll
} = useStickToBottom({ } = useStickToBottom({
resize: { //
damping: 0.5, //
stiffness: 0.28, resize: 'instant',
mass: 0.75
},
initial: 'instant' initial: 'instant'
}); });
//
// idWeakMap 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 // KaTeX stick-to-bottom
provide('mathRenderedCallback', () => { provide('mathRenderedCallback', () => {
if (isNearBottom.value && stickScrollToBottom) { if (isNearBottom.value && stickScrollToBottom) {
@ -1168,7 +1204,9 @@ let wheelListener: ((event: WheelEvent) => void) | null = null;
let traceAttachLogged = false; let traceAttachLogged = false;
function isScrollBounceTraceEnabled() { function isScrollBounceTraceEnabled() {
if (typeof window === 'undefined') return true; // scroll
// window.__SCROLL_BOUNCE_TRACE__ = true localStorage.scrollBounceTrace = '1'
if (typeof window === 'undefined') return false;
try { try {
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__; const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
if (explicit === false || explicit === '0') return false; if (explicit === false || explicit === '0') return false;
@ -1176,9 +1214,9 @@ function isScrollBounceTraceEnabled() {
const localFlag = window.localStorage?.getItem('scrollBounceTrace'); const localFlag = window.localStorage?.getItem('scrollBounceTrace');
if (localFlag === '0' || localFlag === 'false') return false; if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true; if (localFlag === '1' || localFlag === 'true') return true;
return true; return false;
} catch { } catch {
return true; return false;
} }
} }
@ -1282,15 +1320,20 @@ function attachBounceListener() {
lastObservedHeight = height; lastObservedHeight = height;
if (heightDelta !== 0) { if (heightDelta !== 0) {
console.log('[ChatAreaScrollDebug] height-change', { bounceTraceLog(
heightDelta, 'height-change',
scrollHeight: height, {
top, heightDelta,
delta, scrollHeight: height,
escapedFromLock: escapedFromLock.value, top,
isAtBottom: isAtBottom.value, delta,
isNearBottom: isNearBottom.value, escapedFromLock: escapedFromLock.value,
}); isAtBottom: isAtBottom.value,
isNearBottom: isNearBottom.value
},
'height-change',
120
);
} }
// scrollHeight / // scrollHeight /
@ -1303,15 +1346,20 @@ function attachBounceListener() {
heightDelta > largeGrowthThreshold && heightDelta > largeGrowthThreshold &&
!escapedFromLock.value !escapedFromLock.value
) { ) {
console.log('[ChatAreaScrollDebug] TRIGGER:large-growth-scroll', { bounceTraceLog(
heightDelta, 'large-growth:trigger',
threshold: largeGrowthThreshold, {
top, heightDelta,
delta, threshold: largeGrowthThreshold,
escapedFromLock: escapedFromLock.value, top,
isAtBottom: isAtBottom.value, delta,
isNearBottom: isNearBottom.value, escapedFromLock: escapedFromLock.value,
}); isAtBottom: isAtBottom.value,
isNearBottom: isNearBottom.value
},
'large-growth:trigger',
0
);
isHandlingLargeGrowth = true; isHandlingLargeGrowth = true;
markProgrammaticHint('ChatArea.largeGrowth'); markProgrammaticHint('ChatArea.largeGrowth');
suppressUserIntentUntil = Date.now() + 900; suppressUserIntentUntil = Date.now() + 900;
@ -1408,7 +1456,7 @@ function attachBounceListener() {
} }
}; };
el.addEventListener('scroll', scrollListener, { passive: true }); el.addEventListener('scroll', scrollListener, { passive: true });
if (!traceAttachLogged) { if (!traceAttachLogged && isScrollBounceTraceEnabled()) {
traceAttachLogged = true; traceAttachLogged = true;
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', { console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
hasElement: !!el, hasElement: !!el,
@ -1550,15 +1598,6 @@ async function stickScrollToBottom(
) { ) {
const el = scrollRef.value; const el = scrollRef.value;
if (el) { 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( bounceTraceLog(
'programmatic:scrollToBottom:before', 'programmatic:scrollToBottom:before',
{ {
@ -1999,9 +2038,11 @@ onMounted(() => {
}); });
nextTick(() => observeUserBubbles()); nextTick(() => observeUserBubbles());
} }
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', { if (isScrollBounceTraceEnabled()) {
hasScrollRef: !!scrollRef.value console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
}); hasScrollRef: !!scrollRef.value
});
}
timerHandle = window.setInterval(() => { timerHandle = window.setInterval(() => {
nowMs.value = Date.now(); nowMs.value = Date.now();
}, 1000); }, 1000);

View File

@ -122,7 +122,8 @@
contain: layout style; contain: layout style;
} }
.messages-flow > .message-block.message-block--last { /* 窗口化后 message-block 外层有 virtua 列表项包装,不再是 messages-flow 直接子元素,改用后代选择器 */
.messages-flow .message-block--last {
margin-bottom: 0; margin-bottom: 0;
} }
@ -214,7 +215,8 @@
padding-right: 12px !important; padding-right: 12px !important;
} }
.message-block:first-child { /* 窗口化后每个 message-block 都是包装项的首子,:first-child 会匹配所有块,改用显式 class */
.message-block--first {
margin-top: 0; margin-top: 0;
} }