处理百万级任务,如何保证页面不卡顿?
一、问题的本质:JavaScript 阻塞浏览器渲染
当浏览器页面上有大量任务需要执行时(例如一百万个),如果在一个同步的 JavaScript 循环中一次性完成,会长时间占用 主线程。
浏览器的主线程负责执行 JavaScript、进行页面布局 (Layout)、绘制 (Paint) 以及响应用户交互。当主线程被长时间占用的 JavaScript 任务阻塞时,浏览器将无法进行渲染更新和响应用户操作,导致页面出现卡顿甚至卡死现象。
javascript
// 错误示例:一次性执行百万任务,将导致页面卡死
function performMillionTasks() {
const tasks = Array.from({ length: 1_000_000 }, (_, i) => () => {
// 假设这是一个耗时的计算任务
console.log(`Executing task ${i + 1}`);
});
console.time('Execution Time');
tasks.forEach(task => task()); // 浏览器在此处卡住,直到所有任务执行完毕
console.timeEnd('Execution Time');
}
// 调用该函数将导致页面无响应
// performMillionTasks();二、核心解决思路:任务分片 (Task Slicing)
解决问题的核心思路是分时执行或任务分片:将庞大的任务分解成若干个小批次的任务块,在每个任务块执行完毕后,将主线程的控制权交还给浏览器,使其有机会进行页面渲染和响应用户输入。
核心目标:不要长时间霸占主线程,给浏览器留出渲染的时间。
三、具体实现方案
方案一:使用 Web Worker
Web Worker 允许我们在后台线程(分线程)中执行脚本,而不会影响主线程的性能。
- 适用场景:适用于纯计算、CPU 密集型的任务,例如大文件分片、数据加密/解密、图像处理、计算文件哈希值等。
- 核心限制:
Worker线程无法直接访问或操作 DOM。 如果任务涉及 DOM 的读写,此方案不适用。数据通信需要通过postMessage和onmessage事件进行。
示例代码
javascript
// main.js - 主线程脚本
const worker = new Worker('worker.js');
// 向 worker 发送任务指令
worker.postMessage({ command: 'start', count: 1_000_000 });
// 接收来自 worker 的消息
worker.onmessage = function(e) {
console.log('Message received from worker:', e.data);
};
worker.onerror = function(error) {
console.error('Error in worker:', error);
};
// worker.js - Worker 线程脚本
self.onmessage = function(e) {
if (e.data.command === 'start') {
const totalTasks = e.data.count;
let completedTasks = 0;
for (let i = 0; i < totalTasks; i++) {
// 执行复杂的计算...
completedTasks++;
}
// 任务完成后,将结果发回主线程
self.postMessage({ status: 'complete', completed: completedTasks });
}
};方案二:使用 requestIdleCallback
requestIdleCallback 会在浏览器的一帧(Frame)的剩余空闲时间内执行回调函数。这是一种“见缝插针”的策略,非常适合处理可以延后的低优先级任务。
- 工作原理:浏览器在完成渲染等高优先级工作后,如果当前帧还有剩余时间,就会调用
requestIdleCallback的回调。 - 关键API:回调函数会接收一个
deadline对象,通过deadline.timeRemaining()可以获取当前帧剩余的毫秒数,从而判断是否还有足够的时间执行下一个任务。 - 优点:由浏览器原生调度,能够智能地利用空闲资源,避免影响关键渲染路径。
示例代码
javascript
const tasks = Array.from({ length: 1_000_000 }, (_, i) => `Task ${i + 1}`);
let currentTaskIndex = 0;
function runTaskBatch(deadline) {
// 当帧有剩余时间,或者任务超时时,执行任务
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && currentTaskIndex < tasks.length) {
// 执行一个任务单元
console.log(`Processing ${tasks[currentTaskIndex]}`);
currentTaskIndex++;
}
// 如果还有任务未完成,预约下一次空闲时执行
if (currentTaskIndex < tasks.length) {
requestIdleCallback(runTaskBatch, { timeout: 1000 }); // timeout 保证任务不会被无限期推迟
} else {
console.log('All tasks completed!');
}
}
// 启动任务
requestIdleCallback(runTaskBatch, { timeout: 1000 });方案三:使用 setTimeout 或 postMessage
这是一种更传统和通用的手动任务切分方式,其原理是利用宏任务(Macrotask)队列机制,在每个任务批次执行后,通过 setTimeout(callback, 0) 将下一个任务批次的执行权推入宏任务队列,从而释放主线程。
- 工作原理:
setTimeout(callback, 0)会将callback放入宏任务队列的末尾,等待当前同步代码和微任务队列执行完毕,并且浏览器完成一次渲染后,再从队列中取出并执行。 - 优点:兼容性好,逻辑简单直观,可以手动控制每次执行的任务量和间隔。
- 类比:React 的 Fiber 架构也采用了类似的时间分片思想来调度组件的渲染更新。
示例代码
javascript
const tasks = Array.from({ length: 1_000_000 }, (_, i) => `Task ${i + 1}`);
let currentTaskIndex = 0;
const BATCH_SIZE = 100; // 每次执行100个任务
function processChunk() {
if (currentTaskIndex >= tasks.length) {
console.log('All tasks completed!');
return;
}
const chunkEnd = Math.min(currentTaskIndex + BATCH_SIZE, tasks.length);
for (let i = currentTaskIndex; i < chunkEnd; i++) {
// 执行一个任务单元
console.log(`Processing ${tasks[i]}`);
}
currentTaskIndex = chunkEnd;
// 使用 setTimeout 将下一个任务块的执行推迟到下一个事件循环
setTimeout(processChunk, 0);
}
// 启动任务
processChunk();四、方案对比总结
| 方案 | 优点 | 缺点/限制 | 适用场景 |
|---|---|---|---|
Web Worker | 不阻塞主线程,充分利用多核 CPU | 无法直接操作 DOM,需要通过消息传递通信,有一定开销 | CPU 密集型、与 UI 无关的后台计算 |
requestIdleCallback | 浏览器原生调度,智能利用空闲时间,避免影响关键渲染 | 执行时机不确定,可能被低优先级推迟,兼容性稍差 | 可延后、非核心的低优先级任务 |
setTimeout | 兼容性极好,控制灵活,实现简单 | 时间控制不如 requestIdleCallback 精确,setTimeout(0) 存在最小延迟(通常为 4ms) | 需要广泛兼容性和手动控制执行节奏的场景 |