优化:会话滚动锚点复用块集合缓存

collectTurnBlocks 结果按列表缓存(WeakMap),读锚点与还原锚点在同一帧内不再各查一遍整份列表

useConversationScroll 的 MutationObserver 在子元素增删时调 invalidateTurnBlocks,失效信号与重订阅共用同一处

新增两例纯函数用例:连续收集只查一次 DOM、子元素变化并失效后重新收集

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:58:19 +08:00
parent e3c3091ec4
commit 1caf8ae8b0
3 changed files with 52 additions and 4 deletions
@@ -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', () => {
@@ -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。 */
@@ -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)) {