从头认识JavaScript的事件循环模型
1. JS的运?机制
介绍
众所周知JavaScript是??单线程的语?,所以在JavaScript的世界中默认的情况下同?个时间节点只能做?件事 情,这样的设定就造成了JavaScript这?语?的?些局限性,?如在我们的??中加载?些远程数据时,如果按照 单线程同步的?式运?,?旦有HTTP请求向服务器发送,就会出现等待数据返回之前??假死的效果出现。因为 JavaScript在同?个时间只能做?件事,这就导致了??渲染和事件的执?,在这个过程中?法进?。显然在实际 的开发中我们并没有遇?过这种情况。
关于同步和异步
同步(阻塞): 同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的?式)执?代码逻辑,进?代码的解释和运 ?,所以在运?代码时,不会出现先运?4、5?的代码,再回头运?1、3?的代码这种情况。
接下来通过下列的案例升级?下代码的运?场景:
var a = 1 var b = 2 var d1 = new Date().getTime() var d2 = new Date().getTime() while(d2-d1<2000){ d2 = new Date().getTime() } //这段代码在输出结果之前??会进??个类似假死的状态 console.log(a+b)
这就导致了程序阻塞的出现,这也是为什么将同步的代码运?机制叫 做阻塞式运?的原因。
异步(?阻塞):
在上?的阐述中,我们明?了单线程同步模型中的问题所在,接下来引?单线程异步模型的介绍。异步的意思就是 和同步对?,所以异步模式的代码是不会按照默认顺序执?的。JavaScript执?引擎在?作时,仍然是按照从上到 下从左到右的?式解释和运?代码。在解释时,如果遇到异步模式的代码,引擎会将当前的任务“挂起”并略过。也 就是先不执?这段代码,继续向下运??异步模式的代码,那么什么时候来执?同步代码呢?直到同步代码全部执 ?完毕后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进?执?,所以异步代码并不会【阻塞】同步代码 的运?,并且异步代码并不是代表进?新的线程同时执?,?是等待同步代码执?完毕再进??作。我们阅读下? 的代码分析:
var a = 1 var b = 2 setTimeout(function(){ console.log('输出了?些内容') },2000) //这段代码会直接输出3并且等待2秒左右的时间在输出function内部的内容 console.log(a+b)
?阻塞式运?的代码,程序运?到该代码?段时,执?引擎会将程序保存到?个暂存区,等待所有同步代码全部执 ?完毕后,?阻塞式的代码会按照特定的执?顺序,分步执?。这就是单线程异步的特点。
JavaScript的运?顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任 务执?完毕之后才能执?。
经典案例
var a = 1 var b = 2 var d1 = new Date().getTime() var d2 = new Date().getTime() setTimeout(function(){ console.log('我是?个异步任务') },1000) while(d2-d1<2000){ d2 = new Date().getTime() } console.log(a+b)
//这段代码在输出3之前会进?假死状态,'我是?个异步任务'?定会在3之后输出,
异步任务放在暂存区,等待同步任务执行完再执行,无论同步任务等待多久
JS的线程组成
在了解线程组成前要了解?点,虽然浏览器是单线程执?JavaScript代码的,但是浏览器实际是以多个线程协助操 作来实现单线程异步模型的,具体线程组成如下:
1. GUI渲染线程
2. JavaScript引擎线程
3. 事件触发线程
4. 定时器触发线程
5. http请求线程
6. 其他线程
按照真实的浏览器线程组成分析,我们会发现实际上运?JavaScript的线程其实并不是?个,但是为什么说 JavaScript是??单线程的语?呢?因为这些线程中实际参与代码执?的线程并不是所有线程,?如GUI渲染线程为 什么单独存在,这个是防?我们在html??渲染?半的时候突然执?了?段阻塞式的JS代码?导致??卡在?半停 住这种效果。在JavaScript代码运?的过程中实际执?程序时同时只存在?个活动线程,这?实现同步异步就是靠 多线程切换的形式来进?实现的。是由多个线程组成的单线程,切换实现,同时只存在一个线程。
2. JavaScript的运?模型
上图是JavaScript运?时的?个?作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们JavaScript 执?代码的线程,主线程代码在运?时,会按照同步和异步代码将其分成两个去处,如果是同步代码执?,就会直 接将该任务放在?个叫做“函数执?栈”的空间进?执?,执?栈是典型的【栈结构】(先进后出),程序在运?的 时候会将同步代码按顺序?栈,将异步代码放到【?作线程】中暂时挂起,【?作线程】中保存的是定时任务函 数、JS的交互事件、JS的?络请求等耗时操作。当【主线程】将代码块筛选完毕后,进?执?栈的函数会按照从外 到内的顺序依次运?,运?中涉及到的对象数据是在堆内存中进?保存和管理的。【1】(知识点,深浅拷贝,基本数据类型和引用数据类型)当执?栈内的任务全部执?完毕 后,执?栈就会清空。执?栈清空后,“事件循环”就会?作,“事件循环”会检测【任务队列】中是否有要执?的任 务,那么这个任务队列的任务来源就是?作线程,程序运?期间,?作线程会把到期的定时任务、返回数据的http 任务等【异步任务】按照先后顺序插?到【任务队列】中,等执?栈清空后,事件循环会访问任务队列,将任务队 列中存在的任务,按顺序(先进先出)放在执?栈中继续执?,直到任务队列清空。
【1】引申知识数据类型
数据分为基本数据类型(String, Number, Boolean, Null, Undefined,Symbol(new in ES 6)和 引用数据类型(统称为 Object 类型,细分的话有:Object 、Array 、Date 、RegExp、Function… )。
-
基本数据类型的特点:直接存储在栈(stack)中的数据
- 引用数据类型的特点:存储的是该对象在栈中引用,真实的数据存放在堆内存里
引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
从代码?段开始分析
function task1() { console.log('第?个任务') } function task2() { console.log('第?个任务') } function task3() { console.log('第三个任务') } function task4() { console.log('第四个任务') } task1() setTimeout(task2, 1000) setTimeout(task3, 500) task4()
如上图,在上述代码刚开始运?的时候我们的主线程即将?作,按照顺序从上到下进?解释执?,此时执?栈、? 作线程、任务队列都是空的,事件循环也没有?作。接下来我们分析下?个阶段程序做了什么事情。
结合上图可以看出程序在主线程执?之后就将任务1、4和任务2、3分别放进了两个?向,任务1和任务4都是?即 执?任务所以会按照1->4的顺序进栈出栈(这?由于任务1和2是平?任务所以会先执?任务1的进出栈再执?任务 4的进出栈),?任务2和任务3由于是异步任务就会进??作线程挂起并开始计时,并不影响主线程运?,此时的 任务队列还是空置的。
我们发现同步任务的执?速度是?快的,这样?下执?栈已经空了,?任务2和任务3还没有到时间,这样我们的事 件循环就会开始?作等待任务队列中的任务进?,接下来就是执?异步任务的时候了。
我们发现任务队列并不是?下?就会将任务2和任务三?起放进去,?是哪个计时器到时间了哪个放进去,这样我 们的事件循环就会发现队列中的任务,并且将任务拿到执?栈中进?消费,此时会输出任务3的内容。
到这就是最后?次执?,当执?完毕后?作线程中没有计时任务,任务队列的任务清空程序到此执?完毕。
总结
我们通过图解之后脑??就会更清晰的能搞懂异步任务的执??式了,这?采?最简单的任务模型进?描绘复杂的 任务在内存中的分配和?向是?常复杂的,我们有了这次的经验之后就可以通过观察代码在?脑中先模拟?次执 ?,这样可以更清晰的理解JS的运?机制。
关于执?栈
function task1(){ console.log('task1执?') task2() console.log('task2执?完毕') } function task2(){ console.log('task2执?') task3() console.log('task3执?完毕') } function task3(){ console.log('task3执?') } task1() console.log('task1执?完毕')
操作流程(先进后出)
第?次执?的时候调?task1函数执?到console.log的时候先进?输出,接下来会遇到task2函数的调?会出现下? 的情况:
执?到此时检测到task2中还有调?task3的函数,那么就会继续进?task3中执?,如下图:
在执?完task3中的输出之后task3内部没有其他代码,那么task3函数就算执?完毕那么就会发?出栈?作。
此时我们会发现task3出栈之后程序运??会回到task2的函数中继续他的执?。接下来会发?相同的事情。
再之后就剩下task1??了,他在task2销毁之后输出task2执?完毕后他也会随着出栈?销毁。
当task1执?完毕之后它随着销毁最后??输出,就会进?执?栈执?并销毁,销毁之后执?栈和主线程清空。这 个过程就会出现123321的这个顺序,?且我们在打印输出时,也能通过打印的顺序来理解?栈和出栈的顺序和流 程。
关于递归
阶乘
function compute(n){ if(n === 1) return 1 return n * compute(n-1) }
如 果了解了执?栈的执?逻辑后,递归函数就可以看成是在?个函数中嵌套n层执?,那么在执?过程中会触发?量 的栈帧堆积,如果处理的数据过?,会导致执?栈的?度不够放置新的栈帧,?造成栈溢出的错误。所以我们在做 海量数据递归的时候?定要注意这个问题。
关于执?栈的深度:
执?栈的深度根据不同的浏览器和JS引擎有着不同的区别,我们这?就Chrome浏览器为例?来尝试?下递归的溢 出:
var i = 0; function task(){ i++ console.log(`递归了${i}次`) task() } task()
递归会在执行栈中叠加,以至于溢出
3.宏任务和微任务
记住任务队列中中的宏任务会最佳到任务队列的尾部
宏任务:
宏任务是JavaScript中最原始的异步任务,包括setTimeout、setInterVal、AJAX等,在代码执?环境中按照同步代 码的顺序,逐个进??作线程挂起,再按照异步任务到达的时间节点,逐个进?异步任务队列,最终按照队列中的 顺序进?函数执?栈进?执?。
微任务:
微任务是随着ECMA标准升级提出的新的异步任务,微任务在异步任务队列的基础上增加了【微任务】的概念,每 ?个宏任务执?前,程序会先检测中是否有当次事件循环未执?的微任务,优先清空本次的微任务后,再执?下? 个宏任务,每?个宏任务内部可注册当次任务的微任务队列,再下?个宏任务执?前运?,微任务也是按照进?队 列的顺序执?的。
总结:
在JavaScript的运?环境中,代码的执?流程是这样的: 1. 默认的同步代码按照顺序从上到下,从左到右运?,运?过程中注册本次的微任务和后续的宏任务: 2. 执?本次同步代码中注册的微任务,并向任务队列注册微任务中包含的宏任务和微任务 3. 将下?个宏任务开始前的所有微任务执?完毕 4. 执?最先进?队列的宏任务,并注册当次的微任务和后续的宏任务,宏任务会按照当前任务队列的队尾继续向 下排列
经典笔试题
代码输出顺序问题1
setTimeout(function() {console.log('timer1')}, 0)
requestAnimationFrame(function(){
console.log('UI update')
})
setTimeout(function() {console.log('timer2')}, 0)
new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})
console.log('end')
requestAnimationFrame是请求下?次重绘事件,所以 他的执?频率要参考浏览器的刷新率。
所以会有两个答案
代码输出顺序问题2
document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(1)); console.log(2); }) document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(3)); console.log(4); })
这个案例代码简单易懂,但是很容易引起错误答案的出现。由于该事件是直接绑定在document上的,所以点击? ?就会触发该事件,在代码运?时相当于按照顺序注册了两个点击事件,两个点击事件会被放在?作线程中实时监 听触发时机,当元素被点击时,两个事件会按照先后的注册顺序放?异步任务队列中进?执?,所以事件1和事件2 会按照代码编写的顺序触发。
这?就会导致有?分析出错误答案:2,4,1,3。
为什么不是2,4,1,3呢?由于事件执?时并不会阻断JS默认代码的运?,所以事件任务也是异步任务,并且是宏 任务,所以两个事件相当于按顺序执?的两个宏任务。 这样就会分出两个运?环境,第?个事件执?时,console.log(2);是第?个宏任务中的同步代码,所以他会?即执 ?,?Promise.resolve().then(()=> console.log(1));属于微任务,他会在下?个宏任务触发前执?,所以这?输出 2后会直接输出1. ?下?个事件的内容是相同道理,所以输出顺序为:2,1,4,3。
document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(1)); console.log(2); settimeOut(()=>{console.log(5)}) }) document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(3)); console.log(4); settimeOut(()=>{console.log(6)}) })
还记得上面的重点吗
异步任务队列中的遇到宏任务会放到队列的最后
214356
总结
关于事件循环模型今天就介绍到这?,在NodeJS中的事件循环模型和浏览器中是不?样的,本?是以浏览器的事 件循环模型为基础进?介绍,事件循环系统在JavaScript异步编程中占据的?重是?常?的,在?作中可使?场景 也是众多的,掌握了事件循环模型就相当于,异步编程的能?上升了?个新的?度。