Skip to content

处理百万级任务,如何保证页面不卡顿?

一、问题的本质: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 的读写,此方案不适用。数据通信需要通过 postMessageonmessage 事件进行。

示例代码

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 });

方案三:使用 setTimeoutpostMessage

这是一种更传统和通用的手动任务切分方式,其原理是利用宏任务(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)需要广泛兼容性和手动控制执行节奏的场景