渲染十万条数据不卡顿

面试官非常喜欢问这道极限压测题:“后端犯浑,一个接口一口气直接返回了 10 万条列表数据给你。页面不能分页,要求你全部渲染在这个页面上且不能把页面卡死白屏,你该怎么做?”

这背后考察的,其实是前端应对大数据量导致的主渲染线程阻塞的解题能力与大局观。

为什么直接渲染 10 万条会卡死?

哪怕是极简结构,向页面的 DOM 树内硬生生地塞入 100,000 个独立的 <div> Node节点。

  • JS 耗时惊人:创建和组装十万个节点并挂载,这一瞬时间会榨干所有的执行时间。
  • 渲染引擎崩溃:主线程把渲染树计算完后,交给浏览器去 Layout 并绘制巨幅长卷,浏览器不仅当场失去响应(白屏),最后吃空内存 OOM 崩溃。

针对这道题,我们从基础到极致,主要有三套解法。

解法一:时间分片(Time Slicing / 分批渲染)

既然一次性塞入十万条会堵塞血管,那我们能不能“蚂蚁搬家”,一次只塞几十条,把长卡顿切碎成用户无法感知的超短小执行块呢?

这就是时间分片的核心。我们利用浏览器的原生高级 API requestAnimationFrame(每一帧渲染之前执行的回调),把十万条数据切分成无数小批次,趁浏览器在每一帧刷新页面的空隙,塞进几百条数据。

const totalData = new Array(100000).fill('我是数据');
const container = document.getElementById('list-container');
const batchSize = 100; // 每一帧渲染 100 条
let currentIndex = 0; // 当前渲到了哪条

function renderBatch() {
  const fragment = document.createDocumentFragment();
  
  // 从未渲染的数据中抓出 100 条
  const end = Math.min(currentIndex + batchSize, totalData.length);
  for (let i = currentIndex; i < end; i++) {
    const div = document.createElement('div');
    div.innerText = totalData[i] + ` - 第${i}`;
    fragment.appendChild(div);
  }
  
  // 一次性贴进 DOM 树
  container.appendChild(fragment);
  currentIndex = end;
  
  // 如果还有没搬完的,预约浏览器在下一帧空闲时继续搬
  if (currentIndex < totalData.length) {
    window.requestAnimationFrame(renderBatch);
  }
}

// 启动时间切片渲染引擎
renderBatch();

评价:用 requestAnimationFrame 切了片,首屏瞬间就出来了,页面也不会假死。但它有一个致命缺陷:当几秒之后这十万个节点最终还是全部塞进了页面里,你的 HTML 依然挂载着多达数十万个真实 DOM!此时只要页面结构稍有风吹草动引发回流,拖拽滚动条,依然能感受到如同陷入了深深沼泽泥潭般的惊雷卡顿。

终极解法二:虚拟列表(Virtual List)🌟

要彻底解决这道题,并满足生产环境企业级苛刻的流畅度要求,唯一真神是——虚拟长列表技术

这是包括 VS Code 的代码行、巨无霸表格组件(AntD Table的虚拟模式)、大长文的动态评论区都在使用的终极利器。

核心原理:视口切除(只渲染你此刻能看见的范围)。

既然用户的屏幕高度可能只有 800px,一屏顶破天只能看到几十行数据。那我何苦在背后渲染十万个真实节点?

  1. 幽灵占位符撑开容器:我创建一个空壳,高度人工设置为 100000 * 每行高度 50px = 5百万px 的极高不可见空 <div>。这样一来,浏览器的滚动条直接变得极其短小,用户觉得里面仿佛沉淀了万条长河。
  2. 侦测滚动窗口区间:监听父容器的 scroll 滚动行为并获取目前的 scrollTop
  3. 计算动态索引切片:经过小学算术 startIndex = Math.floor(scrollTop / 每行高度),我就瞬间算出:哦,此时用户的窗口正好滑到了第 4500 项的位置。
  4. 渲染实体抽屉(核心渲染区):我利用绝对定位或是 translate,在这片滑动的空白深渊中,精准定位到第 4500 的深度。然后我仅仅向 DOM 里挂载渲染 [4500] 到 [4530] 这短短 30 个真实卡片 DOM。并随着用户上下拖拽滚轮,不断的销毁上方滚出的节点,补充新进入视口的下方节点。

不管后台吐给我十万条还是一个亿的数据,除了作为 JS Array 原生变量占点内存外,真实写入到页面进行重绘计算回流的 DOM 树只有这区区 30 棵树杈子。自然快如闪电。

生产中的虚拟列表

由于实现中常常要应对“高度不等的列表项动态测量缓存”、“滚动性能节流”、“缓冲挂载区”等深坑,绝大多数工程里不会选择手磨这套系统。由于我们在前文无限滚动方案篇中曾有所涉猎,企业内可以直接祭出杀器:

  • React 生态:react-window, react-virtualized
  • Vue 生态:vue-virtual-scroller

高频面试题剖析

Q:遇到海量数据甚至超十万的数据要在单页纯前台展示不能翻页,你会如何处理解决因节点过多造成的白屏或灾难卡顿?

回答思路:

  1. 破题直击要害:挑明单次暴力的全量 appendChild 会阻塞长达几秒的 UI Main Thread 让页面白屏卡死挂掉,而这归根到底是大量重算排版带来的性能恶化和巨量对象的内存积压。
  2. 提出进阶策略(掩耳盗铃的时间切片流):可以说出方案 A:在后台分段分批加载数据,并在内部利用 DocumentFragment 内存片段包裹,通过递归包裹 requestAnimationFrame(或新版的 requestIdleCallback 更好),在不阻塞系统绘制的高帧切片零碎时间里“分批塞进去”。强调这种方法能保命首屏呈现,但最后真实 DOM 太深还是会带来滚动操作时的掉帧。
  3. 搬出究极大杀器(镇压一切长列表神针):亮瞎盲点的终极方案——虚拟列表。
    • 讲述三步核心逻辑:“超大占位符撑住总滑轮滚动空间”;
    • “挂载极少数在可视区域(ViewPort)里的渲染项以避免一切回流负担”;
    • “通过监听高度运算 scrollTop 计算边界绝对定位,实时偷天换日更换挂载容器内展示的那几十个节点值”。指出这是真正一劳永逸拔掉卡死隐患的工程级答案。