深入理解JavaScript事件循环机制:宏任务与微任务详解

156 阅读6分钟

引言

JavaScript作为一门单线程语言,通过事件循环(Event Loop)机制实现了异步编程。理解事件循环对于编写高效、可预测的JavaScript代码至关重要。本文将深入探讨事件循环机制,特别是宏任务和微任务的执行顺序与应用场景。

事件循环基础概念

JavaScript引擎执行代码时,会将不同任务分为同步任务和异步任务。同步任务直接在主线程执行,而异步任务则进入任务队列,等待主线程空闲时执行。这种机制就是事件循环。

在事件循环中,任务又分为两类:

  • 宏任务(Macro Task):如setTimeoutsetInterval、I/O操作等
  • 微任务(Micro Task):如Promise.thenqueueMicrotaskMutationObserver

浏览器环境下的事件循环

基本执行顺序

来看一个简单的例子:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>宏任务,微任务,任务队列</title>
</head>

<body>
  <script>
    // 一个script 就是宏任务开始
    // static 静态方法  
    console.log('script start');
    // 异步任务,宏任务
    setTimeout(() => {
      console.log('setTimeout');
    }, 0)
    // then 异步 微任务 任务队列
    Promise.resolve().then(() => {
      console.log('promise');
    })

    console.log('script end');
  </script>
</body>

</html>

执行结果:

image.png 这个例子清晰地展示了事件循环的核心规则:

  1. 首先执行同步代码(script startscript end
  2. 然后执行当前事件循环中的所有微任务(promise
  3. 最后执行宏任务(setTimeout

微任务之MutationObserver

MutationObserver是浏览器提供的一个API,用于监听DOM变化,它也是一种微任务:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>微任务</title>
</head>

<body>
  <script>
    // event loop 是 JS 执行机制,也是代码执行的开始
    // html 是第一个BFC 块级格式化上下文
    const target = document.createElement('div')
    document.body.appendChild(target)
    const observer = new MutationObserver(() => {
      console.log('微任务:MutationObserver');
    })
    // 监听target 节点的变化
    observer.observe(target, {
      attributes: true,
      childList: true
    })

    target.setAttribute('data-set', '123')
    target.appendChild(document.createElement('span'))
    target.setAttribute('style', 'background-color:green')
  </script>
</body>

</html>

上面的代码创建了一个MutationObserver实例并监听DOM节点的变化。当我们对DOM节点进行修改时,回调函数会在当前事件循环的微任务阶段执行。虽然我们进行了三次DOM操作,但MutationObserver会将这些变化批量处理,在微任务阶段只触发一次回调。

queueMicrotask API

HTML规范提供了queueMicrotask API,它是一种更直接地创建微任务的方法:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>微任务</title>
</head>

<body>
  <script>
    console.log('同步');
    // 批量更新
    // dom 树,  cssom, layout 树 图层合并
    queueMicrotask(() => {
      // 让批量更新去到渲染队伍之前
      // 会在渲染之前执行,保证DOM更新,但不是渲染完了
      // 一个元素的高度  offsetHeight scrollTop getBoundingClientRect()
      // 立即重绘重排 耗性能 用来观察DOM的新的API
      console.log('微任务: queueMicrotask');

    })
    console.log("同步结束");
  </script>
</body>

</html>

执行结果:

同步
同步结束
微任务: queueMicrotask

queueMicrotask对于DOM操作特别有用,它可以在DOM更新后但在浏览器重新渲染前执行代码,有助于避免不必要的重绘重排。

Node.js环境下的事件循环

Node.js的事件循环与浏览器有所不同,它增加了一些特有的微任务类型:

// Node.js 环境特有的微任务
console.log('Start');

// 微任务
Promise.resolve().then(()=>{
  console.log('Promise Resolved');
})

// node 微任务
// process 进程对象
process.nextTick(()=>{
  console.log('Process Next Tick');
})
// 宏任务
setTimeout(()=>{
  console.log('haha');
  Promise.resolve().then(()=>{
    console.log('inner Promise');
  })
},0)
console.log('end');

执行结果:

Start
end
Process Next Tick
Promise Resolved
haha
inner Promise

在Node.js环境中,process.nextTick的优先级高于Promise,会先于其他微任务执行。这是Node.js特有的一个API,用于将回调函数放到当前执行栈的尾部,在下一个事件循环之前执行。

复杂场景分析

让我们分析一个更复杂的例子,包含多个Promise和setTimeout:

console.log('同步Start');
const promise1 = Promise.resolve('First Promise')
const promise2 = Promise.resolve('Second Promise')
const promise3 = new Promise(resolve =>{
  resolve('Third Promise')
  console.log('promise3');
  
})


setTimeout(()=>{
  console.log('下一把再相见');
  const Promise4 = Promise.resolve('Fourth Promise')
  Promise4.then(value => console.log(value))
},0)
setTimeout(()=>{
  console.log('下一把再相见');
},0)

promise1.then(value => console.log(value))
promise2.then(value => console.log(value))
promise3.then(value => console.log(value))

console.log('同步end');

执行结果:

同步Start
promise3
同步end
First Promise
Second Promise
Third Promise
下一把再相见
Fourth Promise
下一把再相见

这个例子揭示了以下几点:

  1. Promise构造函数内的代码是同步执行的(promise3立即输出)
  2. 所有同步代码执行完后,才会执行微任务队列
  3. 微任务队列执行完后,才会执行宏任务队列
  4. 在执行一个宏任务过程中产生的新微任务,会在当前宏任务执行完后立即执行

事件循环的实际应用

理解事件循环机制在以下场景特别有用:

  1. UI渲染优化:使用微任务可以确保在下一次渲染之前完成DOM操作,减少重绘次数
  2. 异步操作编排:根据任务类型合理安排代码执行顺序
  3. 性能优化:将计算密集型任务拆分并使用微任务执行,避免阻塞主线程

来道经典的代码题

大家和我一起来看到下面的代码题,输出最终的打印顺序结果:

console.log('script start')  // 1. script start

async function async1() {
  await async2()
  console.log('async1 end')   // 5. async1 end
}
async function async2() {
  console.log('async2 end')  // 2. async2 end
}
async1()

setTimeout(function() {
  console.log('setTimeout')   // 8. setTimeout
}, 0)

new Promise(resolve => {   // 3. Promise
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')  // 6. promise1
  })
  .then(function() {
    console.log('promise2')  // 7. promise2
  })

console.log('script end')    // 4. script end

可以看到打印结果如下: image.png

首先执行同步代码,打印"script start",调用async1()函数,进入async1内部,在async1中遇到await async2(),立即执行async2()函数,async2函数执行,打印"async2 end"

await使async1剩余部分进入微任务队列等待;继续执行同步代码,遇到setTimeout,其回调被放入宏任务队列 ;遇到Promise构造函数,立即执行内部代码,打印"Promise";Promise的两个then回调被放入微任务队列。

打印"script end",同步代码执行完毕,开始清空微任务队列:

  • 执行await后的代码,打印"async1 end"

  • 执行第一个then回调,打印"promise1"

  • 执行第二个then回调,打印"promise2"

微任务队列清空后,执行宏任务队列中的setTimeout回调,打印"setTimeout"

这体现了JavaScript的事件循环机制:先执行同步代码,然后是微任务,最后是宏任务。


总结

JavaScript的事件循环机制是理解异步编程的基础,掌握宏任务和微任务的执行顺序可以帮助我们编写更高效、更可预测的代码:

  1. 同步代码优先执行
  2. 微任务队列次之执行(PromisequeueMicrotaskMutationObserver等)
  3. 宏任务队列最后执行(setTimeoutsetInterval、I/O等)
  4. Node.js中的process.nextTick优先级高于其他微任务

参考资源: