JavaScript的隐藏心脏:深入事件循环机制与异步陷阱

59 阅读5分钟

一、什么是事件循环?

JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。为了处理异步操作(如网络请求、定时器等),JavaScript 引入了事件循环机制。

事件循环的核心思想是:先执行同步代码,然后处理异步任务。异步任务又分为微任务宏任务,它们有不同的执行优先级。

二、进程与线程基础

在深入事件循环前,我们需要了解一些基本概念:

  • 进程:CPU 在运行指令和保存上下文所需要的时间。比如浏览器打开一个新标签页就是一个新进程。
  • 线程:是进程中的更小单位,指执行一段指令所需的时间。一个进程可以包含多个线程,如:
    • HTTP 线程
    • JS 引擎线程
    • 渲染线程

注意:JS 引擎线程和渲染线程是互斥的,但其他线程可以同时工作。

三、同步与异步执行

同步代码示例

let a = 1
console.log(a) // 1

这是最简单的同步代码,按顺序立即执行。

异步代码示例

setTimeout(() => {
  a = 2
  console.log(a) // 2 (1秒后执行)
}, 1000)

console.log(a) // 1 (立即执行)

这里 setTimeout 是异步的,不会阻塞后续代码执行。

四、微任务与宏任务

1. 微任务(Microtasks)

v8引擎会在执行时创建一个微任务队列,方便后续微任务的按序执行。

以下这些都是微任务:

  • Promise.then
  • process.nextTick
  • MutationObserver

2. 宏任务(Macrotasks)

同样的,v8引擎会在执行时创建一个宏任务队列,方便后续宏任务的按序执行。

以下这些都是宏任务:

  • setTimeout
  • setInterval
  • AJAX
  • I/O 操作
  • UI 渲染

执行顺序规则

  1. 执行所有同步代码(这属于宏任务),并将所有的微任务放入微任务队列,所有的宏任务放入宏任务队列
  2. 依次从微任务队列中取出并执行所有微任务。
  3. 如有需要,渲染页面。
  4. 宏任务队列中取出一个任务执行,开启下一个宏任务周期,即事件循环
  5. 重复上述过程。

五、代码案例分析

案例1:基础执行

console.log(1);
new Promise((resolve) => {
  console.log(2);
  resolve();
})
  .then(() => {
    console.log(3);
    setTimeout(() => {
      console.log(4);
    }, 0);
  });

setTimeout(() => {
  console.log(5);
  setTimeout(() => {
    console.log(6);
  }, 0);
}, 0);

console.log(7);

// 输出顺序:1 2 7 3 5 4 6

图例1.png

过程解析:

  1. 先执行同步代码,依次输出打印:1, 2, 7,将then放入微任务队列中,将最外层的setTimeout放入宏任务队列中。
  2. 执行队列中唯一的微任务,先输出打印:3,将then内部的setTimeout插入宏任务队列中,此时微任务队列为空,再从宏任务队列的队头取出一个宏任务执行。
  3. 执行最外层的setTimeout宏任务,开启下一轮宏任务周期。
  4. 先输出打印:5,再将最外层setTimeout嵌套的setTimeout放入宏任务队列中。
  5. 此时微任务队列仍为空,再从宏任务队列的队头取出一个宏任务执行,即then内部的setTimeout,输出打印:4
  6. 此后再取出宏任务队列中最后一个setTimeout,输出打印6。

案例2:包含await行为

console.log('script start');
async function async1() {
  await async2()
  console.log('async1 end');
}
async function async2() {
  console.log('async2 end');
}
async1()
setTimeout(() => {
  console.log('setTimeout');
}, 0)
new Promise((resolve) => {
  console.log('promise');
  resolve()
})
  .then(() => {
    console.log('then1');
  })
  .then(() => {
    console.log('then2');
  });
console.log('script end');

// 输出顺序:
// script start
// async2 end
// promise
// script end
// async1 end
// then1
// then2
// setTimeout

图例2.png

关键点

  • 带有async的函数的返回值是一个Promise对象,所以在执行时也视作同步代码
  • await 会将其后面的代码视为微任务,依次插入微任务队列中。
  • v8引擎会将await这行代码视作同步代码,在宏任务中执行。

过程解析:

  1. 和上述案例类似,先执行同步代码,先输出script start。然后执行async1函数,此时发现有await修饰的代码,将其后面的async1 end视作微任务,插入微任务队列中,再同步执行async2函数,输出async2 end
  2. 继续向下执行同步代码,发现setTimeout,将其插入宏任务队列中,然后依次打印Promise中的promise和末尾的script end,并将遇到的两个then视作微任务,依次插入微任务队列中。
  3. 以上,同步代码执行完毕。现在,从微任务队列中依次取出任务执行,即分别输出async1 endthen1then2。此后,微任务队列为空,从宏任务队列的队头取出一个(也仅一个)执行,输出setTimeout

六、总结

  1. 避免微任务的深嵌套v8需要将微任务队列中的任务执行完后才开启下一个宏任务,过多的微任务有可能导致页面卡顿。
  2. 合理使用setTimeout:即使延迟设为0也是宏任务,和同步代码是一个量级的。
  3. 理解async/await本质:它只是Promise的语法糖,async修饰的函数返回结果就是Promise对象,而await在功能上等同于then,但await属于同步代码
  4. 注意I/O性能I/O也属于宏任务,大量I/O操作可能影响用户体验

通过理解事件循环机制,开发者可以更好地理解异步代码的执行顺序,以此来优化性能,更能有效地避免常见的异步陷阱!