10分钟了解宏任务与微任务
2024-12-09 11:13:16

面试官:您对异步了解吗?

我:多少懂一点,异步就是前端浏览器为了不阻塞主线程运行,采取的一种策略,可以将部分任务挂起,等到主任务队列执行完毕后,进入队列循环执行异步。

面试官:不错,那您应该也知道宏任务和微任务,这里您能说说吗?

我:哈哈哈哈,这个我以前了解过,现在忘了呢。

面试官:哈哈哈,您又开始幽默了。

正文

宏任务和微任务的存在几乎是前端常见的面试题,我很早以前只是了解,但是没细看,如今被打脸确实算是咎由自取。

于是,今天我仔细翻看查阅了这块的资料,可以非常确信的说,宏任务的说法确实被取消了,搜不到宏任务这个说法了。

详情参考,深入:微任务与 Javascript 运行时环境

当然,官方的说法取消归取消,但是执行顺序的基本模型按照原来的那个思路理解是没问题的。

理解宏任务与微任务,本质上是要理解JS代码中执行顺序的队列,也就是所谓的事件循环机制。

本篇主要讲宏任务与微任务,并不涉及到DOM渲染,如果要是加入DOM渲染,个人参考这篇文档:对于宏任务和微任务,你知道多少?

这篇文章讲的相对较为详细,配图很适合新人观看,而且个人仔细对比了其他几篇文档,这确实也不是胡说,是正儿八经的解析了一遍,可以参考。

宏任务(macrotask)

  • 主代码块
  • setTimeout
  • setInterval
  • setImmediate ()-Node
  • requestAnimationFrame ()-浏览器
  • AJAX
  • DOM事件
1
2
3
4
5
6
7
8
console.log('script start');

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

console.log('script end');
//正确答案是: script start, script end, setTimeout

分析:JS运行主线程,碰到setTimeout放入宏任务队列等待执行,之后继续运行主线程,主线程执行完毕之后,去宏任务队列读取对应的函数,进入主线程执行。

微任务(microtask)

  • process.nextTick ()-Node
  • Promise.then()
  • catch
  • finally
  • Object.observe
  • MutationObserver
1
2
3
4
5
6
7
8
9
10
console.log('script start');

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

console.log('script end');
//正确答案是: script start, script end, promise1, promise2

分析:JS运行主线程,碰到Promise放入微任务队列等待执行,之后继续运行主线程。

主线程执行完毕之后,去微任务队列读取对应的函数,进入主线程执行。

注意点

  • setTimeout是一个宏任务,它的事件回调在宏任务队列,Promise.then()是一个微任务,它的事件回调在微任务队列,二者并不是在一个任务队列
  • 当执行到script脚本的时候,js引擎会为全局创建一个执行上下文,在该执行上下文中维护了一个微任务队列,当遇到微任务,就会把微任务回调放在微队列中,当所有的js代码执行完毕,在退出全局上下文之前引擎会去检查该队列,有回调就执行,没有就退出执行上下文,这也就是为什么微任务要早于宏任务。
  • 二者没有什么优先级之说,只是进入了循环的机制中,有时候看着像是优先度高,实际上只是因为机制的原因。

因此,我们要注意一些不完全正确的说法,很多人说什么微任务执行优先度高于宏任务,本质上是没有理解事件循环的模型。

在下边的练习中,我们会发现,如果微任务的执行优先度真的高于宏任务,那么按理来说,微任务应该全部优先宏任务出结果,但事实显然不是。

练习

进入练气之前,我们牢记这个规律:一次循环,宏任务执行一个,先清空微任务队列,再去执行下一个宏任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
setTimeout(() => {
console.log("1");
setTimeout(() => {
console.log("2");
}, 500);
new Promise(resolve => {
resolve();
console.log("3");
}).then(() => {
console.log("4");
});
}, 500);

setTimeout(() => {
console.log("5");
new Promise(resolve => {
resolve();
console.log("6");
}).then(() => {
console.log("7");
});
}, 500);

console.log("8");
// 正确答案是:8, 1, 3, 4, 5, 6, 7, 2
  1. 首先执行主线程,主线程本身是宏任务,执行过程中遇到 setTimeout(宏任务),将其分配到宏任务队列中,继续执行主线程,执行过程中遇到第二个 setTimeout(宏任务),将其追加到宏任务队列中,继续执行主线程,首先输出6,主线程执行完毕。
  2. 去宏任务队列读取对应的函数,进入主线程执行,接着输出1,遇到一个 setTimeout(宏任务),将其分配到新的宏任务队列中,遇到一个 promise (微任务),但是构造函数中的代码为同步代码,接着输出3,则then 之后的任务加入到微任务队列中去,则第一个setTimeout(宏任务)执行完毕。
  3. 遵循事件循环机制,执行第一个setTimeout(宏任务)中碰到的promise (微任务),接着输出4
  4. 继续从第一个宏任务队列读取函数,接着输出5,同理,微任务在当前宏任务队列执行完成后,优先下一轮的宏任务执行,清空队列,输出6,7。
  5. 执行第一个setTimeout(宏任务)中包含的setTimeout(宏任务),接着输出2

以上,我们会发现,微任务的优先度并未高于宏任务,只是因为在第二轮循环中,微任务要优先清空。

同时,因为一次循环中只会清空一个宏任务,所以5对应的setTimeout并进入第一轮的宏任务中,等到第二轮结束之后,再继续向下寻找,才进入了第三轮的任务循环,找到下一个宏任务,如此,直至完毕。

总结

JavaScript是一种单线程语言,所有任务都在一个线程上完成。

一旦遇到大量任务或者遇到一个耗时的任务,比如加载一个高清图片,网页就会出现”假死”,因为JavaScript停不下来,也就无法响应用户的行为。

为了防止主线程的阻塞,JavaScript 有了 同步 和 异步 的概念。

所以 JavaScript 便使用一套机制去处理同步和异步操作,那就是事件循环 (Event Loop)。

  • 所有同步任务都在主线程上依次执行,形成一个执行栈(调用栈),异步任务则放入一个任务队列
  • 当执行栈中任务执行完,再去检查微任务队列里的微任务是否为空,有就执行,如果执行微任务过程中又遇到微任务,就添加到微任务队列末尾继续执行,把微任务全部执行完
  • 微任务执行完后,再到任务队列检查宏任务是否为空,有就取出最先进入队列的宏任务压入执行栈中执行其同步代码
  • 然后回到第2步执行该宏任务中的微任务,如此反复,直到宏任务也执行完,如此循环

Call Stack,就是面试中提到的宏任务清空之后,首先执行当前的微任务,再去尝试DOM渲染,最后触发EventLoop机制,执行宏任务,如此反复循环。

结语

虽然又是一道造火箭才会有用的题目,但是作为前端开发者,在面试中,这似乎成了段位升级的必考题。

我个人挺讨厌这种感觉的,不过,关于浏览器的基础常识,多懂一点也好,而且这也是要弄懂nextTick必须的点。

我找到的这篇文章相对写的较为准确,但是不够生动形象,个人更推荐看视频:面试还不会判断宏任务和微任务的输出顺序?

这个UP的配图挺省心,而且例子也颇为形象,我个人很喜欢,简单易懂。

参考

深入:微任务与 Javascript 运行时环境

面试还不会判断宏任务和微任务的输出顺序?

对于宏任务和微任务,你知道多少?