宏任务与微任务

宏任务与微任务

在点击事件冒泡捕获的处理中,通过使用setTimeout 的方式,想要调整某个代码的执行顺序的话,其具体的执行顺序,由一套 机制 去实现,这套机制就是通过定义 宏任务与微任务 来安排执行顺序。

异步机制:javascript事件循环

概念

image-20230428112130558

内存堆:这是内存分配发生的地方。当V8引擎遇到变量声明和函数声明的时候,就把它们存储在堆里面。

调用栈:这是你的代码执行时的地方。当引擎遇到像函数调用之类的可执行单元,就会把它们推入调用栈。

JS单线程,指的是在JS引擎中,解析执行JS代码的调用栈是唯一的,所有的JS代码都在这一个调用栈里按照调用顺序执行,不能同时执行多个函数。

image-20230428114659761

Web APIs:还有很多引擎之外的 API,我们把这些称为浏览器提供的 Web API,比如说 事件监听函数、DOM、HTTP/AJAX请求、setTimeout等等。

回调队列(Event Queue):按照先进先出的顺序存储所有的回调函数。在任意时间,只要Web API容器中的事件达到触发条件,就可以把回调函数添加到回调队列中去。

事件循环 (Event Loop):持续的检测调用栈和回调队列,如果检测到调用栈为空,它就会通知回调队列把队列中的第一个回调函数推入执行栈。

机制

image-20230428112435241

JS运行时环境的工作机制:

  1. JS引擎:(唯一主线程)按顺序解析代码,遇到函数声明,入堆,遇到函数调用,入栈;
  2. 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;
  3. 如果是异步函数调用,分发给Web API(多个辅助线程),进入Event Table并注册函数,异步函数弹出栈,继续下一个函数调用;
  4. Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了,Event Table就将回调函数推入回调队列中。
  5. Event Loop:不停地检查主线程的调用栈与回调队列,当调用栈空时,就把回调队列中的第一个任务推入栈中执行,不断循环。

例子

下面是一段简易的 ajax 请求代码:

1
2
3
4
5
6
7
8
9
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');
  • ajax进入Event Table,注册回调函数 success

  • 执行 console.log('代码执行结束')

  • ajax事件完成,回调函数 success 进入Event Queue。

  • 主线程从Event Queue读取回调函数 success 并执行。

下面是一段更详细的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
setTimeout(function(){
console.log('Hey, Why am I last?')
}, 0)

function sayHi(){
console.log('Hello')
}

function sayBye(){
console.log('Goodbye')
}

sayHi()
sayBye()

执行过程是这样的:

  1. JS引擎会检查整段代码的语法错误,如果没有错误,就从头开始深度解析
  2. 首先遇到setTimeout函数调用,把它推入执行栈顶
  3. 解析函数体,发现setTimeout函数是Web API的一种,因此就把它分发到Web API模块然后推出栈
  4. 因为定时器设置了0ms延迟,因此Web API模块立即把它的匿名回调函数推入到回调函数函数队列。事件循环检测执行栈是否是空闲,但是当前栈并不空闲,因为…
  5. 当setTimeout函数一被分发到Web API模块,JS引擎发现了两个函数声明,把它们存储在堆内存里,然后遇到了sayHi函数的调用,就把它推入了栈顶
  6. sayHi函数调用了console.log函数,因此console.log就被推入了栈顶
  7. JS引擎开始解析console.log的函数体,它接收了一个消息去打印‘Hello’,然后被弹出栈
  8. JS引擎返回到函数sayHi的执行,遇到函数的结束符号}之后,把sayHi弹出栈
  9. sayHi函数一出栈,紧接着sayBye函数被调用,它就被推入栈顶,被解析,调用console.log,把console.log推入栈顶,打印一条消息,弹出栈。然后sayBye函数弹出栈
  10. 事件循环检测到执行栈终于空闲了,通知回调队列,然后回调队列把其中的匿名函数推入执行栈
  11. 匿名函数(就是setTimeout的回调函数)被解析,调用console.log,console.log推入栈顶
  12. console.log执行完毕、再出栈
  13. 匿名函数再被推出栈,程序结束

另一个异步机制:宏任务与微任务

但是,JS异步还有一个机制,就是遇到宏任务,先处理宏任务——将宏任务放入Event Queue,然后再处理微任务——将微任务放入Event Queue。
注意,这里两个Event Queue不是同一个queue。
当js引擎的主线程执行栈为空时,它会优先从微任务queue里遍历注册的回调函数并一一执行,然后再从宏任务的queue里遍历执行注册的回调函数,如下图:

tapd_55367591_1587814302_40

  • Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列

  • Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把微任务消息队列中的第一个任务推入栈中执行,执行完成后,再取第二个微任务,直到微任务消息队列为空;然后 去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完成后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。

上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。

宏任务 (macrotask/task)

包括:

  • I/O(例如点击一次button,上传一个文件,与程序产生交互的这些都可以称之为I/O
  • setTimeout
  • setInterval
  • setImmediate(仅Node)
  • requestAnimationFrame(仅浏览器)
  • xhr
  • postMessage
  • MessageChannel

对于 setInterval(fn,ms) 来说,不是每过 ms 秒会执行一次 fn ,而是每过 ms 秒,会有 fn 进入Event Queue。
一旦 setInterval 的回调函数 fn 执行时间超过了延迟时间 ms ,那么就完全看不出来有时间间隔了。

P.S. 有些地方会列出来UI Rendering,说这个也是宏任务,可是在读了HTML规范文档以后,发现这很显然是和微任务平行的一个操作步骤

P.S. requestAnimationFrameMDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行

微任务 (microtask/job)

包括:

  • Promise.then catch finally
  • process.nextTick(仅Node)
  • MutationObserver(仅浏览器)

P.S. new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。 async/await底层是基于Promise封装的,所以await前面的代码相当于new Promise,是同步进行的,await后面的代码相当于then,才是异步进行的。

P.S. 在Promise/A+的规范中,Promise的实现可以是微任务,也可以是宏任务,但是普遍的共识表示(至少Chrome是这么做的),Promise应该是属于微任务阵营的*

例子

1
2
3
4
5
6
7
8
9
10
setTimeout(_ => console.log(4))

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

console.log(2)

最终输出结果为:1 > 2 > 3 > 4

再来看一个嵌套的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Promise.resolve().then(()=>{
console.log('Promise1')
Promise.resolve().then(()=>{
console.log('Promise2')
})

})

setTimeout(()=>{
console.log('setTimeout1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
},0)

最后输出结果是Promise1 > setTimeout1 > Promise2 > setTimeout2

Promise1

Node 环境

Node中事件循环

Node用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。 Node也是单线程,但是在处理Event Loop上与浏览器稍微有些不同。

在node里,有一些常用的异步API,这里简单介绍下他们:

setImmediate()

setImmediate()在一次Event Loop执行完毕后立刻调用。

setTimeout则是通过计算一个延迟时间后进行执行。

所以如下示例,不能保证输出顺序。

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

而如果是下面这样,则一定是setImmediate先输出。

1
2
setTimeout(_ => console.log('setTimeout'), 20)
setImmediate(_ => console.log('setImmediate'))

process.nextTick()

process.nextTick()会将回调函数放入队列中,在下一轮Tick时取出执行

这是因为 ,process.nextTick()中的回调函数执行的优先级要高于setImmediate()。
Node里,事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者, setImmediate()属于check观察者。

process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前”执行栈”一次执行完,多个setImmediate可能则需要多次loop才能执行完。 事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取”事件队列”!

1
2
3
process.nextTick(function foo() {
process.nextTick(foo);
});

参考

https://juejin.im/post/5e972f3c518825739d40874a

JS核心理论之《运行机制与宏任务、微任务》

JS核心理论之《JS引擎、运行时与调用椎栈》

From

copy from wenjie

感谢你的打赏哦!