在前端开发中,我们时常会遇到需要处理大量数据渲染的场景 ------ 比如一次性插入 10000 条甚至更多数据到页面中。采用 "暴力破解" 的方式直接同步执行,往往会导致页面卡顿、白屏,严重影响用户体验。
时间分片(Time Slicing) 则是解决这一问题的核心思想,它通过 "拆分任务 + 异步调度" 的方式,让 JS 执行与页面渲染互不阻塞,实现流畅的数据处理与展示。
一、问题:为什么大量数据直接渲染会卡顿?
要理解时间分片的价值,首先我们要从浏览器的Event Loop(事件循环) 机制入手。
众所周知,浏览器的 JS 线程与渲染线程是 "互斥" 的 ------ 也就是说,当 JS 线程在执行同步代码时,渲染线程会被阻塞,导致无法更新页面,只有当 JS 线程空闲时,渲染线程才会执行页面重绘。
1.1 暴力破解的困境:同步执行的问题
为了让大家更好的感受到普通方法和时间分片的区别,现在,我将先展示最直接的暴力破解法,其核心逻辑就是通过for
循环同步创建 10000 个li
元素并插入页面:
html
<ul id="container"></ul>
<script>
let now = Date.now();
const total = 100000;
let ul = document.getElementById('container');
// 同步循环创建并插入元素
for(let i = 0; i < total; i++){
let li = document.createElement('li');
li.innerText = Math.random()*total;
ul.appendChild(li);
}
console.log('JS运行时间', Date.now() - now); // 看似JS执行快
setTimeout(()=>{
console.log('总运行时间', Date.now() - now); // 实际总耗时远更长
},0);
</script>
这是运行结束后的运行时间:
从这里可以看出,暴力破解法问题如下:
- JS 线程阻塞 :100000 次
createElement
、appendChild
是同步任务,会占据 JS 线程较长时间(即使 JS 本身执行耗时短,DOM 操作的开销也会累积); - 渲染被推迟:在 JS 同步任务执行期间,渲染线程完全被阻塞,页面无法更新,用户会看到 "白屏" 或 "卡顿",直到所有 JS 任务结束后,渲染线程才会一次性完成所有元素的绘制;
- 用户体验差:若数据量更大(比如 100 万条),那么很可能导致浏览器出现 "无响应" 。
二、时间分片思想
时间分片的本质是 **"化整为零" ------ 将原本需要一次性完成的大量任务(如 100000 条数据渲染),拆分成多个小批次任务(如每次渲染 10 条),并通过 异步调度机制 **(如setTimeout
、requestAnimationFrame
)让这些小任务在 JS 线程空闲时执行。
其核心逻辑符合 Event Loop 的调度规则:
- 执行一小批任务(如渲染 10 条数据),耗时极短(通常 < 16ms,远小于屏幕刷新周期);
- 释放 JS 线程,让渲染线程执行页面更新(此时用户能看到已渲染的部分数据,无白屏);
- 等待下一次异步调度时机,重复执行下一批任务,直到所有数据处理完成。
通过这种方式,JS 执行与页面渲染交替进行,既完成了大量数据处理,又保证了页面的流畅性。
三、时间分片的实现方式
3.1 基础实现:基于 setTimeout 的异步调度
setTimeout
是最基础的异步调度 API,它能将任务推入 "宏任务队列",待当前同步任务执行完毕、微任务队列清空后,再等待指定时间(此处设为 0ms,即尽快执行)执行。
代码示例:
html
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000; // 总数据量
const once = 50; // 每次渲染50条(批次大小,可调整)
let index = 0; // 当前渲染的起始索引
// 递归执行分片任务
const loop = (curTotal, curIndex) => {
// 终止条件:剩余数据为0时停止
if (curTotal <= 0) return false;
// 计算当前批次需渲染的数量(避免最后一批不足10条)
const pageCount = Math.min(curTotal, once);
// 异步执行当前批次渲染
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
const li = document.createElement('li');
li.innerText = `${curIndex + i}: ${Math.random() * total}`;
ul.appendChild(li);
}
// 递归执行下一批:剩余数据量=当前总量-本批数量,起始索引=当前索引+本批数量
loop(curTotal - pageCount, curIndex + pageCount);
}, 0);
};
// 启动分片渲染
loop(total, index);
</script>
核心逻辑解析:
- 批次大小(once) :设为 50 是权衡 "渲染效率" 与 "流畅度" 的结果 ------ 批次太小会增加异步调度次数,批次太大仍可能阻塞,这个数据可根据实际数据量调整。
- 递归终止 :当
curTotal
(剩余数据量)≤0 时,停止递归,避免无限调用; - 异步调度 :
setTimeout(fn, 0)
确保当前批次的 DOM 操作在 "下一次宏任务" 中执行,此时 JS 线程会先释放,让渲染线程更新已完成的部分。
不足:可能存在 "轻微白屏"
setTimeout
的调度时机由 JS 引擎决定,不一定与浏览器的 "屏幕刷新周期"(通常为 60Hz,即每 16.6ms 刷新一次)同步。
若宏任务执行时机与渲染时机错位,在我们拖动进度条时,可能导致短暂的 "白屏"(虽比同步执行好很多,但体验仍有优化空间)。
3.2 优化实现:基于 requestAnimationFrame 的渲染同步
为解决setTimeout
的 "时机错位" 问题,可使用浏览器原生 API------requestAnimationFrame(rAF) 。它的核心优势是:确保回调函数在 "屏幕每一次刷新前" 执行,与渲染周期完全同步,不会丢帧,彻底解决白屏问题。
实现代码(文档示例):
html
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000;
const once = 50;
let index = 0;
const loop = (curTotal, curIndex) => {
if (curTotal <= 0) return false;
const pageCount = Math.min(curTotal, once);
// 用rAF替代setTimeout,与屏幕刷新同步
requestAnimationFrame(() => {
for (let i = 0; i < pageCount; i++) {
const li = document.createElement('li');
li.innerText = `${curIndex + i}: ${(Math.random() * total).toFixed(2)}`;
ul.appendChild(li);
}
loop(curTotal - pageCount, curIndex + pageCount);
});
};
loop(total, index);
</script>
核心优势:
- 渲染同步:rAF 的回调会在浏览器准备好重绘时触发(约每 16.6ms 一次),确保当前批次的 DOM 操作完成后,渲染线程能立即更新页面,用户看到的是 "逐步流畅加载",无任何白屏;
- 性能友好 :若浏览器标签页处于 "后台",rAF 会自动暂停,避免浪费 CPU 资源(
setTimeout
仍会执行)。
适用场景:
对渲染流畅度要求高的场景,如长列表渲染、大数据表格展示等。
四、两种实现方式的对比
特性 | setTimeout 实现 | requestAnimationFrame 实现 |
---|---|---|
调度时机 | 宏任务队列,时机不固定 | 与屏幕刷新同步(≈16.6ms / 次) |
白屏问题 | 可能存在轻微白屏 | 无白屏,渲染流畅 |
后台运行 | 仍会执行,消耗 CPU | 后台暂停,节省资源 |
兼容性 | 所有浏览器支持 | IE9 + 支持(现代浏览器均兼容) |
适用场景 | 对流畅度要求不高的简单场景 | 长列表、大数据渲染等核心场景 |