先提出一个问题JavaScript 既然是单线程,那为何浏览器或 Node.js 能够执行异步操做呢?javascript
下面简短解释一下:html
一、JavaScript 是单线程的,只有一个主线程;java
二、函数内的代码是从上到下依次执行,遇到被调用的函数先进入被调用的函数执行,待完成后继续执行;(这个机制主要是经过函数调用栈实现的)web
三、遇到异步事件,JavaScript 的宿主环境会另开一个线程,主线程继续执行,待结果返回后,执行回调函数。segmentfault
上述的宿主环境,则是指浏览器或 Node.js 环境,在浏览器中通常会提供额外的线程,而在 Node.js 中,则是借助 libuv 来实现不一样操做系统上的多线程。而且在宿主环境中,这个异步线程又分为 微任务 和 宏任务 。浏览器
以上内容不明白不要紧,接着往下看。网络
咱们知道,JavaScript 刚出来的时候是做为浏览器内的一种脚本语法,负责操做 DOM,与用户进行互动等,若是是多线程的话,执行顺序没法预知,并且操做以哪一个线程为准也是个难题。因此为了不这种局面,JavaScript 便采用单线程设计,这已经成了这门语言的核心特征,未来也不会改变。数据结构
在 HTML5 时代,浏览器为了充分发挥 CPU 性能优点,容许 JavaScript 建立多个线程,可是即便能额外建立线程,这些子线程仍然是受到主线程控制,并且不得操做 DOM,相似于开辟一个线程来运算复杂性任务,运算好了通知主线程运算完毕,结果给你,这相似异步的处理方式,但并无改变 JavaScript 单线程的本质。多线程
JavaScript只有一个主线程,因此也只有一个函数调用栈,学过数据结构的同窗应该都知道,栈是一种后进先出(LIFO)的数据结构。并发
在JavaScript中,每当开始执行一个函数时,就会建立一个函数的执行上下文,咱们能够笼统的将JavaScript中的执行上下文分为全局上下文和函数执行上下文。能够经过例子理解,以下代码:
function a(){
var hello = "Hello";
var world = "world";
function b(){
console.log(hello);
}
function c(){
console.log(world);
}
b();
c();
}
a();
复制代码
函数的出入栈顺序以下图:
若是对函数调用栈还不是很了解,请参考个人另一篇文章:读《JavaScript核心技术解密》笔记
从函数调用栈的执行特色中能够知道,栈内后一个函数必须在前一个函数执行完成以后才能够开始执行,若是某一个函数任务须要很长时间才能完成的话,例如网络请求,I/O操做等,后面的函数任务就会一直在等待,那么整个系统的效率就会特别低。因而你们意识到,这些耗时久的任务彻底能够先挂起,等主线程上的其余任务执行完以后,再回头将这些挂起的任务继续执行,因此有了任务队列的概念。
咱们能够简单的理解为一个函数就是一个任务,基本上能够将任务分为同步任务和异步任务。
同步任务就是指在主线程上排队执行的任务,只有当前一个任务完成以后后一个才会执行;异步任务则是不进入主线程,而是进入任务对列的任务,只有队列任务通知了主线程说某个异步任务能够执行了,该任务才会进入主线程执行。
因此,咱们思考得知,当执行过程碰到setTimeout
等异步操做时,会将其交给浏览器或 Node.js 的其余线程进行处理,当达到setTimeout
指定延迟执行的时间后,才会将回调函数放入任务队列中。
咱们能够看一个例子:
function fun() {
function callback() {
console.log('执行回调');
}
setTimeout(callback, 2000);
console.log('准备');
}
fun();
复制代码
在调用栈-异步模块-任务队列模型中,上述代码的执行过程以下:
第一步,fun()
函数入栈(咱们省略了该代码全局执行上下文入栈步骤)
第二步,由于fun()
函数内执行了setTimeout()
,因此setTimeout()
入栈,如图:
第三步,因为setTimeout()
是异步操做,不属于JavaScript主线程模块内容,因此setTimeout()
进入异步执行模块执行计时,如图
第四步,fun()
函数内的console.log('准备')
函数进入函数调用栈并执行,因此控制台输出准备
第五步,因为fun()
函数内部没有其余须要继续执行的函数,因此fun()
出栈,随后全局上下文也没有须要执行的代码,因此全局上下文也出栈,如图:
第六步,假如此时恰好setTimeout()
的两秒计时结束,那么异步模块就会将setTimeout()
的回调函数放到任务队列里面,由于此时函数调用栈已经空闲,因此任务队列依次将任务函数入栈,如图:
第七步,进入callback()
回调中,将console.log('执行回调')
入栈执行,因此在控制台输出执行回调
,如图:
第八步,callback()
再出栈,整段代码执行结束。
上面所说的步数并非说必定是有8步,目的是让你们有个顺序了解接下来每一步会进行什么内容,理解JavaScript的函数调用执行,异步模块和任务队列之间的关系是最重要的。
那么,这段代码总体的过程就是如图所示,经过这种创建底层模型的方式能够加深你们的理解。趁热打铁,请阅读以下代码,想想在“调用栈-异步模块-任务队列”模型中,是怎么样的一个流程:
setTimeout(() => {
console.log('1');
}, 32);
setTimeout(() => {
console.log('2');
}, 30);
function fun() {
console.log('3');
}
for (var i = 0; i < 100000000; i++) {
i === 99999999 && console.log('4');
}
fun();
复制代码
代码最终输出的内容顺序是4 3 2 1
,请思考执行过程。
注意一点,就是两个
setTimeout()
都会进入异步模块,这里主要进入了异步模块,这两个函数实际上是同时执行的,延迟30ms的先完成,先进入队列(先进先出),延迟32ms的后完成后进入队列,因此最后的顺序是... 2 1
,即2在1前面。
上述讲到异步模块,在浏览器中,例如 Chrome 浏览器,由 webcore 模块担任开启其余线程角色,其提供了DOM Binding
、network
、timer
子模块,这些均可以理解为异步模块,分别对应DOM处理、Ajax、时间处理函数等API。
而在Node.js中,前言里也说到了,是经过libuv来实如今不一样操做系统上统一的线程调度API。
前言里说到任务由宏任务和微任务构成,也被称为task
和job
,咱们看一张网上的事件循环图:
其中,Task Queue
是指宏任务,Microtask Queue
则是微任务。
宏任务大概包括主线程代码
、setTimeout
、setInterval
、setImmediate(仅Node.js)
、requestAnimationFrame(仅浏览器)
、I/O
、UI Rendering
;
微任务大概包括Promise.then/catch/finally
、process.nextTick(仅Node.js)
、MutationObserver(仅浏览器)
、Object.observe(已废弃)
。
事件循环中,当主线程的全部任务(函数)执行结束以后,而后顺序执行微任务队列中的全部微任务,当全部的微任务执行完成后,再执行宏任务队列中的下一个宏任务,当这个宏任务执行完毕,再看微任务队列是否存在微任务,若是存在,则顺序执行全部微任务,一直循环直至全部的任务执行完毕。
注意,浏览器在每一次宏任务结束的时候都会进行一次渲染
任务队列的事件循环能够用下图表示:
分析一段代码:
<script> setTimeout(() => { console.log(4) }, 0); new Promise((resolve) => { console.log(1); for (var i = 0; i < 10000000; i++) { i === 9999999 && resolve(); } console.log(2); }).then(() => { console.log(5); }); console.log(3); </script>
<script> console.log(6) new Promise((resolve) => { resolve() }).then(() => { console.log(7); }); </script>
复制代码
代码中输出顺序为:1 2 3 5 6 7 4
;简单分析下:
开始,程序往下走,遇到setTimeout,是异步任务,放到异步模块执行,执行结束的回调进入宏任务队列先暂存着,如图:
继续往下走,碰到Promise对象。因为是new操做,其构造函数是一个匿名函数,因此会当即执行Promise构造函数的实参函数任务,因此console.log(1)
被执行,控制台输出1,接着进入循环,直到执行resolve()
,执行完该函数以后,会附带调用then
方法,由于then
属于异步方法,因此then
内部的回调console.log(5)
被送入微任务队列,接着执行console.log(2)
,控制台输出2,此时状态如图:
程序往下走,紧接着执行console.log(3)
,因此控制台输出3。到如今控制台输出顺序为1 2 3。
到这里,第一段脚本里已经结束了,因此此时在这段<script>
脚本中函数调用栈已空,按照以前的事件循环逻辑,微任务队列里的任务会依次放到函数调用栈里面执行,因此接下来控制台就输出5,如图:
当微任务队列中的全部任务执行完毕(这里只有一个微任务),函数调用栈为空会先看程序是否能够继续,因为下一个<script>
脚本存在,因此事件循环被打断,继续下一个脚本内容,因此先执行console.log(6)
,控制台输出6,此时已输出顺序为1 2 3 5 6
,如图:
接下来,又将碰到一个Promise,Promise内构造函数的回调参数函数会当即执行,内部执行到resolve()
则会调用其then()
,因为then()
是异步方法,因此进入异步执行模块执行以后将console.log(7)
放入微任务队列,如图:
因为在这个<script>
脚本里没有其他代码,因此接下来执行全部的微任务,则继续执行console.log(7)
,随后根据事件循环原理执行下一个宏任务console.log(4)
,到此全部的代码执行完毕,因此最终的顺序是1 2 3 5 6 7 4
。
能够尝试分析下下面这个题:
setImmediate(() => {
console.log(1);
},0);
setTimeout(() => {
console.log(2);
},0);
new Promise((resolve) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(5);
});
console.log(6);
process.nextTick(()=> {
console.log(7);
});
console.log(8);
复制代码
一、异步执行模块内到底是怎么执行的呢? 笔者我的以为里面的执行是每个异步函数都分配一个线程去执行,能够说是将异步函数跟主线程并发执行的,当异步函数执行结束以后,再将异步里面的回调任务根据宏任务与微任务的划分划入不一样的任务队列,等待事件循环。
二、若是总体script属于宏任务,那么主线程的函数调用栈算不算入宏任务里面?若是算入,那以下代码是否顺序应该是1 2 3 5 6 7 8 4
?结果确定不是,正确顺序是1 2 3 5 6 8 7 4
;因此笔者以为在微任务console.log(5)执行结束,即第一次微任务队列被清空,函数调用栈会先判断程序是否还有script代码能够加载,若能够则截断本次事件循环,再次进入顺序执行状态,这样彷佛说的通一些。
<script> setTimeout(() => { console.log(4) }, 0); new Promise((resolve) => { console.log(1); for (var i = 0; i < 10000000; i++) { i === 9999999 && resolve(); } console.log(2); }).then(() => { console.log(5); }); console.log(3); </script>
<script> console.log(6) new Promise((resolve) => { resolve() }).then(() => { console.log(7); }); console.log(8); </script>
复制代码
参考文章:
梁音.JavaScript 事件循环及异步原理(彻底指北); 代码题目取自该文章,其文章后面最后还有一个进阶题,有兴趣伙伴能够研究下。