18

背景

在研究js的异步的实现方式的时候,发现了JavaScript 中的 macrotask 和 microtask 的概念。在查阅了一番资料之后,对其中的执行机制有所了解,下面整理出来,希望可以帮助更多人。

先了解一下js的任务执行机制

首先,javascript是单线程的,所以只能通过异步解决性能问题(否则,如果前面一个任务阻塞了,那么后续的任务都要等待,这种效果是无法接受的)。js在执行代码时存在着两个比较重要的东西:执行栈和任务队列,这两个东西都是用来存储任务的,区别在于:执行栈里面存着的都是同步任务,也就是要按顺序执行的任务;而任务队列中存着的是一些异步任务,这些异步任务一定要等到执行栈清空后才会执行(这句话很重要)。关于任务队列,它还分成两种,一种叫作macrotask queue(姑且这么命名,因为严格来说规范中只有说task,并没有提到macrotask这个概念。这里为了容易区分,可以理解为macrotask=task!=microtask),另一种叫作microtask queue。如果同时考虑node环境和浏览器环境的话,这两种任务分别对应以下api:
microtasks:

  • process.nextTick
  • promise
  • Object.observe
  • MutationObserver

macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI渲染
  • script标签中的整体代码

javascript在执行时,先从 macrotasks 队列开始执行,取出第一个 macrotask 放入执行栈执行,在执行过程中,如果遇到 macrotask,则将该 macrotask 放入 macrotask 队列,继续运行执行栈中的后续代码。如果遇到microtask,那么将该microtask放入microtask队列,继续向下运行执行栈中的后续代码。当执行栈中的代码全部执行完成后,从microtasks队列中取出所有的microtask放入执行栈执行。执行完毕后,再从macrotasks 队列取出下一个macrotask放入执行栈。然后不断重复上述流程。这一过程也被称作事件循环(Event Loop)。
javascript就是通过这种机制来实现异步的。主线程会暂时存储I/O等异步操作,直接向下执行,当某个异步事件触发时,再通知主线程执行相应的回调函数,通过这种机制,javascript避免了单线程中异步操作耗时对后续任务的影响。

图解事件循环流程

clipboard.png

根据图中描述,一次事件循环的执行步骤如下:
1、从macrotask queue中取出最早的任务
2、在执行栈中执行第一步取出的任务
如果任务中存在microtask,将其压入到microtask queue中
如果任务中存在macrotask,将其压入到macrotask queue中
直到执行完毕
3、执行栈设置为null
4、从macrotask queue中删除执行过的macrotask
5、取出microtask queue中的全部任务,放入执行栈,
如果任务中存在microtask,将其压入到microtask queue中
如果任务中存在macrotask,将其压入到macrotask queue中
注意:这里产生的microtask(也就是microtask产生的microtask )也会在这一步骤中执行。
直到当前microtask queue为空,此步骤结束。
6、执行第一步的操作

实例验证

我们执行如下一段代码,用上面的思路执行,看一下结果是否和预期的一致。

console.log('start')

const interval = setInterval(() => {  
  console.log('setInterval')
}, 0)

setTimeout(() => {  
  console.log('setTimeout 1')
  Promise.resolve()
      .then(() => {
        console.log('promise 3')
      })
      .then(() => {
        console.log('promise 4')
      })
      .then(() => {
        setTimeout(() => {
          console.log('setTimeout 2')
          Promise.resolve()
              .then(() => {
                console.log('promise 5')
              })
              .then(() => {
                console.log('promise 6')
              })
              .then(() => {
                clearInterval(interval)
              })
        }, 0)
      })
}, 0)

Promise.resolve()
    .then(() => {  
        console.log('promise 1')
    })
    .then(() => {
        console.log('promise 2')
    })

按照上面的思路,我们来理一下,预测一下执行结果,看看实际效果是否是这样的。
执行流程:
第一轮:
1、首先这一整段js代码作为一个macrotask先被执行
2、遇到console.log('start'),输出start
3、遇到setInterval,回调函数作为macrotask压入到macrotask queue中,
此时macrotask queue:[setInterval]
4、遇到setTimeout,回调函数作为macrotask压入到macrotask queue中,
此时macrotask queue:[setInterval,setTimeout1]
5、遇到Promise,并且调用了resolve方法,触发了回调,回调作为microtask压入到microtask queue中
此时microtask queue:[promise 1,promise 2]
6、执行栈为空,将microtask queue中的任务放入执行栈
7、执行microtask queue中Promise的回调任务,分别打印promise 1,promise 2
8、执行栈为空,microtask queue为空,开始下一轮事件循环
目前的console中打印内容:
start
promise 1
promise 2
目前macrotask queue:[setInterval,setTimeout1]

第二轮:
1、从macrotask queue中取出最早的任务,这里对应的是第一轮中第3步的回调函数:console.log('setInterval'),输出setInterval
2、setInterval的回调函数作为macrotask压入到macrotask queue中
此时macrotask queue:[setTimeout1,setInterval]
3、执行栈为空,microtask queue为空,开始下一轮事件循环
目前的console中打印内容:
start
promise 1
promise 2
setInterval
目前macrotask queue:[setTimeout1,setInterval]

第三轮:
1、从macrotask queue中取出最早的任务,目前是setTimeout1的回调,将取出的任务放入执行栈执行
2、遇到console.log('setTimeout 1'),输出setTimeout 1
3、遇到Promise,并且调用了resolve方法,触发回调,回调作为microtask压入到microtask queue中
此时microtask queue:[promise 3,promise 4,() => {setTimeout 2}]
4、执行栈为空,将microtask queue中的任务放入执行栈
5、执行microtask queue中Promise的回调任务:
输出promise 3
输出promise 4
将setTimeout 2压入macrotask queue
6、执行栈为空,microtask queue为空,开始下一轮事件循环
目前的console中打印内容:
start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
目前macrotask queue:[setInterval,setTimeout2]

第四轮:
1、从macrotask queue中取出最早的任务,这里对应的是setInterval,输出setInterval
2、setInterval的回调函数作为macrotask压入到macrotask queue中
此时macrotask queue:[setTimeout2,setInterval]
3、执行栈为空,microtask queue为空,开始下一轮事件循环
目前的console中打印内容:
start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
目前macrotask queue:[setTimeout2,setInterval]

第五轮:
1、从macrotask queue中取出最早的任务,目前是setTimeout2的回调,将取出的任务放入执行栈执行
2、遇到console.log('setTimeout 2')输出setTimeout 2
3、遇到Promise,并且调用了resolve方法,触发回调,回调作为microtask压入到microtask queue中
此时microtask queue:[promise 5,promise 6,() => {clearInterval}]
4、执行栈为空,将microtask queue中的任务放入执行栈
5、执行microtask queue中Promise的回调任务:
输出promise 5
输出promise 6
clearInterval清空setInterval计时器
6、执行栈为空,microtask queue为空,macrotask queue为空,任务结束。
最终的console中打印内容:
start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
promise 5
promise 6

通过图片可以看到,结果跟我们的预期一致,在promise2的后面作为方法的返回值,多打印了一个undefined,这个应该好理解的。

clipboard.png图片描述

这里面有个小问题,就是在不同的环境下(node/浏览器),promise4后面的setInterval表现可能会有差异,这里可能跟setTimeout和setInterval的最小间隔有关,虽然我们写成0ms,但实际上这个最小值是有限制的,现阶段不同组织和不同的js引擎实现机制存在差异,不过这个问题不在本次讨论范围之内了。如果我们将上述代码中setInterval的间隔设置为10,那么整个执行流程将严格符合我们的预期。

有什么用?

  • 后续我们在代码中使用Promise,setTimeout时,思路将更加清晰,用起来更佳得心应手。
  • 在阅读一些源码时,对于一些setTimeout相关的骚操作可以理解的更加深入。
  • 理解javascript中的任务执行流程,加深对异步流程的理解,少犯错误。

总结

  • js事件循环总是从一个macrotask开始执行
  • 一个事件循环过程中,只执行一个macrotask,但是可能执行多个microtask
  • 执行栈中的任务产生的microtask会在当前事件循环内执行
  • 执行栈中的任务产生的macrotask要在下一次事件循环才会执行

水电
534 声望33 粉丝