原文地址:Tasks, microtasks, queues and schedules
同类文章 这一次,彻底弄懂 JavaScript 执行机制
鉴于上篇文章有提到过 微任务与宏任务,所以,在此做个细致补充: 话不多说,直接进入正文:
- [1] 本文主要根据网上资源总结而来,如有不对,请斧正。
- [2] 需要知道的专业名词术语:synchronous:同步任务、asynchronous:异步任务、task queue/callback queue:任务队列、execution context stack:执行栈、heap:堆、stack:栈、macro-task:宏任务、micro-task:微任务
首先我们要知道两点:
- JavaScript是单线程的语言
- Event Loop是javascript的执行机制
javascript事件循环
js是单线程,就像学生排队上厕所,学生需要排队一个一个上厕所,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:
- 同步任务
- 异步任务
从图片可知,一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制
先看一段代码:
1 | console.log('script start'); |
打印顺序是什么? 正确答案是:script start, script end, promise1, promise2, setTimeout 已蒙圈。。。
为什么会出现这样打印顺序呢?
- 如下导图(此图从网站下载)
解读:
- 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)。
我们不禁要问了,那怎么知道主线程执行栈为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
看代码:
1 | let data = []; |
上面是一段简易的ajax请求代码:
- ajax进入Event Table,注册回调函数success。
- 执行console.log(‘代码执行结束’)。
- ajax事件完成,回调函数success进入Event Queue。
- 主线程从Event Queue读取回调函数success并执行。
相信通过上面的文字和代码,你已经对js的执行顺序有了初步了解。
微任务(Microtasks)、宏任务(task)?
微任务和宏任务皆为异步任务,它们都属于一个队列,主要区别在于他们的执行顺序,Event Loop的走向和取值。那么他们之间到底有什么区别呢?
一个掘金的老哥(ssssyoki)的文章摘要:
那么如此看来我给的答案还是对的。但是js异步有一个机制,就是遇到宏任务,先执行宏任务,将宏任务放入eventqueue,然后在执行微任务,将微任务放入eventqueue最骚的是,这两个queue不是一个queue。当你往外拿的时候先从微任务里拿这个回掉函数,然后再从宏任务的queue上拿宏任务的回掉函数。 我当时看到这我就服了还有这种骚操作。
- 而宏任务一般是:包括整体代码script,setTimeout,setInterval、setImmediate。
- 微任务:原生Promise(有些实现的promise将then方法放到了宏任务中)、process.nextTick、Object.observe(已废弃)、 MutationObserver
- 记住就行了。
- process是什么?
不废话,看以下例子:
setTimeout
大名鼎鼎的setTimeout无需再多言,大家对他的第一印象就是异步可以延时执行,我们经常这么实现延时3秒执行:
1 | setTimeout(() => { |
渐渐的setTimeout用的地方多了,问题也出现了,有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?
1 | setTimeout(() => { |
根据前面我们的结论,setTimeout是异步的,应该先执行console.log这个同步任务,所以我们的结论是:
1 | // 执行console |
去验证一下,结果正确! 然后我们修改一下前面的代码:
1 | setTimeout(() => { |
乍一看其实差不多嘛,但我们把这段代码在chrome执行一下,却发现控制台执行task()需要的时间远远超过3秒,说好的延时三秒,为啥现在需要这么长时间啊? 这时候我们需要重新理解setTimeout
的定义。我们先说上述代码是怎么执行的:
- ask()进入Event Table并注册,计时开始。
- 执行sleep函数,很慢,非常慢,计时仍在继续。
- 3秒到了,计时事件timeout完成,task()进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着。
- sleep终于执行完了,task()终于从Event Queue进入了主线程执行。
上述的流程走完,我们知道setTimeout这个函数,是经过指定时间后,把要执行的任务(本例中为task())加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。
我们还经常遇到setTimeout(fn,0)
这样的代码,0秒后执行又是什么意思呢?是不是可以立即执行呢?
答案是不会的,setTimeout(fn,0)
的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:
1 | //代码1 |
代码1的输出结果是:
1 | 先执行这里 |
代码2的输出结果是:
1 | //先执行这里 |
关于setTimeout
要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML
的标准,最低是4毫秒
。有兴趣的同学可以自行了解。
setInterval
上面说完了setTimeout
,当然不能错过它的孪生兄弟setInterval
。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval
会每隔指定的时间将注册的函数置入Event Queue
,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于setInterval(fn,ms)
来说,我们已经知道不是每过ms
秒会执行一次fn
,而是每过ms
秒,会有fn
进入Event Queue
。一旦setInterval
的回调函数fn
执行时间超过了延迟时间ms
,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。
Promise与process.nextTick(callback)
Promise
的定义和功能本文不再赘述,可以学习一下 阮一峰老师的Promise
而process.nextTick(callback)
类似node.js
版的”setTimeout
“,在事件循环的下一次循环中调用 callback
回调函数。
不同类型的任务会进入对应的Event Queue
,比如setTimeout
和setInterval
会进入相同的Event Queue
。
看例子:
1 | setTimeout(()=>{ |
最后输出结果是Promise1,Promise2,setTimeout1
Promise
参数中的Promise1
是同步执行的 其次是因为Promise
是microtasks
,会在同步任务执行完后会去清空microtasks queues
, 最后清空完微任务再去宏任务队列取值
。
1 | Promise.resolve().then(()=>{ |
这回是嵌套,大家可以看看,最后输出结果是Promise1,setTimeout1,Promise2,setTimeout2
- 一开始执行栈的同步任务执行完毕,会去
microtasks queues
找清空microtasks queues
,输出Promise1
,同时会生成一个异步任务setTimeout1
- 去宏任务队列查看此时队列是
setTimeout1
在setTimeout2
之前,因为setTimeout1
执行栈一开始的时候就开始异步执行,所以输出setTimeout1
- 在执行
setTimeout1
时会生成Promise2的一个microtasks
,放入microtasks queues
中,接着又是一个循环,去清空microtasks queues
,输出Promise2
- 清空完
microtasks queues
,就又会去宏任务队列取一个,这回取的是setTimeout2
如下图:
最后我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:
1 | console.log('1'); |
第一轮事件循环流程分析如下:
- 整体script作为第一个宏任务进入主线程,遇到console.log,输出
1
。 - 遇到
setTimeout
,其回调函数被分发到宏任务Event Queue
中。我们暂且记为setTimeout1
。 - 遇到
process.nextTick()
,其回调函数被分发到微任务Event Queue中。我们记为process1
。 - 遇到
Promise
,new Promise
直接执行,输出7
。then
被分发到微任务Event Queue
中。我们记为then1
。 - 又遇到了
setTimeout
,其回调函数被分发到宏任务Event Queue
中,我们记为setTimeout2
。
宏任务Event Queue | 微任务Event Queue |
---|---|
setTimeout1 | process1 |
setTimeout2 | then1 |
上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1
和7
。
我们发现了process1
和then1
两个微任务。
- 执行
process1
,输出6
。 - 执行
then1
,输出8
。
好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8
。那么第二轮时间循环从setTimeout1
宏任务开始:
- 首先输出
2
。接下来遇到了process.nextTick()
,同样将其分发到微任务Event Queue
中,记为process2
。 new Promise
立即执行输出4
,then
也分发到微任务Event Queue
中,记为then2
宏任务Event Queue | 微任务Event Queue |
---|---|
setTimeout2 | process3 |
then3 |
- 第三轮事件循环宏任务执行结束,执行两个微任务process3和then3。
- 输出10。
- 输出12。
- 第三轮事件循环结束,第三轮输出9,11,10,12。
- 整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)
作者:张倩qianniuer
链接:https://juejin.im/post/5b498d245188251b193d4059
来源:掘金