4、JavaScript 执行优化:减少长任务、拆分同步代码

页面切换卡顿,很多时候不是渲染慢,而是 JS 把主线程占死了。

我见过不少项目,一个点击事件触发了几百毫秒的计算,用户点完按钮页面直接“冻住”。说白了,浏览器的主线程既要跑 JS,又要处理布局、绘制、响应交互。你 JS 一直占着不放,页面自然就卡了。

这一章,我们就来聊聊怎么把那些“又长又臭”的同步任务拆开、打散、甚至扔到后台去。

4.1 长任务:卡顿的元凶

浏览器把超过 50ms 的 JS 执行称为“长任务”(Long Task)。为什么是 50ms?因为 60fps 的渲染间隔大约是 16.6ms,但浏览器需要留出余量来处理输入和绘制。一个任务超过 50ms,用户就能感知到卡顿。

核心原则: 任何同步执行的 JS 代码块,都不应该超过 50ms。

我在项目中遇到过这样的情况:一个表格组件,渲染前要对 10000 条数据做复杂格式化。直接写个 for 循环,跑下来 200ms。用户点切换页面,白屏 200ms 才出来。这就是典型的长任务。

4.2 拆分同步代码的三种武器

要解决长任务,思路很简单:把一个大任务切成若干小片,每片执行时间控制在 10-20ms,片与片之间让浏览器有机会处理其他事情。

常用的有三种方式:setTimeout、requestIdleCallback、Web Workers。它们各有各的适用场景。

方法 执行时机 是否阻塞主线程 适用场景
setTimeout(fn, 0) 宏任务队列,尽快执行 是(但分片后不连续阻塞) 拆分循环、分批渲染
requestIdleCallback 浏览器空闲时段 是(但只在空闲时执行) 低优先级任务、后台分析
Web Workers 独立线程,并行执行 否 CPU 密集型计算、数据处理

4.3 setTimeout:最朴素的拆分方案

setTimeout 的原理很简单:把一段代码放到下一个宏任务里执行。这样当前任务结束后,浏览器可以先处理渲染和交互,再回来执行下一段。

我习惯用它来拆分大数据量的循环。比如上面那个 10000 条数据的格式化,可以这样拆:

function processChunk(data, index, chunkSize = 100) {
  const end = Math.min(index + chunkSize, data.length);
  for (let i = index; i < end; i++) {
    // 对 data[i] 做格式化
    data[i].formatted = formatItem(data[i]);
  }
  if (end < data.length) {
    // 还有剩余数据,放到下一个宏任务
    setTimeout(() => processChunk(data, end, chunkSize), 0);
  } else {
    // 全部完成,触发渲染
    renderTable(data);
  }
}

// 启动
processChunk(rawData, 0, 100);
小技巧: chunkSize 可以根据数据复杂度调整。我一般从 50-200 开始试,用 Chrome Performance 面板看每片执行时间,控制在 10ms 以内最安全。

但要注意,setTimeout 有 4ms 的最小延迟(嵌套深度超过 5 层时)。如果你需要更精细的控制,可以考虑 requestIdleCallback。

4.4 requestIdleCallback:利用浏览器的“空闲时间”

requestIdleCallback 会在浏览器完成所有高优先级任务(渲染、交互、动画)后,有空闲时间才执行你的回调。它非常适合处理那些“不着急,但早晚要做”的任务。

我曾经在一个后台管理系统里,用 requestIdleCallback 来做埋点数据的批量上报。用户操作完,页面先响应,等空闲了再慢慢发请求。

function scheduleIdleTask(task, options = {timeout: 2000}) {
  requestIdleCallback((deadline) => {
    // deadline.timeRemaining() 返回当前帧剩余时间(ms)
    while (deadline.timeRemaining() > 5 && task.hasNext()) {
      task.processNext();
    }
    if (task.hasNext()) {
      // 没做完,下一帧继续
      scheduleIdleTask(task, options);
    }
  }, options);
}

// 使用示例
const task = {
  data: largeArray,
  index: 0,
  hasNext() { return this.index < this.data.length; },
  processNext() {
    // 处理一条数据
    this.index++;
  }
};
scheduleIdleTask(task);
避坑指南: 我曾经在低端安卓机上用过 requestIdleCallback,发现它可能长时间不被调用(浏览器一直忙)。所以一定要设置 timeout 参数,保证任务最终能执行。另外,Safari 不支持 requestIdleCallback,需要 polyfill 或用 setTimeout 兜底。

4.5 Web Workers:真正的“后台线程”

前面两种方法虽然能拆分任务,但本质上还是在主线程上跑。如果计算量特别大(比如图像处理、数据排序、加密解密),主线程还是会被拖慢。

Web Workers 就不一样了。它在一个独立的线程里运行,完全不阻塞主线程。主线程只管发消息、收结果。

我参与过一个报表项目,用户筛选条件后要对 50 万条数据做聚合计算。直接在主线程算,页面卡死 3 秒。用 Web Worker 后,主线程只负责显示 loading,计算完再更新视图,体验好了很多。

// worker.js
self.onmessage = function(e) {
  const { data, config } = e.data;
  // 执行复杂计算
  const result = heavyComputation(data, config);
  // 把结果发回主线程
  self.postMessage(result);
};

// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ data: largeDataSet, config: options });
worker.onmessage = function(e) {
  // 拿到结果,更新 UI
  updateChart(e.data);
  worker.terminate(); // 用完就关掉
};
注意: Worker 里不能操作 DOM,不能访问 window、document。它只能做纯计算。数据通过 postMessage 传递,会有序列化/反序列化的开销。如果数据量特别大(几百 MB),可以考虑用 Transferable Objects 来避免拷贝。

4.6 如何选择?一张图看懂

下面这张图总结了三种方案的决策流程,你可以根据任务类型和紧急程度来选。

JavaScript 长任务拆分决策流程 检测到长任务 任务是否可以拆分成小片? 是 否 setTimeout 分片 requestIdleCallback 低优先级?用空闲回调 Web Workers CPU 密集型计算 不阻塞主线程 主线程流畅,页面不卡顿

4.7 实战中的组合策略

在实际项目中,我很少只用一种方法。通常是这样组合的:

  • UI 交互触发的计算(比如搜索过滤):用 setTimeout 分片,保证输入框能及时响应。
  • 后台数据预处理(比如日志分析、数据聚合):用 requestIdleCallback,不抢用户操作的时间。
  • 重度计算(比如图像处理、大量数据排序):直接上 Web Worker,彻底不占主线程。
我的习惯: 在项目里我会封装一个 scheduler 工具函数,自动判断当前环境支持哪种方案,降级处理。比如优先用 requestIdleCallback,不支持就用 setTimeout,再不行就同步执行(但会给出警告)。

4.8 测量与验证

优化做完了,怎么知道有没有效果?

我推荐用 Chrome DevTools 的 Performance 面板。录制一段操作,看 Main 线程的火焰图。如果之前有红色的长任务标记,优化后红色块消失了,变成一个个绿色的小短条,那就说明拆成功了。

另外,也可以看 Total Blocking Time (TBT) 这个指标。TBT 是 FCP 到 TTI 之间所有长任务的阻塞时间总和。优化后 TBT 应该明显下降。

我曾经踩过的坑: 有一次我用 setTimeout 拆分了一个循环,但每片里都访问了 DOM。结果虽然主线程没长任务了,但频繁的 DOM 操作导致重排重绘,页面反而更卡。记住:拆分的是 JS 计算,不是 DOM 操作。DOM 操作应该集中到最后一并处理。

嗯,这一章的内容就到这里。记住一个核心:别让 JS 独占主线程超过 50ms。拆开它,让浏览器喘口气,用户的手感自然就顺了。