背景
大概是大半年前,我在微信上看到了一篇前端文章。文章开头就拿了一道面试题,让读者写出答案。题目乍看上去不难,都是console.log()的代码,唯一让我觉得有猫腻的是多了一些setTimeout和Promise。我也没想太多,信心满满地就开始做题了。果不其然,和正确答案南辕北辙。看作者后续的解释,才第一次接触到了宏任务与微任务这两个概念。当时理解了一点,知道这两个概念都是用于区分异步任务的。随后就过了大半年,直到前几天又看了一道题。答案差了一半,脑海里对于宏任务和微任务的理解又变得模糊了。反思了一下,还是当初对于知识的理解就不深刻,没有化为自己的东西。这也就是基础不扎实的一个表现吧。
在掘金上看了不少同行的文章,但都不是自己的理解,在此总结一下自己的理解。
JS的特征:单线程
在说宏任务和微任务之前,先说一下一些基础的概念。JavaScript是1995年诞生的,最初的目的是赋予网页生命,在网页上执行一些逻辑,增加用户与网页的交互,让网页变得更加有「智能」。因为最初的目的很简单,所以注定了JS并不复杂,也不允许很复杂。在这个基础上,JS的一个特质就凸显出来了,那就是单线程。
单线程是什么意思呢?意思就是JS引擎一次只能处理一件事情,如果有多件事情需要处理,那么JS引擎会一件一件的来。我们可以认为JS引擎就像郭靖一样,憨憨的,学不会周伯通的左右互搏。既然有单线程语言,那肯定也有多线程语言了。没错,基本上后端语言都是多线程的,就像黄蓉一样,一心多用,古灵精怪。
这个世界是客观的,得到了一些东西相应地也会失去一些东西。个中好坏都凭自己去判断与接受。单线程的JS带来的好处就是简单,不会出现多个线程同时修改DOM的情况,那样网页将会变得一团糟。
其实,JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:
- JS引擎线程
- 事件触发线程
- 定时触发器线程
- 异步http请求线程
- GUI渲染线程
同步和异步事件
这里讲一下同步和异步,这两个概念是脱离编程语言的。映射的是现实世界中的情况。同步就是马上就能完成的事情,异步就是一时半会不能完成的事情。
举个例子,打开课本,这就是一个同步事件。你马上就能做到,没有丝毫的延迟和不确定性。再看水壶烧水,这是一个异步事件。因为你不能控制水烧开,只能等待水被烧开。对比了一下,异步事件比同步事件多了「不确定性」,或者说引入了「变量」。反映到代码上,下面的第一行代码就是同步代码,直接打印出1。第二行代码定时器 setTimeout,是一个异步事件,因为需要等待一个设置好的时间200ms才能完成打印。即使这个时间是0,也会被JS引擎归入异步事件。当然了,ajax请求更是典型的异步事件,需要等待服务器的响应。
console.log(1);
setTimeout(() => {
console.log(2);
},200);
异步事件中有一个回调函数的概念,因为异步事件不是一时半会就能完成了。那么当异步事件完成之后,需要做的事情是什么呢?好比上面说的水壶烧水,水开了之后,我要喝水,这就是一个回调逻辑。也可以等水开了,我要洗衣服,这也是一个回调逻辑。所以说异步事件,需要注册一个回调函数,表示异步事件完成之后需要做的处理。那么这里隐藏了一个问题,那就是谁在异步事件完成之后通知JS引擎呢?答案是浏览器的线程,负责监听异步事件的完成。
最开始说了JS是单线程语言,一次只能做一件事,那么碰到同步事件就很简单了,不废话,直接做。但是遇到异步事件,就有点复杂了,JS引擎需要考虑以下几个问题:
- 碰到异步事件怎么做?
- 如何知道异步事件已完成?
- 如果有多个异步事件,先执行哪个?
为了解决异步事件的执行顺序问题,JS引擎产生了一个机制,那就是Event Loop(事件循环)。通过这个机制,JS引擎对异步事件进行处理,一次处理一件。
事件循环(Event Loop)
首先我们要明确的是在最高层级,同步事件先于异步事件执行。那么如何执行多个异步事件,就需要「事件循环」的机制来处理多个异步事件的先后顺序。
解读:
- 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)
我们不禁要问了,那怎么知道主线程执行栈为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
宏任务和微任务
如上面的流程图所示,异步任务会首先进入Event Table并注册了回调函数,当指定的事情完成时,Event Table会将这个函数移入Event Queue。问题来了,不同的异步事件,会进入不同的Event Queue。这里就进一步把异步事件划分为宏任务和微任务。
举个例子,去银行办理业务,首先需要取号等到叫到自己。一般上边都会印着类似:“您的号码为XX,前边还有XX人。”之类的字样。因为一个窗口的柜员同时只能处理一个来办理业务的客户,对于柜员来说,每一个来办理业务的客户都是一个宏任务。当柜员处理完当前客户的问题以后,选择接待下一位,广播叫号,也就是下一个宏任务的开始。类比Excel中的「宏」的概念,这里的「宏」有着整体的概念。
所以多个宏任务合在一起就可以说一个任务队列在这,队列里是当前银行中所有排号的客户。任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中,就像在银行中排号,如果叫到你的时候你不在,那么你当前的号牌就作废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来以后还需要重新取号。 而且一个宏任务在执行的过程中,是可以添加一些微任务的,就像在柜台办理业务,你前面的一位老大爷可能在存款,在存款这个业务办理完之后,柜员询问大爷还有没有其他要办的业务。大爷说像办理理财,那么柜员会继续为大爷办理理财业务,而不会直接办理下一位客户的业务。所以本来快轮到你来办理业务,会因为老大爷临时添加的“理财业务”而往后推。也许老大爷在办完理财以后还想 再办一个信用卡?或者 再买点儿纪念币?无论是什么需求,只要是柜员能够帮她办理的,都会在处理你的业务之前来做这些事情,这些都可以认为是微任务。
这里就说明了同一批次的微任务上下文是相同的,依赖于当前的宏任务。 所以当一个宏任务完成后,主线程会询问有没有微任务需要处理,只能处理完了当前所有的微任务,才会开始下一个宏任务。
下面看一下我之前弄错了的题目:
console.log(1)
setTimeout(()=>{console.log(2)},1000)
async function fn(){
console.log(3)
setTimeout(()=>{console.log(4)},20)
return Promise.reject()
}
async function run(){
console.log(5)
await fn()
console.log(6)
}
run()
//需要执行150ms左右
for(let i=0;i<90000000;i++){}
setTimeout(()=>{
console.log(7)
new Promise(resolve=>{
console.log(8)
resolve()
}).then(()=>{console.log(9)})
},0)
console.log(10)
// 1 5 3 10 4 7 8 9 2
在做这道题之前需要明确几个概念:
- 基于微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术,本题中resolve()、await fn()都是微任务。
- Promise中then里面注册的回调是异步的,但是Promise本身是同步的。也就是说
new Promise
在实例化的过程中所执行的代码都是同步进行的,而then
中注册的回调才是异步执行的。 - 不管宏任务是否到达时间,以及放置的先后顺序,每次主线程执行栈为空的时候,引擎会优先处理微任务队列,处理完微任务队列里的所有任务,再去处理宏任务。
- 所有会进入的异步都是指的事件回调中的那部分代码
接下来我们一步一步分析:
-
第一行代码
console.log(1)
是同步任务,直接打印出1
。 -
第二行代码碰到了setTimeout,异步任务并且属于宏任务。首先将setTimeout放进Event Table,并注册回调函数,在1000ms之后,再将回调函数放入宏任务队列。所以此时宏任务队列暂时为空
Event Table 宏任务队列 微任务队列 setTimeout1(1000ms) 空 空 -
接下来是执行run(),虽然函数run,前使用了async关键词,表示内部有异步事件。但是不影响函数其他同步代码执行。代码
console.log(5)
执行,打印5
。然后碰到了关键词await
,继续执行函数fn,代码console.log('3')
执行,打印3
。这时又碰到了setTiemout,将其放入Event Table中。再看,setTimeout后面的Promise.reject属于微任务,将其放入微任务队列。Event Table 宏任务队列 微任务队列 setTimeout1(1000ms) 空 Promise.reject() setTimeout2(20ms) 空 空
这里需要注意的是:由于函数fn没有执行完成,awit fn()后面的代码是不会执行的,浏览器会继续执行后面的for循环。
-
然后执行for循环,由于for循环属于同步任务,这个时候主线程的工作就是执行for循环逻辑,即使循环里面为空,也需要花费150ms。当150ms之后,for循环执行完成,此时Event Table里面的20ms的setTimeout也已经到了时间,回调函数开始被放入宏任务队列了。
Event Table 宏任务对列 微任务队列 setTimeout1(1000ms) setTimeout2(20ms) Promise.reject() 空 空 空 -
主线程继续执行代码,又又又碰到了setTimeout,这个时候依旧放入Event Table中。
-
接着执行代码
console.log(10)
,打印10
。本次宏任务结束,本次大的脚本视为一次宏任务。 -
宏任务结束后,主线程询问是否有微任务需要执行,此时微任务中存在
Promise.reject()
,执行这个任务。然后函数run中的await fn()
完成,注意因为await右边的Promise返回的是reject,所以后面的代码都不会执行。微任务执行完成。Event Table 宏任务队列 微任务队列 setTimeout1(1000ms) setTimeout2(20ms) 空 空 setTimeout3(0ms) 空 -
下面开始下一次的宏任务,执行setTimeout2的回调函数,请注意进入任务队列的都是已经完成的异步事件的回调函数。setTimeout2的回调函数开始执行,
console.log(4)
打印出4
。Event Table 宏任务队列 微任务队列 setTimeout1(1000ms) setTimeout3(0ms) 空 空 空 空 -
下面继续执行下一个宏任务setTimeout3的回调函数。
console.log(7)
打印7
。然后碰到new Promise
,执行同步代码console.log(8)
打印8
。将then
里注册的回调函数放入微任务队列。Event Table 宏任务队列 微任务队列 setTimeout1(1000ms) 空 then -
本次宏任务执行结束,开始执行微任务,
console.log(9)
执行,打印9
。 -
此时如果时间还没有到1000ms,那么需要等待时间完成后,将setTimeout1放入宏任务队列中。
Event Table 宏任务队列 微任务队列 空 setTimeout1(1000ms) 空 -
最后执行宏任务setTimeout1,
console.log(2)
执行,打印2
。到此,代码全部执行完毕。
实际例题
- 题目一
const myPromise = () => Promise.resolve('1')
const myPromise2 = () => Promise.resolve('2')
function firstFunction() {
myPromise().then(res => console.log(res))
console.log('3')
}
async function secondFunction() {
console.log(await myPromise2())
console.log('4')
}
firstFunction()
secondFunction()
- 题目二
function test() {
setTimeout(() => {
Promise.resolve(4).then(res => { console.log(res); })
Promise.resolve(7).then(res => { console.log(res); })
setTimeout(() => {
console.log(3);
})
})
setTimeout(() => {
Promise.resolve(9).then(res => { console.log(res); })
})
Promise.resolve(1).then(res => { console.log(res); })
Promise.resolve(2).then(res => { console.log(res); })
}
test()
- 题目三
function test() {
setTimeout(() => {
Promise.resolve(4).then(res => { console.log(res); })
Promise.resolve(7).then(res => { console.log(res); })
setTimeout(() => {
console.log(3);
}, 400)
}, 400)
setTimeout(() => {
Promise.resolve(9).then(res => { console.log(res); })
})
Promise.resolve(1).then(res => { console.log(res); })
Promise.resolve(2).then(res => { console.log(res); })
}
test()