我们常说 JavaScript 是单线程执行的,指的其实是一个进程里只有一个主线程。也就是说 JS 引擎在同一时间内只能做一件事情(任务)。任务分为同步任务和异步任务,同步任务立即执行,异步任务满足某些条件执行。
进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位。
举个例子:假设我们打开一个浏览器,在浏览器中开了若干个 tab,在某个 tab 中看资料,在另外一个 tab 中写笔记;这些 tab 无不干扰,对于我们一般人来说,我们在同一时间内只能做一件事,看资料或者写笔记。我们就可以把浏览器比作一个“进程”,tab 比作一个“线程”,我们一般人就是一个“ JS 引擎”,看资料和写笔记分别可以看成是两个“任务”,这就很好地类比了 js 的单线程执行过程。
js 是单线程的,这在一般情况下没什么问题,但当我们去加载一个网页时,假如有一张图片,下载需要30秒钟,我们就需要等30秒后这张图片下载完成之后再去做其他的任务,这显然是不合理的。好在,浏览器提供了一些 js 引擎不具备的特性,DOM API、定时器、HTTP请求等,可以用来实现异步、非阻塞的行为。
在 ES3 及更早的版本中,js 引擎只能用来执行任务。也就是说,当宿主环境(如浏览器或者 node )拿到一段代码后,js 引擎执行代码的内容和次序完全是由宿主环境来决定的。在 ES5 之后,js 引入了 Promise 这个新特性,使得 js 引擎也可以自己发起任务了。我们把 js 引擎发起的任务称为微任务,把宿主环境发起的任务称为宏任务。主要是以下几种:
- 宏任务:整体代码的 script、setTimeout、setInterval
- 微任务:Promise.then、MutationObserver、process.nextTick(node)
这里需要注意的是 Promise 的 then 回调才是微任务,而 new Promise 时,传入的函数是立即执行的
js 引擎和宿主环境都能发起任务,而执行任务的只有 js 引擎,那么,js 引擎是按什么样的规则来处理宏任务和微任务的呢?这个次序是由事件循环来安排的。
在讲事件循环之前,先介绍几个概念:执行栈、任务队列。
- 执行栈是一个存储任务调用的栈结构。当 js 执行时,会把任务压到一个栈的结构之中,后进先出,执行完函数后弹出。
- 任务队列是一个储存异步任务的队列结构。当 js 引擎遇到异步的任务时,就会把这个任务放到任务队列中;先进先出的特点,执行完后弹出。任务队列分成两种,一种是宏任务队列,储存宏任务,另外一种是微任务队列,储存微任务。
事件循环
在浏览器中,执行 js 的函数(任务),是由 js引擎的“执行栈”负责的;当 js 引擎接收到一段代码时,就会从上到下解析代码,把同步任务按顺序放到执行栈中去执行。如果遇到异步任务,就会把异步任务先放到任务队列中挂起,等到同步任务执行完成后(也就是执行栈被清空了),再从任务队列中取出一个任务,压到执行栈中去执行。等到执行栈再次被清空,js 引擎就会再去检查任务队列中是否有任务等待执行,如果有则继续压入栈中执行,如此往复,直到任务队列被清空。这个过程,就是事件循环。
在事件循环中的任务队列有宏任务队列和微任务队列,那么这两个任务队列的执行顺序又是怎么样的呢?
在事件循环中,js 执行栈执行任务的顺序是:
- 执行同步任务;
- 遇到异步任务就把他们放到对应的队列中,即:宏任务队列和微任务队列;
- 继续执行同步任务,如果遇到异步任务执行 2 ,直至同步任务全部执行完(js 执行栈被清空);
- 执行并清空微任务队列;在执行微任务的过程中,如果遇到异步任务,应该把它们分别再放入不同的任务队列中;而后依次取出微任务,并执行,直至微任务队列为空。
- 执行并清空宏任务队列和微任务队列;逐个执行宏任务,在执行宏任务时,如果遇到异步任务,也是要把它们分别放入不同的任务队列中的;直到这一个宏任务完成后,再去检查微任务队列是否为空,不为空则先执行 4 ,直至宏任务队列和微任务队列都为空。
需要注意的是,宏任务总是一个一个执行的,而微任务总是一队一队执行的,也就是说,宏任务执行完完整的一个任务之后,就需要去检查一下微任务的队列是否有任务未被执行;而微任务是直至把微任务的队列清空了,才会去检查宏任务队列是否为空。
talk is cheep, show me the code
第一个 :一个宏任务和一个微任务
// 一个同步任务
console.log(1)
// 一个宏任务
setTimeout(() => {
console.log(2)
}, 0)
// 一个微任务
Promise.resolve().then(()=>{
console.log(3)
})
按我们上文提到的规则,上述代码的执行顺序应该是:
- 执行同步任务:
console.log(1)
; - 遇到宏任务,放到宏任务队列中,继续;
- 遇到微任务,放到微任务队列中,继续;
- 没有同步任务了,检查微任务队列;
- 微任务队列中有
Promise.resolve().then(()=>{ console.log(3) })
,执行console.log(3)
,微任务队列清空; - 检查宏任务队列,有
setTimeout(() => {console.log(2)}, 0)
,执行console.log(3)
;一个宏任务执行完毕 - 检查微任务队列,无任务;
- 检查宏任务队列,无任务;结束。
所以输出的结果是:1、3、2,这个结果和 setTimeout 、Promise 还有同步代码的书写顺序并没有关系
这就是一个完整且详尽的事件循环的思路了,下面会举一些更为复杂的例子,按这个思路,基本上就能得出正确的代码执行顺序了。
第二个 :宏任务和微任务相互嵌套
// 微任务1
Promise.resolve().then(()=>{
console.log('1')
// 微任务1里的宏任务1
setTimeout(()=>{
console.log('2')
},0)
})
// 宏任务2
setTimeout(()=>{
console.log('3')
// 宏任务2里的微任务2
Promise.resolve().then(()=>{
console.log('4')
})
},0)
这个例子相对来说比较复杂,但是按上面的思路来,依然可以获得正确的代码执行顺序:
- 遇到微任务1,放入微任务队列;此时的微任务队列为:微任务1;
- 遇到宏任务2,放入宏任务队列;此时的宏任务队列为:宏任务2;
- 同步代码执行完毕,检查微任务队列;
- 取出微任务1,执行
console.log('1')
,遇到宏任务1,放入宏任务队列,此时的微任务队列和宏任务队列分别为: - 微任务队列:空
- 宏任务队列:宏任务2、宏任务1
- 微任务队列为空,执行第一个宏任务2
console.log('3')
; - 遇到微任务2,放入微任务队列,执行结束;此时的队列为:
- 微任务队列:微任务2
- 宏任务队列:宏任务1
- 执行完一个宏任务后,检查微任务队列,不为空;取出第一个微任务2,执行
console.log('4')
;清空微任务队列; - 执行宏任务队列的一个宏任务1,执行
console.log('2')
;两个队列均为空;结束。
所以,上面代码的运行结果就是:1、3、4、2。
这个例子很好地说明了,宏任务是执行完一个,就会去检查微任务队列是否为空,而微任务是直到清空微任务队列,才会切检查宏任务队列是否为空的。
再看个 确认下
// 微任务1
Promise.resolve().then(()=>{
console.log(1)
// 宏任务1
setTimeout(()=>{
console.log(2)
},0)
// 微任务2
Promise.resolve().then(()=>{
console.log(3)
})
})
// 宏任务2
setTimeout(()=>{
console.log(4)
// 微任务3
Promise.resolve().then(()=>{
console.log(5)
})
// 假设这里用的是 new Promise 的写发的话,结果会是怎么样呢
//new Promise(resolve => {
// console.log(5);
// resolve();
//}).then(data => {
// console.log(6);
//})
},0)
这里就不做具体分析了,写下任务队列的变化顺序吧:
- 微任务队列:空
- 宏任务队列:空
===
- 微任务队列:微任务1
- 宏任务队列:宏任务2
===
- 微任务队列:微任务2
- 宏任务队列:宏任务2、宏任务1
===
- 微任务队列:空
- 宏任务队列:宏任务2、宏任务1
===
- 微任务队列:微任务3
- 宏任务队列:宏任务1
===
- 微任务队列:空
- 宏任务队列:宏任务1
===
- 微任务队列:空
- 宏任务队列:空
===
所以,上述例子的任务执行顺序就应该是:微任务1、微任务2、宏任务2、微任务3、宏任务1;
输出结果是:1、3、4、5、2;
如果是用 new Promise 的写法的话,输出的结果就是:1、3、4、5、6、2;这正是因为 new Promise 是立即执行的
总结
- JavaScript 是单线程执行的
- 任务分为同步任务和异步任务;异步任务可以由宿主环境发起(宏任务)或者由 js 引擎发起(微任务)
- 宏任务包括:整体代码的 script、setTimeout、setInterval
- 微任务包括:Promise.then、MutationObserver、process.nextTick(node)
- 事件循环就是不断检查任务队列是否还有任务,如果有,就放进执行栈中执行,执行栈清空后再检查任务队列是否有任务,如此反复的过程
- 宏任务队列和微任务队列是跟随代码的执行动态更新的
- 在事件循环中,执行“一个”宏任务的前提是微任务队列里没有微任务了