Vue 也能时间切片:3000 条列表丝滑渲染真实案例

44 阅读4分钟

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

image.png

image.png

我把这个真实案例抽成了一个在线 demo:

👉 时间切片性能优化 Demo
xuyimingwork.github.io/vue-asyncx/…

建议你这样体验:

  • 打开 demo
  • 在输入框里逐个输入、逐个删除字符
  • 切换「非优化版 / 时间切片版」

可以明显感受到:「UI 可以一边渲染、一边保持响应」

八、总结

我们经常会在技术讨论里看到“标准答案”:

  • 列表多 → 虚拟列表
  • 卡顿 → debounce
  • 异步 → 加 loading

但真实项目里,约束才是第一位的

当虚拟列表不可用时,
时间切片 + 可控的异步过程,
就是一个非常值得认真考虑的方案。

如果你也遇到过:

  • DOM 必须完整存在
  • 操作又不能卡
  • 变动还非常频繁

也许这个思路能帮到你。