搜索与引用跳转
会话内搜索、RAG 引用跳转、「定位到这条消息」都是同一件事: 在数据里找到目标,再把视口移到它那儿。
试试
搜「不定高」或「滚动」,用「上一个 / 下一个」在命中之间跳。 状态行里的命中数是数据层的总数,包含那些还没渲染的项—— 这正是浏览器查找做不到的部分。
为什么必须自己搜
浏览器的页内查找(Ctrl+F)在虚拟列表里只能看到当前渲染的那几十项, 几百上千条里绝大多数根本没有对应的 DOM。所以「找」这件事必须在数据里做, 库负责的是「跳得准」。
同样的道理适用于所有依赖 DOM 存在的浏览器能力:页内查找、Tab 键遍历、 锚点定位。它们在虚拟滚动里都需要应用层补一条数据侧的路径。
三步
- 在数据里找出命中项的索引;
scrollToIndex(index, { align: 'start' })跳过去;- 渲染时把命中的词高亮出来。
scrollToIndex 的目标项此刻多半还没渲染,只能按预估尺寸落位;渲染出来量到 真实尺寸后位置会自动修正,所以不定高的消息列表里也不需要自己算偏移量。
只想让目标项进入视口(已在视口内则不动)时用 scrollIntoView(index), 它对「引用跳转」这种场景更自然,不会把已经看得见的内容再挪一次。
别忘了焦点与辅助技术
跳转是「程序把用户带到别处」,视口动了而焦点没动的话,屏幕阅读器用户得不到任何提示, 键盘用户接着按方向键也会从原处继续。定位时把焦点一并带过去:
vl.scrollToIndex(index, { align: 'start', focus: true });目标项会被补上 tabindex="-1"(能接收编程式焦点,但不进入 Tab 序列—— 项会被回收,全塞进 Tab 序列会让键盘用户在列表里走不出去)。
还有一个虚拟滚动特有的问题:DOM 里只有视口附近那几十项,屏幕阅读器按元素个数推断 规模,几万条的列表会被朗读成「第 3 项,共 20 项」。开启 aria 后, 位置与总数由数据显式告知:
{ aria: true, ariaLabel: '会话消息' }区分「所有命中」和「当前命中」
只把所有命中都染成一个颜色,用户跳转后不知道自己落在哪儿了。 给当前命中一个更强的样式(实底、描边),跳转才有反馈。
搜索大列表
全量 includes 配合输入防抖,几千条数据下完全够用。
数据量再大一个量级,或者需要分词、模糊匹配、拼音首字母时,就该把索引建在数据侧了 ——但跳转部分不变,仍然是 scrollToIndex。 虚拟列表在这件事上只关心「第几项」,不关心你怎么找到它。
Vanilla 写法
// 1. 在数据里找命中位置
const hits = list.reduce((acc, item, i) => {
if (blocksToSearchText(item.blocks).includes(keyword)) acc.push(i);
return acc;
}, []);
// 2. 跳到某个命中项
vl.scrollToIndex(hits[cursor], { align: 'start' });
// 3. 让高亮生效
vl.refreshItems();第三步为什么必要
DOM 层对已在渲染池中的 key 不会重新执行 renderItem,只调整位置—— 这是它的核心优化。代价是「数据变了但 key 没变」时得显式说一声, 搜索高亮正是这类:关键词变了,可见项的 <mark> 需要重新渲染。
vl.refreshItems(); // 重建全部可见项
vl.refreshItems([id1, id2]); // 只重建这几项,其余 DOM 原样保留跳转时用后者。「上一个 / 下一个」只改变了哪一项是「当前命中」, 也就是最多两项的样式,没必要为此重建整屏:
vl.refreshItems([list[prevHit].id, list[nextHit].id]);传进去的 key 不在可见范围内时是空操作——那些项本来就没有 DOM, 等它们滚进视口自然会按新数据渲染,所以不必自己判断谁还渲染着。
别在滚动或流式路径上调它
不带参数的 refreshItems() 会让当前可见的项全部重建,适合「关键词变了」 这种低频动作。逐 token 更新那种场景应该直接改对应节点的内容 (见 流式输出)。
Vue / React 版本完全不需要这一步:那两层是响应式的,关键词变了对应的渲染函数 会自己重新执行。
渲染高亮
renderItem 的第二个参数就是该项在完整列表里的索引,可以直接拿它和当前命中比对:
renderItem: (item, index) => {
const isCurrent = cursor >= 0 && hits[cursor] === index;
// 命中的词包成 <mark>,当前命中额外加一个 class
// ...
}