页面要渲染十万条数据,怎样不卡顿?
一次性插入十万个 DOM 节点会让主线程长时间被占住,页面卡死。能分页就分页;必须一次展示时用虚拟列表,只渲染可视区域的几十条;只是数据多、必须全部插入时,用时间分片把插入拆成很多小批次,每批在 requestAnimationFrame 或 setTimeout 里执行,让浏览器在批次之间响应输入和绘制。
简答
接口一次返回十万条记录,直接循环
appendChild,页面会白屏好几秒,期间点什么都没反应。原因是 JavaScript 和渲染共用主线程,创建、插入十万个节点再算布局,是一个很长的任务,浏览器在它结束前没法处理用户输入,也没法绘制。先想能不能不渲染那么多。用户一屏只能看几十条,最好的办法是分页或者滚动加载。如果产品要求在一个列表里连续滚动浏览全部数据,就用虚拟列表:只渲染可视区域和上下少量缓冲的条目,滚动时根据位置替换内容,页面上始终只有几十个节点。
如果确实要把所有节点都放进页面,比如要支持浏览器的页内搜索,就用时间分片:每次只插入一小批,比如 100 条,插完一批就把控制权交还给浏览器,在下一帧再插下一批。浏览器在批次之间能响应点击、绘制已插入的内容,用户看到列表一点点长出来,而不是卡住不动。
要注意,时间分片只是把卡顿拆开,十万个节点最终都在页面上,内存和后续的布局成本一点没少,滚动、筛选时仍可能慢。数据量大时,虚拟列表才是根本的解决办法。
时间分片怎样写
下面每帧插入一批,插完再安排下一批:
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 有什么区别?》。