作为一个iOS开发,虽然也用JavaScript,但是从没有对一些最基本的原理有比较透彻的理解,比如这里的await和async,之前一直以为async就是iOS的dispatch_async
,直到我偶然在网上看到几篇相关的文章,对打印出来的结果,让我感觉之前都理解错了,这里整合了几个大佬的文章,根据我个人的理解,方便我以后理解,就有了如下总结,我个人认为应该是理解正确了,而且非常通俗易懂,如果有不对的地方,欢迎帮忙指正,部分摘抄自阮一峰大神的博客,整合了自己的逻辑,方便自己完全理解
其实我是看到了网上有些文章,针对一个面试题,用文字表述了对应的执行顺序,因为Node环境和浏览器最新的V8稍有不同就吵起来了,我也是服了,你如果和我一样心中有一套自己的Event Loop理解,怎么会吵起来呢,用自己的一套理解就行了,甚至还能反手给博主一个好人一生的平安的留言,毕竟博主写博客都是不容易的,像我这种配图的,就更不容易了。。。。。。
带着这几个问题,顺便都给解释了:
1.JavaScript为什么所有代码都是单线程执行的?为什么需要异步?单线程又是如何实现异步的呢?
2.什么是Event Loop?
3.为什么Promise比setTimeout先执行?
4.macrotask和microtask是什么?
5.setTimeout是一定会在定时后执行吗?
6.为什么await必须被包裹在async?
首先看一段模拟网络请求的代码
function callback() {
console.log('Done');
}
console.log('before setTimeout()');
setTimeout(callback, 1000); // 1秒钟后调用callback函数
console.log('after setTimeout()');
chrome输出如下
before setTimeout()
after setTimeout()
(等待1秒后)
Done
这里是个人都知道输出顺序,显而易见,后面咱们再结合JS的Event Loop来解释如何实现异步的。那么Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。简单来说他就是一个容器
,里面保存着某个未来才会结束的事件(通常都是一个异步操作)的结果。而且这货一诺千金,肯定会在未来的某个时候触发
const p1 = new Promise(resolve => {
setTimeout(() => {
resolve("Done");
}, 2000);
});
console.log("before setTimeout()");
p1.then(res => {
console.log(res);
});
console.log("after setTimeout()");
这是最基本的Promise介绍,需要详细介绍的可以参考如下传送门
ECMAScript 6 入门
Javascript教程
首先JS是单线程语言
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)
。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
这里姑且认为就一个task queue
,但是根据我查看其它资料来看,后续的Event Loop再详细介绍整体的运转,这里只要知道同步和异步的区分即可。
console.log(1)
setTimeout(function(){
console.log(2)
},0)
console.log(3)
运行结果显而易 132
也就是说 setTimeout
里的函数并没有立即执行,而是延迟一段时间再执行,这就是异步代码。
如果按照现在看到的这样,JS的执行机制可以理解为:
如果上述代码是最基本的,那看到下面就又解释不了了
const p1 = new Promise(resolve => {
setTimeout(() => {
resolve("Done");
});
});
console.log("before setTimeout()");
p1.then(res => {
console.log(res);
});
new Promise( resolve => {
console.log('马上给执行for 循环');
for(let i = 0; i < 1000; i++){
i == 99 && resolve('99中断')
}
} ).then(res=>{
console.log(res);
})
console.log("after setTimeout()");
打印结果如下
before setTimeout()
马上给执行for 循环
after setTimeout()
99中断
Done
如果按照上面的理解setTimeout
和Promise
都会被放入异步队列,但是打印的结果却是setTimeout
最后。这里就引出了另一个机制
Macro Task
和Micro Task
,说人话就是翻译过来的宏任务和微任务,不过我咋觉得怪怪的翻译,还不如不翻译,再通俗一点,按我的理解,就是主线程队列中的任务输送者,就是宏任务,和iOS差不多的RunLoop一样是在另一个Loop中执行的任务,而微任务,就是在不影响主线程任务的前提下,在当前线程执行完任务,在当前Loop需要补充执行的任务。
JS代码执行其实就是往执行栈中放函数。那么当我们遇到异步代码,会被挂起,比如异步I/O,用户输入,定时器等,等待回调,至于挂起到哪里,怎么注册回调的,咱们也懂啊,咱也不敢问啊,反正就是被挂起了,主要你不要阻碍我主线程任务,爱挂哪挂哪。这个时候主线程执行自己的同步代码,被挂起的异步任务也会单独执行,会在合适的时候把回调的任务放入真正的预备的主线程任务队列,当前执行栈中同步任务结束了,在下一个Event Loop会去准备好的主线任务队列中拿出需要执行的代码入栈执行,至于这里为什么要等到下一个Loop,因为这才是一个完整的Loop,不然会一直处于执行等待执行等待,压根没有循环可言。
挂起:
Macro Task:
Micro Task:
EventLoop 个人理解总结 很啰嗦,方便自己理解用
1.启动程序,V8引擎解释JavaScript脚本,入栈执行
2.先从右侧主线程预备的任务队列中取任务(定时器,异步I/O,setImmediate,用户输入),有就加载到同步队列处理,没有继续执行同步主线程的任务,程序开始,右侧队列肯定没有任务,因此根据用户编写的代码开始跑第一次Loop
3.当主线程的代码跑完,可能大量的Promise代码,已经被加入Micro队列,而且已经Resolve决议了,因此会从Micro Task队列中取任务(Promise,nextTick,Jobs队列任务),有的话就继续执行,
4.注意,现在还在一个Event Loop里面,就好比一个循环的里面有他的声明周期,当Loop激活的时候取Macro任务,执行主线程同步任务,然后查询一次Micro队列,如果这个时候有nextTick,就代表在Micro任务执行前执行该任务,如果有setImmediate,代表当前Loop结束,下个开始Loop的时候执行。
5.如果这个时候主线程派发了很多异步任务,比如网络请求和定时器,上面1-4已经跑完了一次Loop,此时可能异步任务已经回调,主线程预备任务队列中已经有了回调,那么下一个Loop开始的时候,就可以从队列中取出加入主线程执行任务,也就是setTimeout开始被执行的时刻,也就是为什么Promise决议的任务比它快的原因
6.然后上述操作周而复始,生生不息
Event Loop简洁总结
实际上上图可以理解为微任务(microtask)的队列。
与之并列的还有一个叫宏任务(macrotask)的队列。
Event loop 每次循环的过程是这样的:
1.从宏任务队列取一个task执行
2.执行 microtask check point
3.必要时执行UI渲染
4.结束本次循环
第2步其实就是按顺序把微任务队列的microtask依次执行完。
setImmediate,setTimeout 是宏任务,nextTick 是微任务。宏任务理解为当前EvenLoop需要被载入到主线程执行的任务,每一批任务肯定是在独立的下一个Loop被执行,而微任务则是当前Loop下快结束前补充的任务。setTimeout是宏任务,Promise是微任务,这也就是为什么Promise更早执行。而且如果你指定setTimeout比如5秒,但是如果上一个Loop如果同步任务繁重,执行了10秒,那么这个5秒的定时器任务是不会再5秒的时候准时触发的,会跟着延迟。
async function testAsync() {
return "hello async";
}
async function testAynsc1() {
console.log('not return');
}
const result = testAsync();
console.log(result);
const result1 = testAynsc1()
console.log(result1);
输出如下:
Promise {<resolved>: "hello async"}
not return
Promise {<resolved>: undefined}
可以看到async就是帮我们自动生成了Promise对象,那么拿到Promise后,我们可以根据then来获取决议后的值
总结:
1.带async关键字的函数,他使得你的函数的返回值必定是Promise对象
2.如果async关键字函数返回的不是promise,会自动用Promise.resolve()包装
3.如果async关键字函数显式地返回promise,那就以你返回的promise为准
4.async表示函数内部有异步操作,无论怎么样,resolve决议的时候都会把任务放入Micro任务队列,在主线程任务当前Loop异步执行
await 等到了它要等的东西,一个 Promise 对象,或者其它值,然后呢?我不得不先说,await 是个运算符,用于组成表达式,await 表达式的运算结果取决于它等的东西。
如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。
如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。
总结:
1.右侧如果是函数,那么函数的return值就是「表达式的结果」
2.右侧如果是一个 ‘hello’ 或者什么值,那表达式的结果就是 ‘hello’,这种情况await就和没加一样
3.await必须在async内
4.await在其右边表达式执行完任务时,会出让当前线程,这句话如果你不懂Event Loop,我估计理解起来够呛,虽然网上茫茫多都这么说,但是按我个人的理解来说,await执行完右边表达式,比如一个Promise包裹的网络请求,await后面的代码就相当于这个Promise的任务,这个任务如果在未来某个时刻请求回来了,会被加入到Micro任务队列,也就是说后面这些代码不会影响卡主主线程的任务继续执行
function takeLongTime() {
return new Promise(resolve => {
console.log('First Promise');
setTimeout(() => resolve("long_time_value"), 1000);
});
}
async function testAwait() {
const v1 = await takeLongTime()
console.log(v1);
console.log('测试一下');
}
testAwait()
console.log('Next');
const v1 = new Promise(resolve=>{
console.log('Next Promise');
resolve('决议Next')
}).then(res=>{
console.log(res);
}).then(res=>{
console.log(res);
})
console.log('end');
输出如下:
First Promise
Next
Next Promise
end
决议Next
undefined
long_time_value
测试一下
如果你一句句看下来的,这里的输出应该分分钟就明白了,咱们先来解释下为什么await
为什么只能在async
里面,看testAwait
这个函数,转换如下
async function testAwait() {
takeLongTime().then(res=>{
console.log(res);
console.log('测试一下');
})
}
可以看到当我们takeLongTime
回调决议的时候,会把之前await后面的函数打包一起丢进Micro任务队列中,在当前Loop最后执行,因此是不阻塞主线程的异步,不会影响testAwait
外面的主线程代码执行。如果被async包裹,其实就是把一段代码通过Promise包裹,如果不用async保护,那么await就会把后面的代码全部当做Micro任务队列,直接丢进Micro队列,因此后面的主线程代码也会被卡主,这就违背了异步的逻辑,因此在编译阶段也会直接报错。
function takeLongTime() {
return new Promise(resolve => {
setTimeout(() => resolve("long_time_value"), 1000);
});
}
takeLongTime().then(v => {
console.log("got", v);
});
function takeLongTime() {
return new Promise(resolve => {
setTimeout(() => resolve("long_time_value"), 1000);
});
}
async function test() {
const v = await takeLongTime();
console.log(v);
}
test();
两者是一样的,我们现在用Event Loop把这个最简单的案例完整表达下。
整体思路分析导图如下
分析如下:
1.定义并且在栈中调用takeLongTime()
,触发Promise中的setTimeout
以及自身的then
回调函数
2.回调未触发之前,暂且挂载到某个地方等待回调,而且不阻塞主线程,一次Event Loop结束
3.这里等待setTimeout
的宏任务回调,多个Loop后,回调触发,任务进入宏任务队列,周期性的Event Loop开始时,检测到有任务可以调用,直接拿过来放入栈中触发
4.这里直接调用挂起的Promise的Resolve回调,这个时候会把Promise的回调任务送入微任务队列,在当前Event Loop 检测时候拿出来调用,结束当前Event Loop
5.Event Loop继续循环检测,结束
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");
}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1");
resolve();
}).then(function() {
console.log("promise2");
});
console.log("script end");
最新的Chrome环境,如果你是老的Node环境,会因为await等到的是Promise对象这个处理上的差异,打印结果会稍有不同,Node比较保守,Chrome就很激进,就好比一个用老版本的Xcode,一个用新版本的Xcode,那么很显然,我们要以激进派为主,同时知道保守派为什么和这个不同即可,因为在不久的将来,我们肯定是以最新的技术为准,具体草案什么的最后有文章可以自行翻译,咱们这就以Chrome的打印为主
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
首先看下我个人对宏任务和微任务的理解
Macro Task:script、setTimeout、setInterval、I/O、UI rendering、postMessage、MessageChannel、setImmediate
Micro Task: Promise、MutaionObserver、process.nextTick、JS引擎发起的任务
如果上面的setTimeout遇到了,就会把回调任务推入宏任务队列在下一个Event Loop
调用
如果遇到了promise.then(),就会把任务推入当前Event Loop对应的微任务队列,在本次Loop
结束前调用
下面就开始以头条的面试题进行图解。
代码从上到下执行,遇到两个函数声明,无视,然后在第一个Loop执行同步任务
根据上面的理论,遇到这个,我们会挂起,这里就不表示具体怎么挂起啦,等待未来某个时刻回调,推入宏任务队列,等待下一个Event Loop执行
函数的定义有async,其实就是被Promise包裹一层而已,表示里面有异步任务,那么继续往下同步执行,把async1 start
放入主线程队列
这里会遇到await,理解为阻塞当前执行代码,等待await的表达式,先不管,先从右往左,执行 async2()
,那么这个函数也是一个async函数,继续向下执行里面的任务console.log("async2");
,这里没有返回值,但是async默认给我们包装决议为Promise.resolve(undefine)
作为返回值当做await等待的表达式,咱们来温习下await在做啥
代码翻译成promise如下,这样子很好理解了吧
async function async1() {
console.log("async1 start");
async2().then(res=>{
console.log("async1 end");
})
return Promise.resolve(undefined)
}
async function async2() {
console.log("async2");
return Promise.resolve(undefined)
}
当await后面的表达式执行完,同步队列加入任务,拿到的返回值是Promise.resolve(undefine)
,这里最新的V8会直接决议,也就是await立马会执行then方法产生回调,因此,后续代码作为回调任务放入当前Event Loop的微任务队列,好多人喜欢把await理解为阻塞,我个人根据Event Loop作图来理解,就直接挂起等合适的时候放入另一个队列,其实都是一个意思,看自己喜欢用哪种方式去理解
继续往下执行,遇到new Promise
,直接执行里面的同步任务promise1
,然后立即决议调用resolve()
,把promise2
放入当前Loop微任务队列
执行最后一句代码console.log("script end");
,当前所有任务和分配结束,启动Event Loop模型循环去看下打印顺序,是不是一目了然
当前Event Loop1
先执行Macro Task Queue
中的任务,清空后去check point
对应的Micro Task Queue
,把里面的任务拿出来执行,这个时候一次时间循环结束,然后无限循环下一个Event Loop
,也就执行到的setTimeout
对应的任务
到此为止,截个Event Loop模型,来理解Promise或者await和async等执行逻辑就异常清晰了,因人而异,我只是看到网上介绍的,只是用文字告诉你具体的执行逻辑,我感觉和我的理解有偏差,我就直接把我的个人理解用图画出来了,如果有问题,欢迎留言指正,这个只是现阶段我个人通俗易懂的理解,欢迎有大佬推翻我的认知。
参考文献:
理解 JavaScript 的 async/await
async/await 执行顺序详解
setTimeout async promise执行顺序总结
环境问题
Node.js事件循环
执行顺序
js执行机制
阮一峰的Event Loop
Promise为什么比setTimeout先执行?