缘由
前端团队在执行 code review 时候,我们发现早期的代码中有很多滥用了 async
await
的代码。虽然在执行中,虽然在同步的代码中乱写 async
await
并没有导致明显的bug,但加了await
跟不加await
的结果是否会有区别?今天我们先看一下以下几行示例代码的执行结果
async function fn() {
console.log('fn start')
await fn2()
console.log('fn end')
}
function fn2() {
console.log('fn2')
}
console.log('script start')
fn()
console.log('script end')
结果对比
-
加await输出的结果是 script start -> fn start -> 2 -> script end -> fn end
-
不加await输出结果是 script start -> fn start -> 2 -> fn end -> script end
在文章开始之前,本文我所说的任务,即为宏任务。只是我个人更倾向于mdn对其的称呼,是“任务”而不是“宏任务”,具体后面会解释
题目1
- 想一想,如果我们执行 foo ,是否会报栈溢出的错误?为什么?
function foo() {
setTimeout(foo, 0)
}
题目1 答案
不会
事件循环 Eventloop
JavaScript 并发模型基于“事件循环”,浏览器提供了运行时环境来执行我们的 JavaScript 代码。主要组成包括调用栈、事件循环、任务队列和 Web API 等。JavaScript 环境的可视化表示大致如下所示。(Promise应该是js的内置方法而不是WebAPIs)
image.pngJavaScript调用栈是后进先出的,引擎每次从调用栈中取出一个函数,然后按顺序从上到下运行代码。每当遇到一些异步代码的时候,比如setTimeout
,他会将其丢给WebAPI,也就是箭头指向的1。
WebAPI会等到合适的时机,再把回调函数,发送到任务队列,也就是箭头2的操作。
Eventloop会不断监视调用栈是否为空,等到调用栈的任务为空的时候,他就会取出回调函数放入调用栈中。(在调用栈不为空的情况下,eventloop不会把任何回调放到调用栈中去,因此也解释了setTimeout的时间不一定是准确的)
从事件循环机制来看这个方法的执行顺序如下:
- 调用
foo()
将把foo
函数放到调用栈中
- 在处理内部代码时, JS 引擎遇到
setTimeout
- 将回调函数
foo
交给 webAPI,函数执行结束,出栈;调用栈又是空的
- 由于定时器设置为 0,时间一到回调函数
foo
将被发送到任务队列
- 进程再次重复,调用栈永远不会溢出
题目2
想一想,如果我们执行下方的foo,再点击页面的按钮,会有响应吗?为什么?
function foo() {
return Promise.resolve().then(foo)
}
题目2答案
不会有响应
虽然他们都是异步,但跟setTimeout不一样,Promise.then是微任务。他们很相像,但是执行时机上是不一样的。
每当一个任务退出且执行上下文堆栈为空时,将逐个执行微任务队列中的每个微任务,与任务的不同之处在于,微任务的执行会一直持续到微任务队列为空——即使在此期间计划了新任务。换句话说,微任务可以将新的微任务编入队列,这些微任务将在下一个任务开始运行之前和当前事件循环迭代结束之前执行。
什么时候会产生任务
mdn上给了我们答案
- A new JavaScript program or subprogram is executed (such as from a console, or by running the code in a <u>
[<script>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script)
</u> element) directly.
- An event fires, adding the event's callback function to the task queue.
- A timeout or interval created with <u>
[setTimeout()](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout)
</u> or <u>[setInterval()](https://developer.mozilla.org/en-US/docs/Web/API/setInterval)
</u> is reached, causing the corresponding callback to be added to the task queue.
常见的微任务
-
promise.then
-
process.nextTick
-
...
点击事件/滚动事件均会向任务队列中加入一个任务,但是由于foo
函数递归增加微任务,因此使得事件循环在无尽处理微任务,所以页面会直接失去响应。
总结
微任务是任务的一个步骤,所以是一个任务先执行,接着当前所有微任务逐条执行;
然后是下一个任务执行, 然后执行当前任务所有的微任务,以此重复下去。如下图所示:
image.png解释一下我个人在本文中称呼其为“任务”而不是“宏任务”的原因,宏任务听起来总是跟微任务的对立的关系。甚至有不少的社区讨论者给出来的结论的【先执行宏任务,再执行微任务】。这种是片面的,微任务是JavaScript级别的,宏任务是宿主级别的,他们之间是包含关系,不是先后关系,也不是对立关系。
思考:在有任务的情况下,我们为什么还需要微任务
这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。区分了微任务和宏任务后,本轮循环中的微任务实际上就是在插队,这样微任务中所做的状态修改,在下一轮事件循环中也能得到同步。
这个操作,确保任务的顺序一致,即使结果或数据是同步的,但同时减少用户可识别的操作延迟的风险。
例子就不搬了,自己去看mdn吧,链接在文末
本文只是粗略介绍了任务与微任务的差异,实际上关联的知识点非常多,包括事件循环,js的单线程,浏览器的多进程,浏览器每个tab是多线程等细节都是每一个前端开发都需要了解的。可点击文末的链接进行了解
来做道面试题吧
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function() {
console.log('settimeout')
})
async1()
new Promise(function(resolve) {
console.log('promise1')
resolve()
}).then(function() {
console.log('promise2')
})
console.log('script end')
参考:
Tasks, microtasks, queues and schedules
In depth: Microtasks and the JavaScript runtime environment - Web APIs | MDN
Using microtasks in JavaScript with queueMicrotask() - Web APIs | MDN
网友评论