3000 条列表卡成 PPT,但产品却说:不能用虚拟列表。
因为他们要 Ctrl + F。
这篇文章记录了我在 DOM 必须完整存在 的极端约束下,
如何通过 时间切片 + 可控异步,让 Vue 列表在高频搜索中依然保持流畅。原理适合任意同时渲染超多 DOM 节点的场景。
没有魔法,只有真实踩坑。
一、事情是这样的:一个“看似很普通”的性能问题
这是一个后台项目里的菜单搜索功能:
- 菜单项 ≈ 3000 条
- 支持模糊搜索
- 搜索结果需要高亮
- 用户会频繁输入 / 删除
结果大家都懂:
👉 输入的时候 UI 明显卡顿
👉 有时甚至短暂无响应
第一反应是什么?
「上虚拟列表啊。」
我一开始也是这么想的。
二、不能用的虚拟列表
产品提了一个非常“朴素”的需求:
这个菜单需要支持浏览器原生的 Ctrl + F 搜索,
因为大家之前经常这么用,有使用习惯。
问题来了:虚拟列表的本质是什么?
- DOM 只渲染可视区域
- 不可见的项 = 不存在于 DOM
而 Ctrl + F 搜索的是什么?
真实存在的 DOM
结论很残酷,但很现实:
❌ 虚拟列表
❌ Ctrl + F
👉 两者在这个场景下是互斥的
三、同时渲染超多 DOM 节点,如何保证丝滑?
当 不能减少 DOM 数量 时,性能优化的方向就彻底变了。
问题不再是:
❌「怎么少渲染一点」
而是:
✅ 「如何在 DOM 必须完整存在的前提下,
让渲染过程不阻塞 UI」
这才是真正的难点。
四、核心思路:把“一次渲染”变成“一个渲染过程”
我最后采用的是一个在 Vue 场景下不算常见、但非常实用的思路:
时间切片(Time Slicing)
但注意,这里说的不是:
- for 循环 + setTimeout 糊一下
而是:
一次搜索 = 一个异步过程
这个过程可以被分段推进
中途可以更新 UI
并且可以被安全地中断
五、真实翻车现场:输入越快,列表越乱
初版代码是这样的:
const commonTimeSlicingList = ref()
async function queryCommonTimeSlicingList(list, chunkSize = 50) {
commonTimeSlicingList.value = []
const total = list.length;
// 分批更新
for (let i = 0; i < total; i += chunkSize) {
const chunk = list.slice(i, i + chunkSize);
commonTimeSlicingList.value = [...commonTimeSlicingList.value, ...chunk];
// 使用 setTimeout 让出控制权,实现时间切片
if (i + chunkSize < total) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
commonTimeSlicingList.value = list;
};
- 列表渲染不再是“一次性完成”
- 而是分多次更新列表
这么实现有个问题,就是 queryCommonTimeSlicingList 无法并发。
一旦并发,比如用户输入快一点(在删除场景很常见),就会出现:
- 上一次搜索还在往列表里追加数据
- 下一次搜索已经开始
最终表现就是:
❌ 搜索结果闪烁
❌ 列表内容被“污染”\
这类问题用 debounce 并不能本质解决,并且牺牲体验
因为 问题不在频率,而在异步过程的重叠。
六、需要把“异步”当成一等公民
我需要一套 能把异步当成“过程”来管理的模型。
最终我用的是一个自己在项目中长期使用的方案:vue-asyncx
在这个案例里,我用了两个非常关键的能力:
✅ 1️⃣ 中途更新
搜索过程中,我不是一次性更新列表,而是:
- 每次渲染 20 条(只要能糊住首屏即可)
- UI 逐步“长出来”
- 每一批之间主动让出主线程
👉 输入不会卡
👉 滚动不会卡
👉 DOM 最终是完整的(Ctrl + F 可用)
✅ 2️⃣ 防竞态
- 新搜索开始时
- 旧搜索的中途更新会被自动废弃
- 不需要自己维护 cancel / flag / token
这一点在高频输入场景下,价值非常大。
代码基本无调整:
const {
commonTimeSlicingList,
queryCommonTimeSlicingList,
} = useAsyncData('commonTimeSlicingList', async (list, chunkSize = 50) => {
const { getData, updateData } = getAsyncDataContext();
// 先清空列表
updateData([]);
const total = list.length;
// 分批更新
for (let i = 0; i < total; i += chunkSize) {
const chunk = list.slice(i, i + chunkSize);
const currentList = getData() || [];
updateData([...currentList, ...chunk]);
// 使用 setTimeout 让出控制权,实现时间切片
if (i + chunkSize < total) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return list;
});
七、效果如何?直接看 Demo
我把这个真实案例抽成了一个在线 demo:
👉 时间切片性能优化 Demo
xuyimingwork.github.io/vue-asyncx/…
建议你这样体验:
- 打开 demo
- 在输入框里逐个输入、逐个删除字符
- 切换「非优化版 / 时间切片版」
可以明显感受到:「UI 可以一边渲染、一边保持响应」
八、总结
我们经常会在技术讨论里看到“标准答案”:
- 列表多 → 虚拟列表
- 卡顿 → debounce
- 异步 → 加 loading
但真实项目里,约束才是第一位的。
当虚拟列表不可用时,
时间切片 + 可控的异步过程,
就是一个非常值得认真考虑的方案。
如果你也遇到过:
- DOM 必须完整存在
- 操作又不能卡
- 变动还非常频繁
也许这个思路能帮到你。