Javascript的事件循环机制

251 阅读11分钟

Javascript的事件循环机制

1、事件循环机制

这是一个总体图 clipboard.png

2、js的运行机制

  JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
  因为单线程,所以javascript在同一时间只能做一件事情--单线程,这就造成了一些局限性,比如我们在页面加载数据的时候,如果是按照单线程的方式运行,一旦http请求向服务器发送,就会等待数据返回之前出现页面等待(假死)的现象,但是在实际的开发使用中并没有这样的现象,这存在一个同步和异步的机制。

  JS做的任务分为同步和异步两种,所谓 "异步",简单说就是一个任务不是连续完成的,先执行第一段,等做好了准备,再回过头执行第二段,第二段也被叫做回调;同步则是连贯完成的。
  采用的这种“异步任务回调通知模式”优势:完成相同的任务,花费的时间大大减少,这种方式也被叫做非阻塞式。而实现这个“通知”的,正是事件循环,把异步任务的回调部分交给事件循环,等时机合适交还给JS线程执行。
  事件循环并不是JavaScript首创的,它是计算机的一种运行机制。事件循环是由一个队列组成的,异步任务的回调遵循先进先出,在JS引擎空闲时会一轮一轮地被取出,所以被叫做循环。

2.1 同步(阻塞)

  同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的⽅式)执⾏代码逻辑,进⾏代码的解释和运⾏,所以在运⾏代码时,不会出现先运⾏3、4⾏的代码,再回头运⾏1、2⾏的代码这种情况。

var a = 1 
var b = 2 
var c = a + b //这个例⼦总c⼀定是3不会出现先执⾏第三⾏然后在执⾏第⼆⾏和第⼀⾏的情况 
console.log(c)

  再比如

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)

  代码执行到时间d1和当前时间相差小于2s的时候,不断刷新d2时间,直到当前时间和d1 间隔>= 2s才会继续执行下一步。这就导致了程序阻塞的出现,这也是为什么将同步的代码运⾏机制叫 做阻塞式运⾏的原因。阻塞式运⾏的代码,在遇到消耗时间的代码⽚段时,之后的代码都必须等待耗时的代码运⾏完毕,才能得到执⾏资 源,这就是单线程同步的特点。

2.2 异步(非阻塞)

  依旧是按照单线程(从上到下、从左到右的⽅式)执⾏代码逻辑,在解释时,如遇到异步模式的代码,引擎会把当前的任务挂起(后面js运行模型,详解),继续执行同步代码,直到执行完所有的同步代码后,按照特定的顺序执行已经准备好的,挂起的异步代码,再去执行回调,所以异步代码不会阻塞同步代码的执行,并且异步代码并不是代表进入了新的线程同时执行,而是等待同步代码执行完成后再进行工作,javascript是单线程的
例1:

var a = 1 
var b = 2 
setTimeout(function(){ 
    console.log('执行了异步代码') 
},2000) 
console.log(a+b)
//这段代码表示,遇到异步代码setTimeout,并不会立马执行,而是会等待同步代码执行完毕后再执行异步函数

例2:

var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
setTimeout(function () {
    console.log('我是⼀个异步任务')
}, 1000)
while (d2 - d1 < 3000) {
    d2 = new Date().getTime()
}
console.log(a + b) //输出3
//这段代码在输出3之前会进⼊假死状态,,执行完同步任务,输出3,才会执行'我是⼀个异步任务',即使setTimeout延时是1s。

总结: JavaScript的运⾏顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任 务执⾏完毕之后才能执⾏。

2.3 js线程的组成

  虽然浏览器是单线程执⾏JavaScript代码的,但是浏览器实际是以多个线程协助操 作来实现单线程异步模型的,具体线程组成如下:

  1. GUI渲染线程
  2. JavaScript引擎线程
  3. 事件触发线程
  4. 定时器触发线程
  5. http请求线程
  6. 其他线程 按照真实的浏览器线程组成分析,我们会发现实际上运⾏JavaScript的线程其实并不是⼀个,但是为什么说 JavaScript是⼀⻔单线程的语⾔呢?因为这些线程中实际参与代码执⾏的线程并不是所有线程,⽐如GUI渲染线程为 什么单独存在,这个是防⽌我们在html⽹⻚渲染⼀半的时候突然执⾏了⼀段阻塞式的JS代码⽽导致⽹⻚卡在⼀半停 住这种效果。在JavaScript代码运⾏的过程中实际执⾏程序时同时只存在⼀个活动线程,这⾥实现同步异步就是靠 多线程切换的形式来进⾏实现的。

3、Javascript的运行模型

image.png   上图是JavaScript运⾏时的⼀个⼯作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们JavaScript 执⾏代码的线程,主线程代码在运⾏时,会按照同步和异步代码将其分成两个去处,如果是同步代码执⾏,就会直接将该任务放在⼀个叫做“函数执⾏栈”的空间进⾏执⾏,执⾏栈是典型的【栈结构】(先进后出),程序在运⾏的时候会将同步代码按顺序⼊函数执行栈,将异步代码放到【⼯作线程】中暂时挂起,【⼯作线程】中保存的是定时任务函 数、JS的交互事件、JS的⽹络请求等耗时操作。当【主线程】将代码块筛选完毕后,进⼊执⾏栈的函数会按照从外到内的顺序依次运⾏,运⾏中涉及到的对象数据是在堆内存中进⾏保存和管理的。当执⾏栈内的任务全部执⾏完毕后,执⾏栈就会清空。执⾏栈清空后,“事件循环”就会⼯作,“事件循环”会检测【任务队列】中是否有要执⾏的任务,那么这个任务队列的任务来源就是⼯作线程,程序运⾏期间,⼯作线程会把到期的定时任务、返回数据的http 任务等【异步任务】按照先后顺序插⼊到【任务队列】中,等执⾏栈清空后,事件循环会访问任务队列,将任务队 列中存在的任务,按顺序(先进先出)放在执⾏栈中继续执⾏,直到任务队列清空

例子3:

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. 代码解析的时候会按照从上到下从左到右执行。
  2. 运行就会按照 task1, task2,task3,task4解析,解析过程中限制性 task1(),继续执行发现task2,task3被setTimeout进行了定时托管,异步任务被挂起,主线程先执行task1(),再执行task4(),将setTimeout(task2,1000) setTimeout(task3,500),先放到工作线程,定时任务到了就推送到任务队列中。
  3. 执行栈中执行完同步任务,task1(),task4(), 去任务队列中寻找是否有需要执行的任务。
  4. 注意的是,setTimeout(task2,1000) setTimeout(task3,500)都在工作线程被挂起,但是定时托管,setTimeout(task3,500)时间先到,所以先被推送到任务队列中,所以先在执行栈中执行,输出顺序应该为 第⼀个任务、第四个任务、第三个任务、第⼆个任务。 图形解析:

image.png

image.png 当工作线程中没有异步任务,任务队列中的所有任务执行完毕,清空任务队列,一个事件循环完成。

4、关于执行栈

  前面说的执行栈中先进后出的原则,和执行任务中的执行函数的工作,现在简单说一下。

例子4

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执⾏完毕')

image.png

image.png 文字解释:

  1. 第⼀次执⾏的时候调⽤task1函数执⾏到console.log的时候先进⾏输出,接下来会遇到task2函数的调⽤,将task2 放到栈顶并执行他,这时task1函数就会暂时停顿,输出“task2执行”。
  2. 执行到task2中还有调用task3的函数,那么将task3函数放到栈顶,继续进入task3函数,输出“task3执行”,
  3. 执行完task3中的输出之后,task3函数中没有其他代码了,那么task3 就算执行完成,执行出栈销毁。
  4. 那么task2回到栈顶,执行刚才“taks3执行”之后的代码,也就是“task3完毕”,那么task2也执行完毕,相同的出栈,销毁。
  5. task1也是和上面一样,出栈,销毁。 总结: 这就是前面说的,栈结构,先进后出

5、宏任务和微任务

  在JavaScript运⾏环境中,包括主线程代码在内,可以理解为所有的任务内部都存在⼀个微任务队列,在每下⼀个宏任务执⾏前,事件循环系统都会先检测当前的代码块中是否包含已经注册的微任务,并将队列中的微任务优先执⾏完毕,进⽽执⾏下⼀个宏任务。

image.png

5.1 宏任务

  宏任务是JavaScript中最原始的异步任务,包括setTimeoutsetInterValAJAX等,在代码执⾏环境中按照同步代码的顺序,逐个进⼊⼯作线程挂起,再按照异步任务到达的时间节点,逐个进⼊异步任务队列,最终按照队列中的 顺序进⼊函数执⾏栈进⾏执⾏

5.2 微任务

  微任务是随着ECMA标准升级提出的新的异步任务,微任务在异步任务队列的基础上增加了【微任务】的概念,每⼀个宏任务执⾏前,程序会先检测中是否有当次事件循环未执⾏的微任务,优先清空本次的微任务后,再执⾏下⼀个宏任务,每⼀个宏任务内部可注册当次任务的微任务队列,再下⼀个宏任务执⾏前运⾏,微任务也是按照进⼊队列的顺序执⾏的。

总结: 事件循环由宏任务和在执行宏任务期间产生的所有微任务组成。完成当下的宏任务后,会立刻执行所有在此期间入队的微任务
  这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。区分了微任务和宏任务后,本轮循环中的微任务实际上就是在插队,这样微任务中所做的状态修改,在下一轮事件循环中也能得到同步。

例子5

 Promise.resolve().then(() => {
    console.log('第一个回调函数:微任务1')
    setTimeout(() => {
        console.log('第三个回调函数:宏任务2')
    }, 0)
})
setTimeout(() => {
    console.log('第二个回调函数:宏任务1')
    Promise.resolve().then(() => {
        console.log('第四个回调函数:微任务2')
    })
}, 0)
//第一个回调函数:微任务1
//第二个回调函数:宏任务1
//第四个回调函数:微任务2
//第三个回调函数:宏任务2

  当然,明白哪些操作是宏任务、哪些是微任务就变得很关键,这是目前业界比较流行的说法: image.png   有些地方会列出来UI Rendering,说这个也是宏任务,可是在读了HTML规范文档以后,发现这很显然是和微任务平行的一个操作步骤
  requestAnimationFrame姑且也算是宏任务吧,requestAnimationFrameMDN的定义为,下次页面重绘前所执行的操作,而重绘也是作为宏任务的一个步骤来存在的,且该步骤晚于微任务的执行。 如果所示,浏览器和node环境下的微,宏任务是不一样的,这里在暂时只做浏览器的讨论。