JavaScript 事件循环机制:宏任务与微任务的执行顺序

JavaScript 事件循环:一场永不停歇的舞蹈 💃🕺

各位观众,各位码农,各位前端er,大家好!欢迎来到今天的“前端茶话会”,我是你们的老朋友,代码界的段子手——Bug Killer(暂且这么叫吧,虽然我杀的Bug还不如生的多…😂)。

今天我们要聊的主题,是JavaScript世界里一个神秘又至关重要的机制——事件循环(Event Loop)

如果你觉得这个名字听起来像是什么科幻大片,那也没错!因为它就像一个永不停歇的舞者,在JavaScript这片舞台上,协调着各种任务的执行,让我们的代码能够优雅流畅地运行。

更具体地说,我们要深入探讨事件循环中的两个重要概念:宏任务(Macro Task)微任务(Micro Task)。 它们就像舞台上的两类舞者,有着不同的步调和优先级,共同编织着JavaScript执行的华丽乐章。

准备好了吗?让我们一起揭开事件循环的神秘面纱,看看宏任务和微任务是如何在这场舞蹈中各显神通的吧!

一、JavaScript:单线程的独舞者 🎤

首先,我们必须明确一个前提:JavaScript本质上是一个单线程的语言。

想象一下,只有一个舞者在舞台上。他/她必须依次完成每一个动作,不能同时分身去做其他事情。这就是JavaScript的单线程特性。

这意味着,JavaScript引擎在同一时刻只能执行一个任务。如果这个任务耗时很长,比如一个复杂的计算或者一个漫长的网络请求,那么整个程序就会被阻塞,就像舞者突然卡住了一样,观众只能干瞪眼。

为了解决这个问题,JavaScript引入了事件循环机制。它就像一个精明的导演,负责调度和安排各种任务的执行顺序,让单线程的舞者也能跳出多任务并行的效果。

二、事件循环:导演的调度台 🎬

事件循环到底是什么呢? 简单来说,它就是一个不断循环的过程,负责从任务队列中取出任务并执行。

我们可以把事件循环想象成一个循环播放的音乐盒,它不断地从盒子里取出音乐片段(任务)并播放(执行)。

这个音乐盒的核心组件包括:

  • 调用栈(Call Stack): 这是JavaScript引擎执行代码的地方,就像舞台,舞者在这里表演。
  • 堆(Heap): 用于存储对象等复杂数据结构,就像舞者的道具。
  • 任务队列(Task Queue): 这是一个存放待执行任务的队列,就像导演的剧本,上面记录着各种任务的出场顺序。
  • 事件循环(Event Loop): 这是整个机制的核心,它像导演一样,不断地检查调用栈是否为空,如果为空,则从任务队列中取出一个任务放到调用栈中执行。

用一张表格来总结一下:

组件 功能 比喻
调用栈 JavaScript引擎执行代码的地方,遵循后进先出(LIFO)的原则。 舞台
用于存储对象等复杂数据结构。 道具
任务队列 存放待执行的任务,遵循先进先出(FIFO)的原则。 剧本
事件循环 不断检查调用栈是否为空,如果为空,则从任务队列中取出一个任务放到调用栈中执行。 导演

事件循环的工作流程大致如下:

  1. JavaScript引擎首先执行全局代码,这些代码会被压入调用栈中。
  2. 当遇到异步任务时,比如setTimeoutPromise.then等,JavaScript引擎会将这些任务交给相应的模块处理(比如浏览器提供的Web API)。
  3. 这些模块会在适当的时候将异步任务的回调函数放入任务队列中。
  4. 事件循环不断地检查调用栈是否为空。
  5. 如果调用栈为空,事件循环会从任务队列中取出一个任务(回调函数)放到调用栈中执行。
  6. 重复步骤4和5,直到任务队列为空。

这个过程就像一个永动机,不断地循环往复,保证了JavaScript代码的执行。

三、宏任务与微任务:舞台上的双人舞 👯

现在,我们要隆重介绍今天的主角:宏任务(Macro Task)微任务(Micro Task)

它们都是任务队列中的成员,但它们有着不同的优先级和执行时机。

1. 宏任务(Macro Task):重量级的舞者 💪

宏任务可以理解为一些比较重量级的任务,它们通常是与I/O操作相关的,比如:

  • setTimeout
  • setInterval
  • setImmediate (Node.js)
  • I/O 操作 (例如:读取文件、网络请求)
  • UI 渲染

每一个宏任务都有一个自己的任务队列,也叫做宏任务队列

我们可以把宏任务想象成舞台上的重量级舞者,他们需要耗费更多的能量和时间来完成表演。

2. 微任务(Micro Task):轻盈的舞者 💃

微任务则是一些比较轻量级的任务,它们通常是与状态更新相关的,比如:

  • Promise.then
  • Promise.catch
  • Promise.finally
  • MutationObserver
  • process.nextTick (Node.js)

所有的微任务都存放在一个微任务队列中。

我们可以把微任务想象成舞台上轻盈的舞者,他们能够快速地完成表演。

3. 宏任务与微任务的区别:优先级与执行时机 🥇🥈

宏任务和微任务最大的区别在于它们的优先级和执行时机。

  • 优先级: 微任务的优先级高于宏任务。
  • 执行时机: 在每一个宏任务执行完毕后,JavaScript引擎会立即执行微任务队列中的所有微任务。

换句话说,JavaScript引擎会尽全力先完成所有的微任务,然后再去执行下一个宏任务。

我们可以用一个简单的例子来说明:

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

这段代码的执行结果是:

script start
script end
promise1
promise2
setTimeout

为什么会是这样的结果呢? 让我们一步一步地分析:

  1. 首先,执行全局代码,输出script start
  2. 遇到setTimeout,这是一个宏任务,将其回调函数放入宏任务队列中。
  3. 遇到Promise.resolve().then,这是一个微任务,将其回调函数放入微任务队列中。
  4. 输出script end
  5. 此时,全局代码执行完毕,调用栈为空。
  6. 事件循环开始工作,它首先检查微任务队列是否为空。
  7. 发现微任务队列中有两个微任务(promise1promise2的回调函数),依次执行它们,输出promise1promise2
  8. 微任务队列为空,事件循环开始执行宏任务队列中的第一个宏任务(setTimeout的回调函数),输出setTimeout

从这个例子可以看出,微任务的执行时机是在当前宏任务执行完毕之后,下一个宏任务执行之前。

4. 一轮事件循环:舞蹈的一个回合 🔄

我们可以把一轮事件循环定义为:

  1. 执行一个宏任务。
  2. 执行所有可执行的微任务。

这个过程会不断重复,直到宏任务队列和微任务队列都为空。

用一张图来总结一下宏任务和微任务的执行顺序:

+---------------------+    +---------------------+    +---------------------+
|   Execute Macro     |    |  Execute All Micro  |    |   Prepare Next     |
|      Task           | => |       Tasks         | => |   Macro Task       |
+---------------------+    +---------------------+    +---------------------+
         ^                                                 |
         |                                                 |
         +-------------------------------------------------+
                       (Loop back)

四、深入解析:宏任务与微任务的爱情故事 💌

为了更好地理解宏任务和微任务,让我们再来看几个例子,深入解析它们之间的关系。

示例1:Promise与setTimeout的纠葛 💔

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => console.log('promise 3'));
}, 0);

setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

Promise.resolve().then(() => {
  console.log('promise 1');
});

Promise.resolve().then(() => {
  console.log('promise 2');
});

这段代码的执行结果是:

promise 1
promise 2
setTimeout 1
promise 3
setTimeout 2

分析:

  1. 首先,执行全局代码,遇到两个setTimeout,将它们的回调函数分别放入宏任务队列中。
  2. 遇到两个Promise.resolve().then,将它们的回调函数分别放入微任务队列中。
  3. 全局代码执行完毕,调用栈为空。
  4. 事件循环开始工作,首先执行微任务队列中的所有微任务,输出promise 1promise 2
  5. 微任务队列为空,事件循环开始执行宏任务队列中的第一个宏任务(setTimeout 1的回调函数),输出setTimeout 1
  6. setTimeout 1的回调函数中,又遇到了一个Promise.resolve().then,将其回调函数放入微任务队列中。
  7. setTimeout 1的回调函数执行完毕,事件循环再次检查微任务队列,发现其中有一个微任务(promise 3的回调函数),执行它,输出promise 3
  8. 微任务队列为空,事件循环开始执行宏任务队列中的第二个宏任务(setTimeout 2的回调函数),输出setTimeout 2

这个例子说明,在宏任务中产生的微任务,会在当前宏任务执行完毕后立即执行。

示例2:MutationObserver的魅力 ✨

MutationObserver是一个用于监听DOM变化的API,它的回调函数也是一个微任务。

const observer = new MutationObserver(() => {
  console.log('mutation observer');
});

const element = document.createElement('div');
observer.observe(element, { attributes: true });

Promise.resolve().then(() => {
  console.log('promise');
});

element.setAttribute('data-test', '1');

console.log('script end');

这段代码的执行结果是:

script end
promise
mutation observer

分析:

  1. 首先,创建了一个MutationObserver实例,并监听了一个div元素的属性变化。
  2. 遇到Promise.resolve().then,将其回调函数放入微任务队列中。
  3. 改变了div元素的属性,触发了MutationObserver的回调函数,将其放入微任务队列中。
  4. 输出script end
  5. 全局代码执行完毕,调用栈为空。
  6. 事件循环开始工作,首先执行微任务队列中的所有微任务,依次输出promisemutation observer

这个例子说明,DOM变化的回调函数也是一个微任务,会在当前宏任务执行完毕后立即执行。

示例3:requestAnimationFrame的秘密 🤫

requestAnimationFrame是一个用于优化动画的API,它的回调函数会在浏览器下一次重绘之前执行。虽然它的执行时机与宏任务相似,但它并不属于宏任务。

requestAnimationFrame(() => {
  console.log('requestAnimationFrame');
});

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('script end');

这段代码的执行结果是:

script end
promise
requestAnimationFrame

分析:

  1. 首先,注册了一个requestAnimationFrame的回调函数。
  2. 遇到Promise.resolve().then,将其回调函数放入微任务队列中。
  3. 输出script end
  4. 全局代码执行完毕,调用栈为空。
  5. 事件循环开始工作,首先执行微任务队列中的所有微任务,输出promise
  6. 微任务队列为空,浏览器准备进行下一次重绘,此时会执行requestAnimationFrame的回调函数,输出requestAnimationFrame

这个例子说明,requestAnimationFrame的回调函数会在浏览器下一次重绘之前执行,它的执行时机在微任务之后,宏任务之前。

五、总结:掌握舞蹈的节奏 🎵

通过以上的分析,我们可以总结出以下几点:

  • JavaScript是单线程的,事件循环机制是解决单线程阻塞问题的关键。
  • 事件循环不断地从任务队列中取出任务并执行。
  • 任务队列分为宏任务队列和微任务队列。
  • 微任务的优先级高于宏任务,会在每一个宏任务执行完毕后立即执行。
  • 一轮事件循环包含:执行一个宏任务,执行所有可执行的微任务。
  • requestAnimationFrame的回调函数会在浏览器下一次重绘之前执行,它的执行时机在微任务之后,宏任务之前。

掌握了事件循环的机制,我们就能够更好地理解JavaScript代码的执行顺序,从而编写出更加高效和可靠的代码。

六、实战演练:Bug Killer的Debug时间 🐛

现在,让我们来做一些实战演练,看看你是否真正掌握了事件循环的知识。

题目1:

console.log('1');

setTimeout(function() {
  console.log('2');
  new Promise(resolve => {
    console.log('3');
    resolve();
  }).then(function() {
    console.log('4')
  })
});

new Promise(resolve => {
  console.log('5');
  resolve();
}).then(function() {
  console.log('6')
});

console.log('7');

setTimeout(function() {
  console.log('8');
  new Promise(resolve => {
    console.log('9');
    resolve();
  }).then(function() {
    console.log('10')
  })
});

请问这段代码的执行结果是什么?

(答案:1 5 7 6 2 3 4 8 9 10)

题目2:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});
console.log('script end');

请问这段代码的执行结果是什么?

(答案:script start async1 start async2 promise1 script end async1 end promise2 setTimeout)

如果你能够正确地回答这些问题,那么恭喜你,你已经成为了事件循环的专家!🎉

七、结语:舞蹈永不落幕 🎭

JavaScript事件循环是一个复杂而精妙的机制,它保证了JavaScript代码的执行效率和流畅性。

掌握了事件循环的知识,我们就能够更好地理解JavaScript的运行原理,从而编写出更加高效和可靠的代码。

希望今天的“前端茶话会”能够帮助你更好地理解JavaScript事件循环的机制。

记住,代码的世界就像一场永不停歇的舞蹈,只有掌握了节奏,才能跳出最美的舞姿!

感谢大家的观看,我们下期再见! 👋

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注