优化:会话滚动锚点复用块集合缓存
collectTurnBlocks 结果按列表缓存(WeakMap),读锚点与还原锚点在同一帧内不再各查一遍整份列表 useConversationScroll 的 MutationObserver 在子元素增删时调 invalidateTurnBlocks,失效信号与重订阅共用同一处 新增两例纯函数用例:连续收集只查一次 DOM、子元素变化并失效后重新收集 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+28
-1
@@ -1,10 +1,11 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
collectTurnBlocks,
|
||||
type ConversationTurnAnchor,
|
||||
findTurnBlock,
|
||||
invalidateTurnBlocks,
|
||||
readTopVisibleTurnAnchor,
|
||||
restoreTurnAnchor,
|
||||
} from './conversationScrollAnchor';
|
||||
@@ -52,6 +53,32 @@ describe('collectTurnBlocks', () => {
|
||||
const { list } = buildList(['t1', 't2']);
|
||||
expect(collectTurnBlocks(list)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('同一列表连续收集只查一次 DOM', () => {
|
||||
const { list } = buildList(['t1', 't2']);
|
||||
collectTurnBlocks(list);
|
||||
const query = vi.spyOn(list, 'querySelectorAll');
|
||||
|
||||
// 一次滚动帧里读锚点、还原锚点会各查一遍:没失效就不该再碰 DOM。
|
||||
collectTurnBlocks(list);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
query.mockRestore();
|
||||
});
|
||||
|
||||
it('子元素变化并失效后重新收集', () => {
|
||||
const { list } = buildList(['t1']);
|
||||
const before = collectTurnBlocks(list);
|
||||
const extra = document.createElement('div');
|
||||
extra.dataset.turnKey = 't2';
|
||||
list.appendChild(extra);
|
||||
|
||||
invalidateTurnBlocks(list);
|
||||
|
||||
const after = collectTurnBlocks(list);
|
||||
expect(after).toHaveLength(2);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTopVisibleTurnAnchor', () => {
|
||||
|
||||
+20
-2
@@ -26,9 +26,27 @@ function turnKeyOf(element: Element): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 列表里所有带回合身份的块,按文档顺序。 */
|
||||
/**
|
||||
* 块集合缓存:一次滚动或一次补偿里读锚点、还原锚点会各查一遍整个列表,长会话下就是每个滚动
|
||||
* 帧的 O(块数) DOM 查询。失效信号是列表子元素增删——只有子元素变化才可能改变「哪些块带回合
|
||||
* 身份」,而 `useConversationScroll` 的 `MutationObserver` 已经在听同一个信号。
|
||||
*/
|
||||
const turnBlockCache = new WeakMap<HTMLElement, HTMLElement[]>();
|
||||
|
||||
/** 列表里所有带回合身份的块,按文档顺序;子元素没变时复用上一次的结果。 */
|
||||
export function collectTurnBlocks(list: HTMLElement): HTMLElement[] {
|
||||
return Array.from(list.querySelectorAll<HTMLElement>(TURN_BLOCK_SELECTOR));
|
||||
const cached = turnBlockCache.get(list);
|
||||
if (cached) return cached;
|
||||
const blocks = Array.from(
|
||||
list.querySelectorAll<HTMLElement>(TURN_BLOCK_SELECTOR),
|
||||
);
|
||||
turnBlockCache.set(list, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 列表子元素变了(回合追加、历史前插、加载行挂卸):丢掉缓存,下次重新收集。 */
|
||||
export function invalidateTurnBlocks(list: HTMLElement): void {
|
||||
turnBlockCache.delete(list);
|
||||
}
|
||||
|
||||
/** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ import {
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import {
|
||||
type ConversationTurnAnchor,
|
||||
invalidateTurnBlocks,
|
||||
readTopVisibleTurnAnchor,
|
||||
restoreTurnAnchor,
|
||||
} from './conversationScrollAnchor';
|
||||
@@ -210,13 +211,15 @@ export function useConversationScroll({
|
||||
}, [turns]);
|
||||
|
||||
// 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。
|
||||
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅。
|
||||
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list || typeof ResizeObserver === 'undefined') return undefined;
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
const subscribe = () => {
|
||||
// 同一份子元素信号同时喂给两处:块集合缓存(锚点定位)与 ResizeObserver 的观察名单。
|
||||
invalidateTurnBlocks(list);
|
||||
observer?.disconnect();
|
||||
observer = new ResizeObserver(() => compensateLayout());
|
||||
for (const child of Array.from(list.children)) {
|
||||
|
||||
Reference in New Issue
Block a user