首先,我们提出几个疑问,什么是宏任务和微任务?他们有什么区别?为什么会有宏任务和微任务?我们来一个一个解答。
在此之前,先了解下事件循环。
事件循环
单线程的JavaScript
我们都知道,JavaScript是一种单线程语言,有同步和异步的概念,异步主要是为了防止代码阻塞,你想一下,如果你请求一个接口,服务器半天没有返回结果,是否页面也要一直等待结果返回呢,所以,对于异步任务,我们可以将它放入另外的队列,等待其他同步代码先执行,从而不会引起阻塞。
但同时,单线程又是必要的,原因就是在浏览器中,我们要对 DOM 做各种操作,如果 JavaScript 是多线程的,那么两个线程同时对一个 DOM 做操作,如何保证不冲突呢?所以,只用一个线程来操作,就保证了程序执行的一致性。
执行栈与事件队列
讲了这么多,JavaScript 到底是如何处理这些事件的执行顺序的呢。有两个概念,执行栈和任务队列。
执行栈
当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。
同步任务再主线程上排队执行,只有一个任务执行完毕,才能执行下一个任务。执行栈是要给存储函数调用的栈结构,遵循先进后出的原则,它主要负责跟踪所有要执行的代码,每当一个函数执行完成,就会从堆栈中 pop 出该执行完成的函数,如果有代码执行就进行 push 操作。
任务队列
主要用来保存异步任务,遵循先进先出的原则,他将新的异步任务发送到队列中进行处理。
JavaScript 在执行代码时,会将同步的代码按顺序在执行栈中执行,当遇到异步任务时,就将其放入任务队列中,等待当前执行栈所有同步代码执行完成之后,就会从异步任务队列中取出已完成的异步任务的回调并将其放入执行栈中继续执行,如此循环往复,直到执行完所有任务。
其实总结起来就是:
网页加载,执行 JS 脚本(有异步任务)->浏览器渲染->处理异步任务->等待用户交互->处理交互相关的 JS 脚本->渲染...
||
\ /
\/
宏任务->渲染->宏任务->渲染...
宏任务和微任务
任务队列其实不止一种,根据任务种类的不同,可以分为微任务(micro task)队列和宏任务(macro task)队列。常见的任务如下:
-
宏任务: script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)
-
微任务: Promise、MutaionObserver、process.nextTick(Node.js 环境);
注意,promise本身是同步的立即执行函数,.then是异步函数。
宏任务和微任务的区别在于队列中事件的执行优先级,进入整体代码(宏任务)后,开始首次事件循环,当执行栈清空后,事件循环机制会有优先检测微任务队列中的事件并推至主线程执行,而当执行栈再次执行完之后,事件循环机制优惠检测微任务队列,如此往复循环。
如图所示,宏任务的优先级高于微任务,每个宏任务执行完毕后都必须将当前的微任务队列清空。
举个例子:
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
let promise = new Promise((res) => {
console.log(3);
resolve();
})
.then((res) => {
console.log(4);
})
.then((res) => {
console.log(5);
});
console.log(6);
// 1 3 6 4 5 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
- 从上往下执行代码,先执行同步代码,输出
script start。 - 遇到setTimeout,现把 setTimeout 的代码放到宏任务队列中。
- 执行 async1(),输出
async1 start, 然后执行 async2(), 输出async2,把 async2() 后面的代码console.log('async1 end')放到微任务队列中。 - 接着往下执行,输出
promise1,把 .then()放到微任务队列中。 - 接着往下执行, 输出
script end。同步代码(同时也是宏任务)执行完成,接下来开始执行刚才放到微任务中的代码。 - 依次执行微任务中的代码,依次输出
async1 end、promise2, 微任务中的代码执行完成后,开始执行宏任务中的代码,输出setTimeout。
为什么要有宏任务和微任务?
回到最初的问题,为什么要有宏任务和微任务?
根据上面的例子,你应该也看明白了,对于异步来说,不同的异步可以有先后执行顺序,例如promise.then和setTimeout,区分宏任务和微任务,是为了给优先级更高的任务一个插队的机会,因为微任务比宏任务有更高的优先级。因为你不可能所有东西都是照顺序执行的,就像你银行办理业务也得有VIP绿色通道。
参考几个定义:
一个 微任务(microtask)就是一个简短的函数,当创建该函数的函数执行之后,并且 只有当 Javascript 调用栈为空,而控制权尚未返还给被 user agent 用来驱动脚本执行环境的事件循环之前,该微任务才会被执行。 事件循环既可能是浏览器的主事件循环也可能是被一个 web worker 所驱动的事件循环。这使得给定的函数在没有其他脚本执行干扰的情况下运行,也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。 ——MDN
通过使用异步 JavaScript技术(例如承诺)允许主代码在等待请求结果的同时继续运行,进一步缓解了这种情况。但是,在更基础级别上运行的代码(例如包含库或框架的代码)可能需要一种方法来安排代码在安全时间运行,同时仍在主线程上执行,而与任何单个请求的结果或任务。——MDN