前端知库

页面要渲染十万条数据,怎样不卡顿?

一次性插入十万个 DOM 节点会让主线程长时间被占住,页面卡死。能分页就分页;必须一次展示时用虚拟列表,只渲染可视区域的几十条;只是数据多、必须全部插入时,用时间分片把插入拆成很多小批次,每批在 requestAnimationFrame 或 setTimeout 里执行,让浏览器在批次之间响应输入和绘制。

参考答案

简答

接口一次返回十万条记录,直接循环 appendChild,页面会白屏好几秒,期间点什么都没反应。原因是 JavaScript 和渲染共用主线程,创建、插入十万个节点再算布局,是一个很长的任务,浏览器在它结束前没法处理用户输入,也没法绘制。

先想能不能不渲染那么多。用户一屏只能看几十条,最好的办法是分页或者滚动加载。如果产品要求在一个列表里连续滚动浏览全部数据,就用虚拟列表:只渲染可视区域和上下少量缓冲的条目,滚动时根据位置替换内容,页面上始终只有几十个节点。

如果确实要把所有节点都放进页面,比如要支持浏览器的页内搜索,就用时间分片:每次只插入一小批,比如 100 条,插完一批就把控制权交还给浏览器,在下一帧再插下一批。浏览器在批次之间能响应点击、绘制已插入的内容,用户看到列表一点点长出来,而不是卡住不动。

要注意,时间分片只是把卡顿拆开,十万个节点最终都在页面上,内存和后续的布局成本一点没少,滚动、筛选时仍可能慢。数据量大时,虚拟列表才是根本的解决办法。

时间分片怎样写

下面每帧插入一批,插完再安排下一批:

JavaScript
function renderInChunks(container, items, chunkSize = 100) {
  let index = 0;
  function renderChunk() {
    const fragment = document.createDocumentFragment();
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      const li = document.createElement('li');
      li.textContent = items[index];
      fragment.append(li);
    }
    container.append(fragment);
    if (index < items.length) requestAnimationFrame(renderChunk);
  }
  requestAnimationFrame(renderChunk);
}

为什么用 requestAnimationFrame?它的回调在浏览器下一次绘制前执行,频率一般和屏幕刷新率一致,每帧插入一批,插完浏览器就能绘制并处理输入。用 setTimeout(renderChunk, 0) 也能把任务拆开。

要注意,requestAnimationFrame 在页面切到后台时会暂停,切回来才继续。如果希望后台也接着插入,就改用 setTimeout。

DocumentFragment 的作用是先在内存里组装好一批节点,再一次性放进页面。MDN 也提醒,它的性能收益常被高估,有的引擎里甚至比直接循环插入慢;这里减少卡顿的关键是分批,而不是 Fragment 本身。

每批多少条合适?目标是每批在 50 毫秒以内完成,超过就会成为长任务,影响交互响应。可以用 Performance 面板观察,再调整批量大小,长任务的标准见《什么是长任务,为什么会影响交互响应?怎样观测和拆分?》。

虚拟列表为什么是根本办法

虚拟列表根据滚动位置算出当前该显示第几条到第几条,只渲染这几十条,用一个撑开的容器保持滚动条高度。无论数据是一千条还是十万条,页面上的节点数基本不变,初始渲染和滚动都很快。具体实现见《虚拟列表通过什么方式减少渲染成本?》,高度不固定时见《动态高度的虚拟列表怎样处理测量、滚动锚点和焦点?》。

常见追问

数据处理本身也很慢怎么办?

比如十万条数据要先排序、过滤、格式化,这部分计算可以放进 Web Worker,算完把结果传回主线程渲染,主线程就不会被计算卡住,见《Web Worker 可以解决哪些主线程卡顿?》。

表格有大量列,横向也很长怎么办?

可以做二维虚拟化,行和列都只渲染可视的部分。也可以用 content-visibility: auto 让屏幕外的内容跳过渲染,但节点仍在 DOM 里,见《content-visibility: auto 与移除 DOM 有什么区别?》。