微任务、宏任务与Event-Loop

首先,JavaScript是一个单线程的脚本语言。javascript

因此就是说在一行代码执行的过程当中,必然不会存在同时执行的另外一行代码,就像使用alert()之后进行疯狂console.log,若是没有关闭弹框,控制台是不会显示出一条log信息的。css

亦或者有些代码执行了大量计算,比方说在前端暴力破解密码之类的鬼操做,这就会致使后续代码一直在等待,页面处于假死状态,由于前边的代码并无执行完。html

因此若是所有代码都是同步执行的,这会引起很严重的问题,比方说咱们要从远端获取一些数据,难道要一直循环代码去判断是否拿到了返回结果么?就像去饭店点餐,确定不能说点完了之后就去后厨催着人炒菜的,会被揍的。前端

因而就有了异步事件的概念,注册一个回调函数,好比说发一个网络请求,咱们告诉主程序等到接收到数据后通知我,而后咱们就能够去作其余的事情了。java

而后在异步完成后,会通知到咱们,可是此时可能程序正在作其余的事情,因此即便异步完成了也须要在一旁等待,等到程序空闲下来才有时间去看哪些异步已经完成了,能够去执行。node

好比说打了个车,若是司机先到了,可是你手头还有点儿事情要处理,这时司机是不可能本身先开着车走的,必定要等到你处理完事情上了车才能走。git

微任务与宏任务的区别

这个就像去银行办业务同样,先要取号进行排号。
通常上边都会印着相似:“您的号码为XX,前边还有XX人。”之类的字样。github

由于柜员同时职能处理一个来办理业务的客户,这时每个来办理业务的人就能够认为是银行柜员的一个宏任务来存在的,当柜员处理完当前客户的问题之后,选择接待下一位,广播报号,也就是下一个宏任务的开始。
因此多个宏任务合在一块儿就能够认为说有一个任务队列在这,里边是当前银行中全部排号的客户。
任务队列中的都是已经完成的异步操做,而不是说注册一个异步任务就会被放在这个任务队列中,就像在银行中排号,若是叫到你的时候你不在,那么你当前的号牌就做废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来之后还须要从新取号web

并且一个宏任务在执行的过程当中,是能够添加一些微任务的,就像在柜台办理业务,你前边的一位老大爷可能在存款,在存款这个业务办理完之后,柜员会问老大爷还有没有其余须要办理的业务,这时老大爷想了一下:“最近P2P爆雷有点儿多,是否是要选择稳一些的理财呢”,而后告诉柜员说,要办一些理财的业务,这时候柜员确定不能告诉老大爷说:“您再上后边取个号去,从新排队”。
因此原本快轮到你来办理业务,会由于老大爷临时添加的“理财业务”而日后推。
也许老大爷在办完理财之后还想 再办一个信用卡?或者 再买点儿记念币
不管是什么需求,只要是柜员可以帮她办理的,都会在处理你的业务以前来作这些事情,这些均可以认为是微任务。面试

这就说明:你大爷永远是你大爷
在当前的微任务没有执行完成时,是不会执行下一个宏任务的。

因此就有了那个常常在面试题、各类博客中的代码片断:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)
复制代码

setTimeout就是做为宏任务来存在的,而Promise.then则是具备表明性的微任务,上述代码的执行顺序就是按照序号来输出的。

全部会进入的异步都是指的事件回调中的那部分代码
也就是说new Promise在实例化的过程当中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
在同步代码执行完成后才回去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务以前执行。
因此就获得了上述的输出结论一、二、三、4

+部分表示同步执行的代码

+setTimeout(_ => {
- console.log(4)
+})

+new Promise(resolve => {
+ resolve()
+ console.log(1)
+}).then(_ => {
- console.log(3)
+})

+console.log(2)
复制代码

原本setTimeout已经先设置了定时器(至关于取号),而后在当前进程中又添加了一些Promise的处理(临时添加业务)。

因此进阶的,即使咱们继续在Promise中实例化Promise,其输出依然会早于setTimeout的宏任务:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)
复制代码

固然了,实际状况下不多会有简单的这么调用Promise的,通常都会在里边有其余的异步操做,好比fetchfs.readFile之类的操做。
而这些其实就至关于注册了一个宏任务,而非是微任务。

P.S. 在Promise/A+的规范中,Promise的实现能够是微任务,也能够是宏任务,可是广泛的共识表示(至少Chrome是这么作的),Promise应该是属于微任务阵营的

因此,明白哪些操做是宏任务、哪些是微任务就变得很关键,这是目前业界比较流行的说法:

宏任务

# 浏览器 Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

有些地方会列出来UI Rendering,说这个也是宏任务,但是在读了HTML规范文档之后,发现这很显然是和微任务平行的一个操做步骤
requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrameMDN的定义为,下次页面重绘前所执行的操做,而重绘也是做为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行

微任务

# 浏览器 Node
process.nextTick
MutationObserver
Promise.then catch finally

Event-Loop是个啥

上边一直在讨论 宏任务、微任务,各类任务的执行。
可是回到现实,JavaScript是一个单进程的语言,同一时间不能处理多个任务,因此什么时候执行宏任务,什么时候执行微任务?咱们须要有这样的一个判断逻辑存在。

每办理完一个业务,柜员就会问当前的客户,是否还有其余须要办理的业务。(检查还有没有微任务须要处理)
而客户明确告知说没有事情之后,柜员就去查看后边还有没有等着办理业务的人。(结束本次宏任务、检查还有没有宏任务须要处理)
这个检查的过程是持续进行的,每完成一个任务都会进行一次,而这样的操做就被称为Event Loop(这是个很是简易的描述了,实际上会复杂不少)

并且就如同上边所说的,一个柜员同一时间只能处理一件事情,即使这些事情是一个客户所提出的,因此能够认为微任务也存在一个队列,大体是这样的一个逻辑:

const macroTaskList = [
  ['task1'],
  ['task2', 'task3'],
  ['task4'],
]

for (let macroIndex = 0; macroIndex < macroTaskList.length; macroIndex++) {
  const microTaskList = macroTaskList[macroIndex]
  
  for (let microIndex = 0; microIndex < microTaskList.length; microIndex++) {
    const microTask = microTaskList[microIndex]

    // 添加一个微任务
    if (microIndex === 1) microTaskList.push('special micro task')
    
    // 执行任务
    console.log(microTask)
  }

  // 添加一个宏任务
  if (macroIndex === 2) macroTaskList.push(['special macro task'])
}

// > task1
// > task2
// > task3
// > special micro task
// > task4
// > special macro task
复制代码

之因此使用两个for循环来表示,是由于在循环内部能够很方便的进行push之类的操做(添加一些任务),从而使迭代的次数动态的增长。

以及还要明确的是,Event Loop只是负责告诉你该执行那些任务,或者说哪些回调被触发了,真正的逻辑仍是在进程中执行的。

在浏览器中的表现

在上边简单的说明了两种任务的差异,以及Event Loop的做用,那么在真实的浏览器中是什么表现呢?
首先要明确的一点是,宏任务必然是在微任务以后才执行的(由于微任务其实是宏任务的其中一个步骤)

I/O这一项感受有点儿笼统,有太多的东西均可以称之为I/O,点击一次button,上传一个文件,与程序产生交互的这些均可以称之为I/O

假设有这样的一些DOM结构:

<style> #outer { padding: 20px; background: #616161; } #inner { width: 100px; height: 100px; background: #757575; } </style>
<div id="outer">
  <div id="inner"></div>
</div>
复制代码
const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')

function handler () {
  console.log('click') // 直接输出

  Promise.resolve().then(_ => console.log('promise')) // 注册微任务

  setTimeout(_ => console.log('timeout')) // 注册宏任务

  requestAnimationFrame(_ => console.log('animationFrame')) // 注册宏任务

  $outer.setAttribute('data-random', Math.random()) // DOM属性修改,触发微任务
}

new MutationObserver(_ => {
  console.log('observer')
}).observe($outer, {
  attributes: true
})

$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)
复制代码

若是点击#inner,其执行顺序必定是:click -> promise -> observer -> click -> promise -> observer -> animationFrame -> animationFrame -> timeout -> timeout

由于一次I/O建立了一个宏任务,也就是说在此次任务中会去触发handler
按照代码中的注释,在同步的代码已经执行完之后,这时就会去查看是否有微任务能够执行,而后发现了PromiseMutationObserver两个微任务,遂执行之。
由于click事件会冒泡,因此对应的此次I/O会触发两次handler函数(一次在inner、一次在outer),因此会优先执行冒泡的事件(早于其余的宏任务),也就是说会重复上述的逻辑。
在执行完同步代码与微任务之后,这时继续向后查找有木有宏任务。
须要注意的一点是,由于咱们触发了setAttribute,实际上修改了DOM的属性,这会致使页面的重绘,而这个set的操做是同步执行的,也就是说requestAnimationFrame的回调会早于setTimeout所执行。

一些小惊喜

使用上述的示例代码,若是将手动点击DOM元素的触发方式变为$inner.click(),那么会获得不同的结果。
Chrome下的输出顺序大体是这样的:
click -> click -> promise -> observer -> promise -> animationFrame -> animationFrame -> timeout -> timeout

与咱们手动触发click的执行顺序不同的缘由是这样的,由于并非用户经过点击元素实现的触发事件,而是相似dispatchEvent这样的方式,我我的以为并不能算是一个有效的I/O,在执行了一次handler回调注册了微任务、注册了宏任务之后,实际上外边的$inner.click()并无执行完。
因此在微任务执行以前,还要继续冒泡执行下一次事件,也就是说触发了第二次的handler
因此输出了第二次click,等到这两次handler都执行完毕后才会去检查有没有微任务、有没有宏任务。

两点须要注意的:

  1. .click()的这种触发事件的方式我的认为是相似dispatchEvent,能够理解为同步执行的代码
document.body.addEventListener('click', _ => console.log('click'))

document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')

// > click
// > click
// > done
复制代码
  1. MutationObserver的监听不会说同时触发屡次,屡次修改只会有一次回调被触发。
new MutationObserver(_ => {
  console.log('observer')
  // 若是在这输出DOM的data-random属性,必然是最后一次的值,不解释了
}).observe(document.body, {
  attributes: true
})

document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())

// 只会输出一次 ovserver
复制代码

这就像去饭店点餐,服务员喊了三次,XX号的牛肉面,不表明她会给你三碗牛肉面。
上述观点参阅自Tasks, microtasks, queues and schedules,文中有动画版的讲解

在Node中的表现

Node也是单线程,可是在处理Event Loop上与浏览器稍微有些不一样,这里是Node官方文档的地址。

就单从API层面上来理解,Node新增了两个方法能够用来使用:微任务的process.nextTick以及宏任务的setImmediate

setImmediate与setTimeout的区别

在官方文档中的定义,setImmediate为一次Event Loop执行完毕后调用。
setTimeout则是经过计算一个延迟时间后进行执行。

可是同时还提到了若是在主进程中直接执行这两个操做,很难保证哪一个会先触发。
由于若是主进程中先注册了两个任务,而后执行的代码耗时超过XXs,而这时定时器已经处于可执行回调的状态了。
因此会先执行定时器,而执行完定时器之后才是结束了一次Event Loop,这时才会执行setImmediate

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))
复制代码

有兴趣的能够本身试验一下,执行屡次真的会获得不一样的结果。

可是若是后续添加一些代码之后,就能够保证setTimeout必定会在setImmediate以前触发了:

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

while(countdown--) { } // 咱们确保这个循环的执行速度会超过定时器的倒计时,致使这轮循环没有结束时,setTimeout已经能够执行回调了,因此会先执行`setTimeout`再结束这一轮循环,也就是说开始执行`setImmediate`
复制代码

若是在另外一个宏任务中,必然是setImmediate先执行:

require('fs').readFile(__dirname, _ => {
  setTimeout(_ => console.log('timeout'))
  setImmediate(_ => console.log('immediate'))
})

// 若是使用一个设置了延迟的setTimeout也能够实现相同的效果
复制代码

process.nextTick

就像上边说的,这个能够认为是一个相似于PromiseMutationObserver的微任务实现,在代码执行的过程当中能够随时插入nextTick,而且会保证在下一个宏任务开始以前所执行。

在使用方面的一个最多见的例子就是一些事件绑定类的操做:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    this.emit('init')
  }
}

const lib = new Lib()

lib.on('init', _ => {
  // 这里将永远不会执行
  console.log('init!')
})
复制代码

由于上述的代码在实例化Lib对象时是同步执行的,在实例化完成之后就立马发送了init事件。
而这时在外层的主程序尚未开始执行到lib.on('init')监听事件的这一步。
因此会致使发送事件时没有回调,回调注册后事件不会再次发送。

咱们能够很轻松的使用process.nextTick来解决这个问题:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    process.nextTick(_ => {
      this.emit('init')
    })

    // 同理使用其余的微任务
    // 好比Promise.resolve().then(_ => this.emit('init'))
    // 也能够实现相同的效果
  }
}
复制代码

这样会在主进程的代码执行完毕后,程序空闲时触发Event Loop流程查找有没有微任务,而后再发送init事件。

关于有些文章中提到的,循环调用process.nextTick会致使报警,后续的代码永远不会被执行,这是对的,参见上边使用的双重循环实现的loop便可,至关于在每次for循环执行中都对数组进行了push操做,这样循环永远也不会结束

多提一嘴async/await函数

由于,async/await本质上仍是基于Promise的一些封装,而Promise是属于微任务的一种。因此在使用await关键字与Promise.then效果相似:

setTimeout(_ => console.log(4))

async function main() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

main()

console.log(2)
复制代码

async函数在await以前的代码都是同步执行的,能够理解为await以前的代码属于new Promise时传入的代码,await以后的全部代码都是在Promise.then中的回调

小节

JavaScript的代码运行机制在网上有好多文章都写,本人道行太浅,只能简单的说一下本身对其的理解。
并无去生抠文档,一步一步的列出来,像什么查看当前栈、执行选中的任务队列,各类balabala。
感受对实际写代码没有太大帮助,不如简单的入个门,扫个盲,大体了解一下这是个什么东西就行了。

推荐几篇参阅的文章:

One more things

Blued前端/Node团队招人。。初中高都有HC
坐标帝都朝阳双井,有兴趣的请联系我:
wechat: github_jiasm
mail: jiashunming@blued.com

欢迎砸简历

相关文章
相关标签/搜索