JavaScript 执行性能调优:从长任务拆分到事件循环优化

HTMLPAGE 团队
15 分钟阅读

讲清楚 JS 主线程怎么工作、什么是长任务、怎么用 requestIdleCallback 和 MessageChannel 优化、何时考虑 Web Worker,让页面保持可响应。

#JavaScript Performance #Event Loop #Long Tasks #Web Worker #Main Thread

JavaScript 执行性能调优:从长任务拆分到事件循环优化

最常见的性能问题不是"网络慢"或"资源大",而是"JavaScript 跑了好久,页面卡住了"。

浏览器的主线程同时处理:JavaScript、样式、布局、绘制、合成。一旦 JavaScript 任务太长,就会阻塞后续的渲染,导致用户感受到的延迟。


1. 事件循环(Event Loop)简述

浏览器每一帧的执行顺序:

┌─────────────────────────┐
│  执行所有同步 JS        │ ← 你的代码在这
│  (Script 标签、事件处理) │
└─────────────────────────┘
         ↓
┌─────────────────────────┐
│  执行 Microtask Queue   │
│  (Promise、MutationObs) │
└─────────────────────────┘
         ↓
┌─────────────────────────┐
│  渲染一帧               │
│  (样式、布局、画、合成) │
└─────────────────────────┘
         ↓
┌─────────────────────────┐
│  执行 Macrotask Queue   │
│  (setTimeout、click)    │
└─────────────────────────┘

关键:如果步骤 1(JavaScript)跑了超过 50ms,就会延迟步骤 3(渲染),导致掉帧。


2. 什么是"长任务"?

浏览器认为"长任务"是指超过 50ms 的 JavaScript 执行。

// ❌ 长任务
function processData() {
  for (let i = 0; i < 1000000000; i++) {
    // 复杂计算,耗时 200ms
    Math.sqrt(i)
  }
}

// 调用这个函数
processData()

// 在这 200ms 内,用户的点击无法响应
// 屏幕也看不到更新

3. 长任务拆分:最常用的优化技术

方法 1:setTimeout 拆分

// 好一点,但不专业
function processDataInChunks() {
  const data = Array.from({ length: 1000000 })
  let index = 0
  
  function processChunk() {
    const end = Math.min(index + 10000, data.length)
    for (let i = index; i < end; i++) {
      // 处理一小块
    }
    index = end
    
    if (index < data.length) {
      setTimeout(processChunk, 0) // 让出主线程
    }
  }
  
  processChunk()
}

这样每 10000 项用 setTimeout 延迟一下,主线程可以处理其他任务。

方法 2:requestIdleCallback(更好)

const data = Array.from({ length: 1000000 })
let index = 0

function processInIdle() {
  requestIdleCallback((deadline) => {
    // deadline.timeRemaining() 返回这一帧还有多少空闲时间
    while (index < data.length && deadline.timeRemaining() > 1) {
      // 处理一项
      index++
    }
    
    if (index < data.length) {
      processInIdle() // 下一帧继续
    }
  })
}

processInIdle()

requestIdleCallback 会在浏览器真正空闲时运行,不会浪费时间。

方法 3:scheduler.yield(最新方案)

// Chrome 123+, 实验性
async function processDataYield() {
  for (let i = 0; i < 1000000; i++) {
    // 每 1000 项让出一次
    if (i % 1000 === 0) {
      await scheduler.yield()
    }
    // 处理
  }
}

4. Web Worker:把计算移出主线程

对于特别重的计算(ml.js、复杂算法),可以用 Web Worker 在后台线程运行:

MainThread.js:

const worker = new Worker('worker.js')

// 发送数据给 worker
worker.postMessage({
  command: 'process',
  data: hugeArray
})

// 监听 worker 的结果
worker.onmessage = (e) => {
  console.log('处理完成', e.data)
}

worker.js:

self.onmessage = (e) => {
  if (e.data.command === 'process') {
    // 长时间计算,不会阻塞主线程
    const result = heavyComputation(e.data.data)
    self.postMessage(result)
  }
}

function heavyComputation(data) {
  // 耗时的 AI、加密、数据处理
  return data.map(...)
}

优势

  • 主线程不会被阻塞
  • 用户交互始终响应

缺点

  • 有通信开销
  • Worker 不能操作 DOM
  • 内存占用(每个 Worker 是独立线程)

5. MessageChannel 优雅地拆分任务

有时候 Promise.then() 优先级太高(Microtask),setTimeout 太不可控。用 MessageChannel 可以获得更精确的控制:

const channel = new MessageChannel()
const port = channel.port1

port.onmessage = () => {
  console.log('继续下一个任务')
  // 这会在本轮 Macrotask 之后,下一个 Macrotask 之前运行
}

function breakLongTask() {
  // 做一个小块任务
  doSomeWork()
  
  // 发送消息给自己,触发下一个任务
  channel.port2.postMessage(null)
}

MessageChannel 插在 Macrotask 之间,比 setTimeout 更高效。


6. 优化策略速查表

场景方案优先级
简单的数据处理用 async/await + requestIdleCallback
CPU 密集型计算(ML、图像处理)Web Worker
频繁小任务的拆分MessageChannel
需要 DOM 更新的逐步操作setTimeout + requestAnimationFrame
依赖 API 的异步流程async/await

7. 常见失败案例

案例 1:过度拆分反而变慢

// ❌ 太细致了,通信开销太大
for (let i = 0; i < list.length; i++) {
  await scheduler.yield()
  processOne(list[i])
}

应该分块处理,而不是每一项都让出。

案例 2:Worker 的数据太大

发送 10MB 的数据给 Worker,光是序列化-反序列化就要几百毫秒。

解决:用 Transferable Objects(零拷贝):

const buffer = new ArrayBuffer(10 * 1024 * 1024)
worker.postMessage({ buffer }, [buffer]) // 转移所有权,不复制

案例 3:忘记监听 Worker 全局异常

worker.addEventListener('error', (e) => {
  console.error('Worker error:', e.message)
})

8. 性能监测与观察

用 Chrome DevTools 观察长任务

  1. Performance tab
  2. 点 record
  3. 做操作
  4. 停止,看时间轴,找紫色块(Long Task)

用代码观察

// Long Task API (实验性)
const observer = new PerformanceObserver((list) => {
  for (const {name, duration} of list.getEntries()) {
    if (duration > 50) {
      console.warn(`Long task: ${name} (${duration.toFixed(2)}ms)`)
    }
  }
})

observer.observe({ entryTypes: ['longtask'] })

9. 最佳实践清单

  • 识别 JS 中的长任务(性能 tab 看紫块)
  • 对 >50ms 的同步计算进行拆分
  • requestIdleCallback 处理非关键数据
  • 对 CPU 密集型计算考虑 Web Worker
  • 定期检查 INP(Interaction to Next Paint)指标
  • 在移动设备上测试(主线程瓶颈更明显)

内链阅读