1.单线程的js
js区别于其他语言即在于它的单线程特性,考虑到它的主要执行环境(浏览器),这样设计也是情理之中:
- js在运行时频繁操作DOM是很常见的业务需求,试想如果同时有多个事件同时在操作同一个DOM节点,浏览器怕是分分钟要崩掉。
- 虽然HTML5中提出
Web Worker
允许多个线程同时运行js代码,但其实质也只是在主线程中分出几个子线程,并没有改变js单线程的本质。并且在多个子线程中运行的js代码是不允许操作DOM的。
2.为什么要有事件循环
事件驱动的js程序难免在程序设计时出现诸多事件,而依靠单线程来执行这些事件,若只是按部就班当一个事件执行完成后再执行另一个事件,那当遇到费时很长的比如ajax请求时,程序岂不是要死掉,依据互联网行业的八秒原则,怕是用户要跑的差不多了。遇到这种情况,如果是别的语言,还可以利用多线程将事件分发,避免造成阻塞。而js的解决方案则是异步处理事件,提到异步则是依赖于我们要谈的事件循环(Event Loop)。下面我们会从浏览器和node环境来谈各自的事件循环具体实现。
3.浏览器端
先从一张图看一下浏览器事件循环的运行机制(图片稍有不完整,会在后面解释中补充)
- 执行栈:
- js引擎在执行js代码时,会维护一个执行栈,这个栈用来存放要执行的事件。
- 执行栈中只会存在一个事件,所谓js的执行过程不过就是各种事件不断入栈->执行->出栈的过程罢了。
- 同步/异步事件
- 记得前面有提到js对于事件的异步处理方法,在这里事件被分为同步事件和异步事件。
- 同步事件比如一些立马执行的语句,比如很常见的
console.log("我是同步的")
,而异步事件则比如一些ajax
请求的回调函数,一些鼠标,键盘事件等。 - 执行过程
- js在执行时(从上到下,逐条执行)碰到同步事件则会压入执行栈,然后被执行,出栈。而当遇到异步事件则会被注册进
Event-Table
中,待事件有结果(比如ajax请求完成,setTimeout
到了延时的时间)时就会被推入事件队列中,就是上图中的callback queue
。当同步任务执行完成后(即主线程中执行栈为空时),event loop
就会去检查我们这里的是事件队列,若存在事件则依次进入执行栈被执行。 - 说了这么多,举个栗子吧
setTimeout(()=>{ console.log('我是延时为3s的定时器') },3000) setTimeout(()=>{ console.log('我是延时为0的定时器') },0) console.log('我是同步的')复制代码
- 先来看执行效果
我是同步的 我是延时为0的定时器 我是延时为3s的定时器复制代码
- 执行过程:
- 先碰到
setTimeout(3s)
,异步事件,将他注册进event-table
中。 - 接下来是
setTimeout(0s)
,同样是异步事件,注册进event-table
中。 - 接着碰到
console.log('我是同步的')
识别为同步事件,进入执行栈,执行,之后出栈。 - 此时我们的执行栈已经为空,
event loop
就会去事件队列中去找是不是有待执行的异步事件。此时延时为0s的定时器肯定已经在队列中了,进入执行栈执行对应的回调函数。接着3s时延时为3s的定时器也会进入事件队列中,和上面的一样被执行。(这里我们看到延时的时间只是它被注册进事件队列的时间,而实际被执行的时间或许会因为事件过多而有延迟,毕竟执行栈中始终只会有一个事件被执行,资源有限,先到先得) - 其实所谓事件循环可以理解成一个死循环,始终在执行栈于事件队列之间交替检查。
- 在上面,我们将事件分为同步事件和异步事件,而实际中对异步事件(也不能完全说是对异步事件的划分,后面解释)有更细的划分。
- microTask(微事件)和macroTask(宏事件)
- 宏事件:
script
(这里经常指的是<script>
标签,作为程序入口,所以我们前面说是对异步事件的划分有点不严谨),setTimeout
,setInterval
等 - 微事件:
promise
等 - 这里主要是为了解决
event loop
在拉取事件时像下面这种情况的尴尬//event-table注册了以下事件 setTimeout(()=>{},0); new Promise(function (resolve,reject){ resolve() }).then(()=>{ //statement }) //到底谁应该被先入队复制代码
- 所以在事件循环中将事件队列分为宏事件队列和微事件队列,异步事件将在有结果时会被分发进各自的队列。
- 对于宏事件和微事件的执行原则
- 将以
script
为第一个宏事件去开始第一轮循环。 - 一次只执行一个宏事件。
- 执行完一个宏事件后会清空此时微事件队列中的所有事件,一次循环完成。
- 去执行宏事件队列中的下一个事件...
- 一张图去理解(引用地址:https://juejin.im/post/59e85eebf265da430d571f89)
- 到这我们的浏览器端的事件循环就结束了,接下来是node环境中的事件循环,先来张图过渡一下。(图片来自李锴的《新时期的node.js入门》)
4.node端
- 作为服务端的js运行环境,node在处理高并发的服务端需求时表现极为出色,而这一特性也于我们的事件循环息息相关。
- 从前面的图来看,node的事件循环是按阶段来的,下面我们来分阶段介绍
- timers:这里维护着一个专门针对
setTimeout
,setInterval
的事件队列。定时器会在有结果(到达延时时间时)被注册进这个队列中后面称timer queue
。 - IO callbacks:这个阶段处理一些上一轮循环少数未执行的I/O回调。
- idle,prepare:内部实现,与代码中事件循环无关。
- poll:这个阶段可以看成整个事件循环的主导者,绝大部分事件循环在这个部分完成。具体过程后面专门介绍。
- check:本阶段主要维护针对
setImmediate(在当前事件循环的结尾执行)
的事件队列(后面称check queue
)。 - close callback:主要处理一些关闭连接的回调。
- 主要来说poll阶段
- poll:这里我们把他解释为轮询
- poll主要干两件事:
- 检查timer维护的事件的事件队列中是否有事件被分发进队列
- 执行poll阶段自己维护的事件队列
- 进入poll阶段时:
- 检查并执行poll事件队列(后面称
poll queue
)中的事件 - 当
poll queue
为空时检查check queue,若不为空进入check阶段,执行。 - 检查
timer queue
是否为空,若不为空进入timer阶段,执行。 - 这里有一个特例
process.nextTick
- 用来定义一个异步事件,这个事件将在当前事件循环阶段结束后执行,注意与
setImmediate
的区别,所以说如果这两个同时存在于一轮循环中,process.nextTick总是在setImmediate
之前执行 - 这个方法定义的事件将被分发到
nextTick queue
中。 - 再来提起我们很熟悉的宏事件和微事件。
- node中也将异步事件分为宏事件和微事件来管理执行顺序,执行原则在最新的node标准中于浏览器一致,这里有一张图很清晰的解释执行顺序(图片来源:https://juejin.im/post/5c337ae06fb9a049bc4cd218)
5.后记
根据个人理解以及整理得,有错误或者偏差敬请原谅,欢迎指正。