从头认识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异步编程中占据的?重是?常?的,在?作中可使?场景 也是众多的,掌握了事件循环模型就相当于,异步编程的能?上升了?个新的?度。

js