通俗易懂的图解JavaScript中的Event Loop以及JavaScript中的await和async执行顺序

前言

作为一个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?

前置知识点

Promise

首先看一段模拟网络请求的代码

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 引擎执行机制

首先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

如果按照上面的理解setTimeoutPromise都会被放入异步队列,但是打印的结果却是setTimeout最后。这里就引出了另一个机制
Macro TaskMicro Task说人话就是翻译过来的宏任务和微任务,不过我咋觉得怪怪的翻译,还不如不翻译,再通俗一点,按我的理解,就是主线程队列中的任务输送者,就是宏任务,和iOS差不多的RunLoop一样是在另一个Loop中执行的任务,而微任务,就是在不影响主线程任务的前提下,在当前线程执行完任务,在当前Loop需要补充执行的任务。

Event Loop提前总结

在这里插入图片描述
图画的不好,将就着看,主要用来分析一一对应。

JS代码执行其实就是往执行栈中放函数。那么当我们遇到异步代码,会被挂起,比如异步I/O,用户输入,定时器等,等待回调,至于挂起到哪里,怎么注册回调的,咱们也懂啊,咱也不敢问啊,反正就是被挂起了,主要你不要阻碍我主线程任务,爱挂哪挂哪。这个时候主线程执行自己的同步代码,被挂起的异步任务也会单独执行,会在合适的时候把回调的任务放入真正的预备的主线程任务队列,当前执行栈中同步任务结束了,在下一个Event Loop会去准备好的主线任务队列中拿出需要执行的代码入栈执行,至于这里为什么要等到下一个Loop,因为这才是一个完整的Loop,不然会一直处于执行等待执行等待,压根没有循环可言。

挂起:

  • 所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

Macro Task:

  • script、setTimeout、setInterval、I/O、UI rendering、postMessage、MessageChannel、setImmediate

Micro Task:

  • Promise、MutaionObserver、process.nextTick、JS引擎发起的任务

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和await理解

async

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

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结束前调用

下面就开始以头条的面试题进行图解。

1.执行同步代码

代码从上到下执行,遇到两个函数声明,无视,然后在第一个Loop执行同步任务
在这里插入图片描述

2.处理setTimeout

根据上面的理论,遇到这个,我们会挂起,这里就不表示具体怎么挂起啦,等待未来某个时刻回调,推入宏任务队列,等待下一个Event Loop执行
在这里插入图片描述

3.执行 async1()

函数的定义有async,其实就是被Promise包裹一层而已,表示里面有异步任务,那么继续往下同步执行,把async1 start放入主线程队列
在这里插入图片描述
这里会遇到await,理解为阻塞当前执行代码,等待await的表达式,先不管,先从右往左,执行 async2(),那么这个函数也是一个async函数,继续向下执行里面的任务console.log("async2");,这里没有返回值,但是async默认给我们包装决议为Promise.resolve(undefine)作为返回值当做await等待的表达式,咱们来温习下await在做啥

如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。 如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 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作图来理解,就直接挂起等合适的时候放入另一个队列,其实都是一个意思,看自己喜欢用哪种方式去理解
在这里插入图片描述

4.执行new Promise代码

继续往下执行,遇到new Promise,直接执行里面的同步任务promise1,然后立即决议调用resolve(),把promise2放入当前Loop微任务队列
在这里插入图片描述

5.当前栈任务执行最后一句

执行最后一句代码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先执行?

©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页