给前端 AI 结果长列表加虚拟滚动时,我会先把事件账本和可见窗口拆开
最近在做一个前端 AI 工作台,Agent 跑一次任务会吐出思考摘要、工具调用、RAG 命中片段、校验结果和最终回答。早期列表量小,直接把事件数组 map 成组件就能跑。后来一次排障任务产生了几千条事件,滚动明显卡顿,展开某条工具结果后列表高度抖动,用户回到详情页还会丢掉原来的阅读位置。这个问题让我意识到,AI 结果列表不能只当成长数组渲染,它更像一份事件账本加一个可见窗口。

原创示意图:把事件账本、投影构建、测量缓存、虚拟视口和滚动恢复拆成五个可排查模块。 来源:Codex image generation
问题背景
TanStack Virtual 文档把它描述成面向长列表的 headless virtualizing 工具,核心价值是只渲染可见项,同时保留样式和结构控制权。MDN 的 ResizeObserver 说明它可以监听元素内容盒或边框盒尺寸变化,这对动态高度的工具结果很关键。Intersection Observer 则适合判断哨兵元素进入视口,常用于懒加载和无限滚动。W3C APG 的 Feed Pattern 也提醒,动态加载内容会影响辅助技术用户的阅读和焦点移动。把这些资料放到 AI 工作台里看,问题不只在性能,还在状态和可访问性边界。
踩坑和关键难点
第一个坑是把渲染数组当事实源。筛选、折叠、搜索、高亮和虚拟窗口都会改变当前数组形状,如果直接在这个数组里改事件状态,后面做导出和审计时很难还原原始顺序。
第二个坑是动态高度。RAG 片段、错误堆栈、表格和 Markdown 预览高度差异很大。只靠固定行高估算,滚动条会跳;每次展开都全量测量,又会把主线程拖慢。
第三个坑是阅读位置恢复。用户从第 1800 条事件点进工具详情,回来以后如果只保存 scrollTop,新增流式事件和上方高度变化都会让位置漂移。真正稳定的锚点应该是 eventId 和局部偏移。
解决思路
我把链路拆成四层。第一层是 append only event ledger,只追加原始事件,记录 eventId、runId、sequence、type、createdAt 和 payloadHash。第二层是 projection builder,根据筛选条件、折叠规则和搜索词生成可见行模型。第三层是 measurement cache,用 eventId 绑定测量结果,ResizeObserver 只更新发生变化的行。第四层是 virtual viewport,只关心可见范围、overscan、滚动锚点和焦点恢复。
这套拆法的核心是让虚拟滚动只处理投影结果,原始事件不跟着 UI 状态变形。流式事件进入账本后,先按 sequence 合并成稳定顺序,再投影成 rowId。展开工具详情时,rowId 不变,只更新高度缓存。搜索和过滤产生新投影时,仍然可以从 eventId 找回原始事件。
关键步骤
第一步是建立事件写入协议。所有 Agent 输出都先进入 ledger,缺少 eventId 或 sequence 的事件直接进隔离队列,避免列表靠数组下标对齐。
第二步是把行模型做薄。可见行只保存 rowId、eventId、kind、summary、expanded 和 heightHint。大 payload 留在详情缓存里,列表只拿首屏摘要。
第三步是做增量测量。初始高度用 estimateSize,展开、图片加载或代码块折行后再由 ResizeObserver 写回 measurement cache。测量更新批量进 requestAnimationFrame,减少连续重排。
第四步是保存滚动锚点。离开详情页前记录 anchorEventId、anchorOffset 和 projectionVersion。返回时先恢复 projection,再滚到 anchorEventId,最后补局部偏移。如果锚点事件被筛掉,就定位到最近的相邻事件。
可复用经验
长列表性能优化最容易只盯 DOM 数量,但 AI 工作台里更重要的是把事实和视图分开。事件账本保证可追溯,可见窗口保证流畅,测量缓存保证滚动稳定,锚点协议保证用户能回到上下文。以后接工具审计、RAG 引用卡片、批量日志下载或错误复盘,都可以复用这条边界:先保存完整事件,再为当前任务投影出合适的阅读窗口。