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",
"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",

View File

@ -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"

View File

@ -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'
});
//
// 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
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);

View File

@ -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;
}