⚡2026 年了,十万级表格还只会「虚拟滚动」?难怪你的页面照样卡顿

0 阅读9分钟

前言

Hello~大家好,我是秋天的一阵风

做前端的小伙伴,面试大概率都被问过这个经典问题:大数据量表格如何优化渲染性能?

标准答案几乎是脱口而出:用虚拟滚动!只渲染可视区域的几十行 DOM,减少页面节点数量。

image.png

但不知道大家有没有踩过这样的坑:项目里老老实实写了虚拟滚动,DOM 数量确实降下来了,可一旦叠加实时搜索、多条件排序、批量筛选、全选反选,页面依旧卡顿、掉帧,甚至出现勾选错位、状态错乱的诡异 bug。

这也是现在面试官深挖的核心考点:

面试官: 单纯虚拟滚动没问题,但搜索、排序、筛选、全选同时触发,如何保证页面丝滑不卡顿、状态不出错?

说白了,2026 年还只靠「虚拟滚动」搞定大数据表格,真的太局限了。

虚拟滚动只能解决渲染层的 DOM 冗余问题,我们项目中 90% 的表格卡顿、状态异常,根本原因从来不是「DOM 太多」。

真正的瓶颈:全量数据计算阻塞主线程、勾选状态设计不合理、大量数据无脑常驻内存

一、性能根因:虚拟滚动治标不治本,卡顿根本不在 DOM

不少前端会有这样的误区:大数据表格卡顿就是 DOM 太多,上虚拟滚动就能万事大吉。可实际开发经常碰到:虚拟滚动已经把 DOM 压得很少,渲染没问题,但只要做搜索、筛选、排序、全选,页面依旧卡顿、出现勾选错乱。

问题根源不在渲染,而是JS 主线程被大量同步计算阻塞

虚拟滚动只处理渲染:只渲染可视区域加少量缓冲行,把页面 DOM 维持在低位,解决大批量节点带来的渲染、重排卡顿,这部分能力已经到顶。

但表格重点是交互,不是静态展示。

很多人写搜索、筛选、排序、批量勾选,直接在主线程遍历全部数据。JS 是单线程,十万级数据的遍历、比对、排序会占满主线程,UI 渲染、用户操作全部被卡住。

这就是开了虚拟滚动表格照样卡的真相:虚拟滚动只管 “画得快”,解决不了计算把主线程堵死的问题。

image.png

二、重新认知虚拟滚动:它只是基础,不是万能解药

我们简单回顾下虚拟滚动的落地逻辑,基本所有开源库都是这套思路:

  1. 固定表格容器高度,监听 scrollTop 滚动位置,实时计算当前可视区域的起止行号
  2. 仅渲染 [start, end] 区间内容,上下预留少量缓冲行,避免滚动闪烁
  3. 通过撑开滚动高度、transform 偏移,模拟完整数据滚动条效果

它完美解决了「DOM 节点过多」的渲染问题,但解决不了任何数据计算和状态问题

虚拟滚动无法解决三个实战高频问题:

  • 全量搜索筛选导致主线程阻塞
  • 虚拟 DOM 场景下,全选、半选状态联动异常
  • 排序、筛选后,勾选状态错位错乱

三、搜索 / 筛选:别再无脑用 filter 遍历全量数据

十万级表格最常见的卡顿场景:实时输入搜索。

绝大多数人的写法非常粗暴:监听 Input 事件,用户每输入一个字符,就全量 filter 遍历一次数据。十万级数据每次遍历耗时几百毫秒,高频输入直接堵死主线程,页面输入延迟、点击无响应。

给大家一套可直接落地、层层递进的优化方案,从低成本到高阶全覆盖。

1. 基础优化:函数防抖

用户连续输入会疯狂触发事件,完全没必要次次执行筛选。设置 300ms 防抖,只在输入停顿后触发检索,直接过滤掉绝大多数无效计算,零成本大幅提优。

2. 核心优化:Web Worker 剥离主线程计算

JS 单线程是卡顿的本质,复杂计算和 UI 渲染互斥。

正确做法:把所有耗时的筛选、匹配逻辑,全部丢到 Web Worker 后台执行,完全不占用主线程,页面操作全程丝滑。

同时做轻量化通信:Worker 不回传完整数据对象,只回传匹配成功的 ID 列表,极大减少通信开销。

3. 进阶优化:倒排索引

如果是后台系统高频检索表格,可以提前预建「关键词 → 行 ID」倒排索引

后续搜索不再全量遍历,直接查表匹配,把 O (N) 遍历降级为近似 O (1) 查询,彻底根除搜索卡顿。

// 防抖封装,拦截高频无效触发
const debounceSearch = debounce((keyword) = >{
    // 仅传递ID列表,轻量化任务下发
    worker.postMessage({
        type: 'TABLE_FILTER',
        keyword,
        sourceData: allTableIdList
    })
},
300)

// Worker 后台纯计算,不阻塞UI
self.onmessage = (e) = >{
    if (e.data.type === 'TABLE_FILTER') {
        // 纯匹配筛选,无DOM操作
        const resultIdList = e.data.sourceData.filter(id = >matchKeyword(id, e.data.keyword))
        // 只回传ID,不回传完整对象
        self.postMessage({
            resultIdList
        })
    }
}

// 主线程仅负责更新渲染
worker.onmessage = (e) = >{
    currentResultIds = e.data.resultIdList updateVirtualListRender(currentResultIds)
}

整体链路非常清晰:用户输入 → 防抖节流 → Worker 后台筛选 → 回传 ID 结果 → 虚拟列表按需渲染

核心原则:所有耗 CPU 的纯计算,一律不准占用主线程

四、勾选状态:告别遍历赋值,用数据推导代替 DOM 操作

虚拟表格最坑的 bug,就是勾选错位、状态错乱

很多人用小数据表格的逻辑写虚拟表格:靠数组存选中项、靠 DOM 赋值控制勾选、全选就遍历所有数据批量打标。

这套写法在虚拟滚动下完全失效:页面永远只有几十行 DOM,根本没有完整的复选框节点。

虚拟列表的勾选,只能靠数据推导,绝对不能靠 DOM 操作。

1. 摒弃数组存储,用 Set 实现 O (1) 读写

千万别用数组存选中 ID。

两种方案性能差距肉眼可见:

// 反面教材:数组存储 + includes 查询 O(N)
const selectedArr = ['id1', 'id2'];
const isSelectedBad = (rowId) = >selectedArr.includes(rowId);

// 正确方案:Set 存储,增删查全部 O(1)
const selectedSet = new Set(['id1', 'id2']);
const isSelectedGood = (rowId) = >selectedSet.has(rowId)

2. 全选 / 半选状态:用标志位推导,拒绝全量遍历

十万条数据全选,千万别循环遍历批量赋值,纯纯无效性能消耗。

最优落地方案:不依赖循环遍历所有数据计算选中状态,而是通过「全局全选标记 + 单独记录取消项」的方式,直接通过数值对比就能精准算出表格的全选、半选、未选三种状态

let isAllSelected = false
const excludedIds = new Set() // 全选状态下,手动取消的项

// 计算选中数量
function getCheckedCount(total) {
  return isAllSelected ? total - excludedIds.size : selectedSet.size
}

// 推导表头三态:未选/全选/半选
function getHeaderStatus(total) {
  const count = getCheckedCount(total)
  if (count === 0) return 'none'
  if (count === total) return 'all'
  return 'indeterminate'
}

// 单行勾选判断(稳定核心)
function getRowChecked(rowId) {
  return isAllSelected ? !excludedIds.has(rowId) : selectedSet.has(rowId)
}

这里是解决勾选错位的终极核心勾选状态永远绑定唯一稳定 rowId,绝对不要绑定数组下标。

排序、筛选会打乱数组下标,但业务 ID 永久不变,这是状态不乱的根本保障。

五、排序优化:异步计算解耦,彻底告别卡顿错位

和筛选同理,主线程直接执行十万级数据 sort 排序,是典型的高危卡顿操作。

同时,绝大多数排序后勾选错位的 bug,根源都是:排序改了数组顺序,状态却绑在了旧下标上

统一最优解:排序计算全部丢入 Web Worker,只更新 ID 序列,不改动状态绑定

完整排序流程

// 主线程:下发排序参数,不参与计算
function handleSort(field, order) {
  worker.postMessage({
    type: 'TABLE_SORT',
    sortField: field,
    sortOrder: order,
    idList: currentResultIds
  })
}

// Worker:后台全量排序,纯计算无阻塞
self.onmessage = (e) => {
  if (e.data.type === 'TABLE_SORT') {
    const { idList, sortField, sortOrder } = e.data
    const sortedIdList = idList.sort((a, b) => {
      const valA = getRowValueById(a, sortField)
      const valB = getRowValueById(b, sortField)
      return sortOrder === 'asc' ? valA - valB : valB - valA
    })
    self.postMessage({ sortedIdList })
  }
}

// 主线程:仅更新顺序,勾选状态完全保留
worker.onmessage = (e) => {
  currentResultIds = e.data.sortedIdList
  updateVirtualListRender(currentResultIds)
}

六、终极架构:存储、计算、渲染三层分层设计

前面的方案可以解决十万级数据的卡顿和错乱问题,但数据量继续上涨,全量数据常驻内存,会引发内存溢出、页面闪退等隐性问题。

这里给大家一套可支撑百万级数据、高可扩展的分层架构 通用方案。

1. 存储层:按需加载,避免内存常驻

不要把全量数据全部塞在前端内存中:

  • 大数据落地 IndexedDB,释放 JS 堆内存压力
  • 虚拟滚动不再读取全量数据,根据可视区域按需读取
  • 搜索、排序仅生成 ID 序列,渲染时再按需拿详情

2. 计算层:所有耗时操作异步化

所有高 CPU 消耗逻辑,统一剥离主线程:

  • 排序、筛选、匹配:Web Worker 执行
  • 高频搜索:防抖 + 倒排索引优化
  • 状态管理:Set/Map + 标志位推导,拒绝遍历

3. 渲染层:极致轻量化

  • 只渲染可视区 + 缓冲行 DOM
  • 滚动按需取数、动态更新视图
  • 杜绝大批量 DOM 一次性挂载
// 存储层:按需从本地取数,不常驻全量数据
async function getTableRowsById(idList) {
  return await IndexedDB.table.bulkGet(idList)
}

// 计算层:统一异步处理耗时操作
function handleTableOperate(type, payload) {
  worker.postMessage({ type, payload })
}

// 渲染层:仅渲染可视区域
function updateVirtualListRender(validIdList) {
  const { start, end } = getViewPortRange()
  const viewIdList = validIdList.slice(start, end + 20)
  getTableRowsById(viewIdList).then(viewData => {
    renderViewDom(viewData)
  })
}

// 完整链路:用户操作 => 异步计算 => 按需取数 => 轻量化渲染

总结

写到最后大家应该明白:大数据表格优化,真的不止虚拟滚动这四个字。

虚拟滚动,只能解决 DOM 渲染的表层问题。真正决定表格流畅度、稳定性的,是你的计算调度、状态设计、内存策略

2026 年的前端工程化,拼的不是会不会写基础功能,而是复杂场景下的细节兜底和架构思维。