Description
简单聊一聊浏览器下的 event-loop, 微任务与宏任务
函数调用栈
函数都是有调用栈的, 大多数程序可以看成函数调用函数不断调用函数, 比如下面这张图:
可以看到, 首先有一个 main(可以看做是入口), 然后依次是fn3()
调用fn2()
, 再调用fn1()
, 最后调用console.log()
. 一般出现错误的时候, 就会出现类似栈调用情况, 如下:
event loop
首先推荐观看这个视频What the heck is the event loop anyway?, 以及作者写了一个 event loop 可视化工具: Loupe, 不过目前这个工具支持比较有限, 不支持查看 callback queue 里面具体的 macrotask 和 microtask.
这里还是总结一下基本的 event loop 流程, 首先看这张图:
- 首先 JavaScript 是一门单线程语言, 只有一个主线程, 你可以看成 main, 该线程负责调用函数堆栈, 也就是前面所说的Call Stack
- 其中有一些 API 浏览器是不提供, 所以无法处理, 主线程会把这些 API, 放到另外一个地方(绿色部分)处理, 这些 API 叫作 Web API(也可以称之为调度者), 如上图中的
setTimeout
,setTimeInterval
,XMLHttpRequest
等等 - 这里注意, 在处理这些 Web API 的时候, js 引擎的主线程还是在不断工作的(如果有任务的话), 可以认为两边是互相工作, 互不干扰
- 回到 WebAPI 部分, 假设放到 Web API 部分的这段代码是
setTimeout((_) => console.log(1), 1000)
, 这里绿色部分所做的是:- 等 1000ms
- 将
(_) => console.log(1)
这个回调放到 Event Queue(事件队列, 蓝色部分) 里面, 这里注意: 并非是把setTimeout()
整个函数放入到事件队列里面, 仅放入其中的回调函数部分
- 也就是说, 调度者在上下文代码中还是同步立即执行, 只不过其随后会将自己里面的回调函数放入任务队列中等待执行.
- 最后对于 Event Queue 部分, 这里会堆积很多 Web API 移动下来的回调函数. 当 js 引擎的 call stack 为空的时候, event queue 会把最先进入到 queue 里面的回调函数推入 call stack 里面执行
给两张张图片描述一下上面的流程, 其中第二张图片的代码为:
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");
bar();
foo();
baz();
几个注意点:
- web api 执行 (setTimeout, 请求等), 一旦时间到, 立马就将里面的回调函数放入到 event queue 任务队列中
- 一旦主线程空了, 那么不管 web api 里面是否还有代码在运行, 都会开始执行任务队列里面的任务
所以整个执行过程是相当连贯协调, 大家分工合作, 每个函数都有自己的职责和暂时归属地, 属于哪里以及什么时候被执行都是井井有条. 避免了同步情况下, 一个无关紧要的任务被卡死, 其余任务无法被执行的现象
异步任务和同步任务
- 同步任务: JS 引擎主线程里面立即被推入 call stack 且可以被执行的函数
- 异步任务: 在 event queue 里面的回调函数, 也就是独立于主线程里面的任务
举一个例子:
下面的这个片段, synCb
这个回调函数是不会被推入到 event queue 里面执行的, 会被浏览器当做同步任务执行, 所以打印的结果就是按照从上到下面的顺序, 依次进入堆栈输出的
console.log('start')
const syncFun = function(synCb) {
const v = 100
synCb(v)
}
syncFun(v => console.log(v))
console.log('end')
// start
// 100
// end
假如我们加入异步任务, 如下, 那么这里的asynCb
是会作为异步任务放入 event queue 里面等主线程清空以后(也就是两个 console.log
都执行完毕), 才会被推入 call stack 被调用
console.log('start')
setTimeout(function asynCb() {
console.log(100)
}, 0)
console.log('end')
// start
// end
// 100
总的来说在这种情况下, js 形成了两个队列: 同步任务队列和异步任务队列, 先执行同步任务队列里面的任务, 所有同步任务执行完毕以后, 再执行异步任务队列里面的任务.
异步回调
普通函数回调
例如setTimeout(callback, timer)
, 里面的 callback 是异步任务, 最后是会被放入到 event queue 里面的, 这里要注意一个setTimeout
的点: 即里面的时间并非真正意义上的执行时间, 考虑如下代码:
setTimeout(() => {
console.log(8)
}, 5000)
setTimeout(() => {
console.log(9)
}, 5000)
setTimeout(() => {
console.log(10)
}, 5000)
// 8
// 9
// 10
上述代码最后会依次打印 8, 9, 10. 这是由于根据执行顺序会依次放入到 event queue 里面, 也就是说, 打印 8 的回调会被先放入到异步任务队列里面, 然后是 9, 10.
所以这里的时间并非指的是这个函数的执行时间, 而指代的是, 异步回调函数几秒后会被放入到任务队列
Promise 和 async/await
Promise
promise
中, .then()
里面的为异步回调
假设有如下代码:
console.log('start')
new Promise((resolve, reject) => {
resolve(1)
console.log('middle')
}).then(v => console.log(v))
console.log('end')
// start
// middle
// end
// 1
上面代码中, v => console.log(v)
是异步回调, 注意, new Promise
在实例化的过程中所执行的代码都是同步进行的, 所以console.log('middle')
会同步执行
async/await
async/await
本质上还是基于 Promise
的一些封装, async
函数在await
之前的代码都是同步执行的,可以理解为await
之前的代码属于new Promise
时传入的代码,await
之后的所有代码都是在Promise.then
中的回调
console.log('start')
async function main() {
console.log('test')
const v = await Promise.resolve(1)
console.log(v)
}
main()
console.log('end')
// start
// test
// end
// 1
写法总结
使用request, 写一个getUser
函数, 有基本以下写法:
回调写法:
const getUser = function(callback) {
request('http://www.example.com', function(err, res, body){
callback(body)
})
}
getUser(v => console.log(v))
Promise 写法:
const getUser = function() {
return new Promise((resolve, reject) => {
request('http://www.example.com', function(err, res, body){
if (err) {
return reject(err)
}
resolve(body)
})
})
}
getUser()
.then(v => console.log(v))
.catch(err => console.error(err))
async/await 写法:
const getUser = function() {
return new Promise((resolve, reject) => {
request('http://www.example.com', function(err, res, body){
if (err) {
return reject(err)
}
resolve(body)
})
})
}
(async function() {
try {
const v = await getUser()
console.log(v)
} catch(e) {
console.error(e)
}
})()
宏任务与微任务
上面提到的 event queue 里的异步任务还可以细分为: macrotask(宏任务), microtask(微任务). 这里注意, 现在标准称呼可以认为是 tasks 和 jobs, 但本文还是以宏任务和微任务来代指
宏任务:
- script(整体代码, 上下文)
setTimeout
,setInterval
- I/O
- UI rendering
微任务:
Promise
process.nextTick
当然上面的任务其实均指的是其中的回调函数, 而上面的 api 前面也提过, 充当调度者的作用
有几个概念需要注意一下:
promise.then(callback)
里面的callback
是一个微任务, 且会被推入当前的微任务队列, 当且仅当该promise
状态变更为resolved
或者rejected
, 否则不被推入任务队列setTimeout(callback, t)
的callback
是一个宏任务, 会被推入当前的宏任务队列中, 即使t
为 0- 整个在 script 中的代码也是一个宏任务
模型
关于宏任务和微任务的模型可以看成如下图的形式:
有几个重要的概念:
- 始终只有一条宏任务队列
- 可以有多条微任务队列, 但是每一个宏任务后面仅跟随一条微任务队列, 且只对当前的宏任务"有效"
- 每一次 loop(循环), 都会去执行宏任务里面最前面的一个宏任务, 然后检查是否有微任务队列, 依次执行微任务队列里面的微任务. 执行完毕后开始下一次循环, 和之前一样从宏任务队列里面选取最新的宏任务执行, 不断循环
- 在执行微任务/宏任务的过程中, 如果发现微任务, 那么添加到当前的微任务队列中等待稍后被执行, 而如果发现宏任务, 该宏任务被添加到宏任务队列中等待下一次 loop 被执行. 也就是说: 发现微任务是可以在当前 loop 下被执行的, 而发现的宏任务只能等到下一次循环的时候被执行
- 每一次 loop 都包含: 一个(且只有一个)宏任务被执行, 以及对应的微任务队列, 执行完毕后开启下一个 loop
总结:
- 运行宏任务队列最先进来的宏任务, 然后移除他(一般第一个宏任务是 script)
- 依次运行微任务队列中的微任务, 移除他们
- 开始下一次 loop(返回过程 1)
异步任务模型伪代码可以模拟成如下:
// 第一次 loop
[
['宏任务 1', '微任务 1.1', ' 微任务 1.2'],
['宏任务 2'],
['宏任务 3'],
]
// 第二次 loop
[
// ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空
['宏任务 2', '微任务 2.1'],
['宏任务 3'],
]
// 第三次 loop
[
// ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空
// ['宏任务 2'], 执行完毕被清空
['宏任务 3'],
]
示例
示例 1:
console.log('start')
setTimeout(function() {
console.log('timeout')
}, 0)
new Promise(function(resolve) {
console.log('promise')
resolve()
}).then(function() {
console.log('promise resolved')
})
console.log('end')
// start
// promise
// end
// promise resolved
// timeout
分析:
- 建立执行上下文,进入执行栈开始执行代码,打印 start
- 往下执行,遇到
setTimeout
,将回调函数放入宏任务队列,等待执行 - 继续往下,有个
new Promise
,其回调函数并不会被放入其他任务队列,因此会同步地执行,打印promise
,但是当resolve
后,.then
会把其内部的回调函数放入微任务队列 - 执行到了最底部的代码,打印出 end, 此时 call stack 被清空, 可以执行异步任务
- 开始第一次 event loop:
- 由于整个 script 算一个宏任务, 因此该宏任务已经被执行完毕
- 检查微任务队列, 发现其中有 3 放入的微任务, 执行打印出 promise resolved,第一次循环结束
- 开始第二次 loop:
- 从宏任务开始,检查宏任务队列是否有可执行代码,发现有 2 中放入的一个,打印timeout
- 检查微任务队列, 微任务, 第二次循环结束
示例 2:
console.log('start')
setTimeout(function () {
console.log('event loop2, macrotask')
new Promise(function (resolve) {
console.log('event loop2, macrotask continue')
resolve()
}).then(function () {
console.log('event loop2, microtask1')
})
}, 0)
new Promise(function (resolve) {
console.log('middle')
resolve()
}).then(function () {
console.log('event loop1, microtask1')
setTimeout(function () {
console.log('event loop3, macrotask')
})
})
console.log('end')
// start
// middle
// end
// event loop1, microtask1
// event loop2, macrotask
// event loop2, macrotask continue
// event loop2, microtask1
// event loop3, macrotask
分析:
- 打印 start, 发现
setTimeout
, 回调放入宏任务队列中 - 发现在初始化 promise 实例, 初始化过程中打印 middle, 初始化完毕后 promise 状态变为 resolve
- resolve 后因为后面的
.then()
将回调放当前入微任务队列中 - 打印 end, script 完成
- 第一次 event loop:
- 由于 script 完成, 相当于完成了宏任务
- 检查微任务队列, 发现有一个在第 3 步放入的微任务, 执行打印 event loop1, microtask1, 继续执行, 发现有
setTimeout
, 将其中回调放入宏任务队列中
- 第二次 event loop:
- 检查宏任务队列, 发现排在最前面的是第 1 步放入的宏任务, 执行打印 event loop2, macrotask
- 继续执行, 发现在初始化 promise 实例, 打印 event loop2, macrotask continue
- promise 在状态被 resolve 之后回调放入当前微任务队列中
- 检查微任务队列, 发现有一个在第 6.3 中的微任务, 执行打印 event loop2, microtask1
- 第三次 event loop:
- 发现在第 5.2 步中放入的最后一个宏任务, 执行并打印 event loop3, macrotask
一个相关问题
前两天在知乎看到的一个问题: JavaScript的DOM事件回调不是宏任务吗,为什么在本次微任务队列触发
console.log('本轮任务');
new Promise((resolve, reject) => {
resolve(3)
}).then(() => {
console.log('本轮微任务');
})
document.getElementById('div').addEventListener('click', () => { console.log('click'); })
document.getElementById('div').click()
// 本轮任务
// click
// 本轮微任务
这里注意: click()
和dispatchEvent()
等人工合成事件是同步触发的, 其回调并非会被放入宏任务队列中, 而是直接作为同步任务执行, 具体答案可以参考这个回答 和补充
参考
- https://zhuanlan.zhihu.com/p/75572565
- https://juejin.im/post/5b73d7a6518825610072b42b
- https://blog.sessionstack.com/how-javascript-works-event-loop-and-the-rise-of-async-programming-5-ways-to-better-coding-with-2f077c4438b5
- https://stackoverflow.com/questions/25915634/difference-between-microtask-and-macrotask-within-an-event-loop-context
- https://jakearchibald.com/2015/tasks-microtas
- https://www.zhihu.com/question/362096226/answer/944729236