整段复制
虚拟滚动只保留视口附近的 DOM。用户选中的范围跨越了未渲染区域时,浏览器能复制到的 只有那几十个还活着的节点——中间成百上千项在剪贴板里凭空消失,而用户完全看不出来, 因为选区高亮是连续的。
这是虚拟滚动最容易被忽略的一处正确性缺口,而「复制整段回答」恰好是 AI 产品的核心操作。
怎么看出差别
点「选中全部对话」,再按 Cmd/Ctrl+C,粘贴到任意编辑器里:开着开关能拿到全部 400 条,关掉之后只剩视口附近的十几条。工具栏右侧会显示这次实际复制了多少字、 其中多少来自未渲染的项。
开启方式
给出 copyText,返回这一项对应的纯文本:
const vl = new VirtList(container, {
list,
itemKey: 'id',
estimatedSize: 80,
// 给出即开启;不给则完全不挂 copy 监听,行为与从前一致
copyText: (item) => `${item.role === 'user' ? '我' : 'AI'}:${blocksToMarkdown(item.blocks)}`,
// 各项之间的分隔符,默认换行
copySeparator: '\n\n',
renderItem: (item) => { /* ... */ },
});运行时可以开关它:vl.updateOptions({ copyText: undefined }) 会把监听摘掉。
复制时发生了什么
拦到 copy 事件后,按选区的实际范围拼装文本:
| 位置 | 取值方式 |
|---|---|
| 首项 | 选区起点到该项末尾(用户可能从半句话中间开始选) |
| 中间项 | 一律走 copyText,从数据取 |
| 末项 | 该项开头到选区终点 |
中间项即使恰好还在 DOM 里(缓冲区里的项)也走数据。否则同一次复制里一部分 来自 DOM、一部分来自数据,格式会不一致——而哪些项还在 DOM 里,对使用方是不可见的。
首尾两项若是整个落在选区里(全选、或者选区从页面别处一直拖过来), 同样走数据:只有「端点确实落在某一项内部」时才需要按 DOM 截取。
选区盖住整个列表时,边界取的是数据的首尾,而不是「第一个 / 最后一个已渲染项」。 后者会把结果限制在渲染窗口内——那正好就是「只复制到视口那一小段」, 等于这个功能没生效。
什么情况下不介入
以下情形一律放行浏览器的默认行为,不改剪贴板:
- 选区为空;
- 选区与这个列表没有交集(用户在选页面上别的东西);
- 两端落在同一项内——这一项完整存在于 DOM,浏览器复制的就是对的。
解不出结果时既不 preventDefault 也不写剪贴板,最坏情况是回到没有这个功能时的 表现,不会因为拦截失败而复制不到东西。
复制的文本要和看到的一致
copyText 与列表项的渲染是两条独立的渠道,库不会去推断它们的关系。 如果渲染时带了发送者名字、时间戳,而 copyText 只返回正文, 那么用户复制到的内容就会与看到的不同。让两者取自同一个格式化函数是最省事的做法。
一个做不到的场景
如果用户按住鼠标从列表顶部一直拖到底部,途中起点那一项的 DOM 可能已经被回收—— 浏览器会自己收缩选区,此时连「选区起点在哪」都不复存在了。
所以「跨很多屏的拖选」在任何虚拟滚动实现里都不可靠,这与本节的补齐无关。 可靠的入口是全选(Cmd/Ctrl+A 或一个「选中全部」按钮): 它一次性给出覆盖整个列表的选区,不依赖拖动过程。
让渲染与复制共用一个格式化函数
const format = (item) => `${item.role === 'user' ? '我' : 'AI'}:${blocksToMarkdown(item.blocks)}`;
const vl = new VirtList(container, {
// ...
copyText: format,
renderItem: (item) => {
const el = document.createElement('div');
el.textContent = format(item);
return el;
},
});