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 中.
再来提起我们很熟悉的宏事件和微事件.
来源: https://juejin.im/post/5c681a4f6fb9a04a04418f24