共计 7857 个字符,预计需要花费 20 分钟才能阅读完成。
转自:面试必问之 JS 事件循环(Event Loop),看这一篇足够!
了解 JavaScript 的事件循环往往随同着宏工作和微工作、JavaScript 单线程执行过程及浏览器异步机制等相干问题,而浏览器和 NodeJS 中的事件循环实现也是有很大差异。相熟事件循环,理解浏览器运行机制将对咱们了解 JavaScript 的执行过程,以及在排查代码运行问题时有很大帮忙。
本文将在浏览器异步执行原理和事件驱动的了解根底上,具体介绍 JavaScript 的事件循环机制以及在浏览器和 NodeJS 中的不同体现。
浏览器 JS 异步执行的原理
JS 是单线程的,也就是同一个时刻只能做一件事件,那么思考:为什么浏览器能够同时执行异步工作呢?
因为浏览器是多线程的,当 JS 须要执行异步工作时,浏览器会另外启动一个线程去执行该工作。也就是说,“JS 是单线程的”指的是执行 JS 代码的线程只有一个,是浏览器提供的 JS 引擎线程(主线程)。浏览器中还有定时器线程和 HTTP 申请线程等,这些线程次要不是来跑 JS 代码的。
比方主线程中须要发一个 AJAX 申请,就把这个工作交给另一个浏览器线程(HTTP 申请线程)去真正发送申请,待申请回来了,再将 callback 里须要执行的 JS 回调交给 JS 引擎线程去执行。即浏览器才是真正执行发送申请这个工作的角色,而 JS 只是负责执行最初的回调解决。所以这里的异步不是 JS 本身实现的,其实是浏览器为其提供的能力。
以 Chrome 为例,浏览器不仅有多个线程,还有多个过程,如渲染过程、GPU 过程和插件过程等。而每个 tab 标签页都是一个独立的渲染过程,所以一个 tab 异样解体后,其余 tab 根本不会被影响。作为前端开发者,次要重点关注其渲染过程,渲染过程下蕴含了 JS 引擎线程、HTTP 申请线程和定时器线程等,这些线程为 JS 在浏览器中实现异步工作提供了根底。
事件驱动浅析
浏览器异步工作的执行原理背地其实是一套事件驱动的机制。事件触发、工作抉择和工作执行都是由事件驱动机制来实现的。NodeJS 和浏览器的设计都是基于事件驱动的,简而言之就是由特定的事件来触发特定的工作,这里的事件能够是用户的操作触发的,如 click 事件;也能够是程序主动触发的,比方浏览器中定时器线程在计时完结后会触发定时器事件。而本文的主题内容事件循环其实就是在事件驱动模式中来治理和执行事件的一套流程。
以一个简略场景为例,假如游戏界面上有一个挪动按钮和人物模型,每次点击右移后,人物模型的地位须要从新渲染,右移 1 像素。依据渲染机会的不同咱们能够用不同的形式来实现。
实现形式一:事件驱动。点击按钮后,批改坐标 positionX 时,立刻触发界面渲染的事件,触发从新渲染。
实现形式二:状态驱动或数据驱动。点击按钮后,只批改坐标 positionX,不触发界面渲染。在此之前会启动一个定时器 setInterval,或者利用 requestAnimationFrame 来一直地检测 positionX 是否有变动。如果有变动,则立刻从新渲染。
浏览器中的点击事件处理也是典型的基于事件驱动。在事件驱动中,当有事件触发后,被触发的事件会按程序临时存在一个队列中,待 JS 的同步工作执行实现后,会从这个队列中取出要解决的事件并进行解决。那么具体什么时候取工作、优先取哪些工作,这就由事件循环流程来管制了。
浏览器中的事件循环
执行栈与工作队列
JS 在解析一段代码时,会将同步代码按程序排在某个中央,即执行栈,而后顺次执行外面的函数。当遇到异步工作时就交给其余线程解决,待以后执行栈所有同步代码执行实现后,会从一个队列中去取出已实现的异步工作的回调退出执行栈继续执行,遇到异步工作时又交给其余线程,…..,如此周而复始。而其余异步工作实现后,将回调放入工作队列中待执行栈来取出执行。
JS 按程序执行执行栈中的办法,每次执行一个办法时,会为这个办法生成独有的执行环境(上下文 context),待这个办法执行实现后,销毁以后的执行环境,并从栈中弹出此办法(即生产实现),而后持续下一个办法。
可见,在事件驱动的模式下,至多蕴含了一个执行循环来检测工作队列是否有新的工作。通过一直循环去取出异步回调来执行,这个过程就是事件循环,而每一次循环就是一个事件周期或称为一次 tick。
宏工作和微工作
工作队列不只一个,依据工作的品种不同,能够分为微工作(micro task)队列和宏工作(macro task)队列。
事件循环的过程中,执行栈在同步代码执行实现后,优先查看微工作队列是否有工作须要执行,如果没有,再去宏工作队列查看是否有工作执行,如此往返。微工作个别在以后循环就会优先执行,而宏工作会等到下一次循环,因而,微工作个别比宏工作先执行,并且微工作队列只有一个,宏工作队列可能有多个。另外咱们常见的点击和键盘等事件也属于宏工作。
常见宏工作:
- setTimeout()
- setInterval()
- setImmediate()
常见微工作:
- promise.then()、promise.catch()
- new MutaionObserver()
- process.nextTick()
console.log('同步代码 1');
setTimeout(() => {console.log('setTimeout')
}, 0)
new Promise((resolve) => {console.log('同步代码 2')
resolve()}).then(() => {console.log('promise.then')
})
console.log('同步代码 3');
下面的代码将按如下程序输入为:” 同步代码 1″、” 同步代码 2″、” 同步代码 3″、”promise.then”、”setTimeout”,具体分析如下:
-
setTimeout 回调和 promise.then 都是异步执行的,将在所有同步代码之后执行;
顺便提一下,在浏览器中 setTimeout 的延时设置为 0 的话,会默认为 4ms,NodeJS 为 1ms。具体值可能不固定,但不是为 0。
- 尽管 promise.then 写在前面,然而执行程序却比 setTimeout 优先,因为它是微工作;
- new Promise 是同步执行的,promise.then 外面的回调才是异步的。
也有人这样去了解:微工作是在以后事件循环的尾部去执行;宏工作是在下一次事件循环的开始去执行。咱们来看看微工作和宏工作的本质区别是什么。
咱们曾经晓得,JS 遇到异步工作时会将此工作交给其余线程去解决,本人的主线程持续往后执行同步工作。比方 setTimeout 的计时会由浏览器的定时器线程来解决,待计时完结,就将定时器回调工作放入工作队列期待主线程来取出执行。后面咱们提到,因为 JS 是单线程执行的,所以要执行异步工作,就须要浏览器其余线程来辅助,即多线程是 JS 异步工作的一个显著特色。
咱们再来剖析下 promise.then(微工作)的解决。当执行到 promise.then 时,V8 引擎不会将异步工作交给浏览器其余线程,而是将回调存在本人的一个队列中,待以后执行栈执行实现后,立马去执行 promise.then 寄存的队列,promise.then 微工作没有多线程参加,甚至从某些角度说,微工作都不能齐全算是异步,它只是将书写时的代码批改了执行程序而已。
setTimeout 有“定时期待”这个工作,须要定时器线程执行;ajax 申请有“发送申请”这个工作,须要 HTTP 线程执行,而 promise.then 它没有任何异步工作须要其余线程执行,它只有回调,即便有,也只是外部嵌套的另一个宏工作。
简略小结一下微工作和宏工作的本质区别:
宏工作特色:有明确的异步工作须要执行和回调;须要其余异步线程反对。
微工作特色:没有明确的异步工作须要执行,只有回调;不须要其余异步线程反对。
定时器误差
事件循环中,总是先执行同步代码后,才会去工作队列中取出异步回调来执行。当执行 setTimeout 时,浏览器启动新的线程去计时,计时完结后触发定时器事件将回调存入宏工作队列,期待 JS 主线程来取出执行。如果这时主线程还在执行同步工作的过程中,那么此时的宏工作就只有先挂起,这就造成了计时器不精确的问题。同步代码耗时越长,计时器的误差就越大。不仅同步代码,因为微工作会优先执行,所以微工作也会影响计时,假如同步代码中有一个死循环或者微工作中递归一直在启动其余微工作,那么宏工作外面的代码可能永远得不到执行。所以主线程代码的执行效率晋升是一件很重要的事件。
一个很简略的场景就是咱们界面上有一个时钟准确到秒,每秒更新一次工夫。你会发现有时候秒数会间接跳过 2 秒距离,就是这个起因。
视图更新渲染
微工作队列执行实现后,也就是一次事件循环完结后,浏览器会执行视图渲染,当然这里会有浏览器的优化,可能会合并屡次循环的后果做一次视图重绘,因而视图更新是在事件循环之后,所以并不是每一次操作 Dom 都肯定会立马刷新视图。视图重绘之前会先执行 requestAnimationFrame 回调,那么对于 requestAnimationFrame 是微工作还是宏工作是有争议的,在这里看来,它应该既不属于微工作,也不属于宏工作。
NodeJS 中的事件循环
JS 引擎自身不实现事件循环机制,这是由它的宿主实现的,浏览器中的事件循环次要是由浏览器来实现,而在 NodeJS 中也有本人的事件循环实现。NodeJS 中也是循环 + 工作队列的流程以及微工作优先于宏工作,大抵体现和浏览器是统一的。不过它与浏览器中也有一些差别,并且新增了一些工作类型和工作阶段。接下来咱们介绍下 NodeJS 中的事件循环流程。
NodeJS 中的异步办法
因为都是基于 V8 引擎,浏览器中蕴含的异步形式在 NodeJS 中也是一样的。另外 NodeJS 中还有一些其余常见异步模式。
文件 I/O:异步加载本地文件。
setImmediate():与 setTimeout 设置 0ms 相似,在某些同步工作实现后立马执行。
process.nextTick():在某些同步工作实现后立马执行。
server.close、socket.on(‘close’,…)等:敞开回调。
设想一下,如果下面的模式和 setTimeout、promise 等同时存在,如何剖析出代码的执行程序呢?只有咱们了解了 NodeJS 的事件循环机制,也就分明了。
事件循环模型
NodeJS 的跨平台能力和事件循环机制都是基于 Libuv 库实现的,你不必关怀这个库的具体内容。咱们只须要晓得 Libuv 库是事件驱动的,并且封装和对立了不同平台的 API 实现。
NodeJS 中 V8 引擎将 JS 代码解析后调用 Node API,而后 Node API 将工作交给 Libuv 去调配,最初再将执行后果返回给 V8 引擎。在 Libux 中实现了一套事件循环流程来治理这些工作的执行,所以 NodeJS 的事件循环次要是在 Libuv 中实现的。
事件循环各阶段
在 NodeJS 中 JS 的执行,咱们次要须要关怀的过程分为以下几个阶段,上面每个阶段都有本人独自的工作队列,当执行到对应阶段时,就判断以后阶段的工作队列是否有须要解决的工作。
- timers 阶段:执行所有 setTimeout() 和 setInterval() 的回调。
- pending callbacks 阶段:某些零碎操作的回调,如 TCP 链接谬误。除了 timers、close、setImmediate 的其余大部分回调在此阶段执行。
- poll 阶段:轮询期待新的链接和申请等事件,执行 I/O 回调等。V8 引擎将 JS 代码解析并传入 Libuv 引擎后首先进入此阶段。如果此阶段工作队列曾经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或期待新的工作进来(如果没有 setImmediate)。在期待新的工作时,如果有 timers 计时到期,则会间接进入 timers 阶段。此阶段可能会阻塞期待。
- check 阶段:setImmediate 回调函数执行。
- close callbacks 阶段:敞开回调执行,如 socket.on(‘close’, …)。
下面每个阶段都会去执行完以后阶段的工作队列,而后继续执行以后阶段的微工作队列,只有以后阶段所有微工作都执行完了,才会进入下个阶段。这里也是与浏览器中逻辑差别较大的中央,不过浏览器不必辨别这些阶段,也少了很多异步操作类型,所以不必刻意去辨别两者区别。代码如下所示:
const fs = require('fs');
fs.readFile(__filename, (data) => {// poll(I/O 回调) 阶段
console.log('readFile')
Promise.resolve().then(() => {console.error('promise1')
})
Promise.resolve().then(() => {console.error('promise2')
})
});
setTimeout(() => {
// timers 阶段
console.log('timeout');
Promise.resolve().then(() => {console.error('promise3')
})
Promise.resolve().then(() => {console.error('promise4')
})
}, 0);
// 上面代码只是为了同步阻塞 1 秒钟,确保下面的异步工作曾经筹备好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {endTime = new Date().getTime();}
// 最终输入 timeout promise3 promise4 readFile promise1 promise2
另一个与浏览器的差别还体现在同一个阶段里的不同工作执行,在 timers 阶段外面的宏工作、微工作测试代码如下所示:
setTimeout(() => {console.log('timeout1')
Promise.resolve().then(function() {console.log('promise1')
})
}, 0);
setTimeout(() => {console.log('timeout2')
Promise.resolve().then(function() {console.log('promise2')
})
}, 0);
- 浏览器中运行
每次宏工作实现后都会优先解决微工作,输入“timeout1”、“promise1”、“timeout2”、“promise2”。 - NodeJS 中运行
因为输入 timeout1 时,以后正处于 timers 阶段,所以会先将所有 timer 回调执行完之后再执行微工作队列,即输入“timeout1”、“timeout2”、“promise1”、“promise2”。
下面的差别能够用浏览器和 NodeJS 10 比照验证。是不是感觉有点反程序员?因而 NodeJS 在版本 11 之后,就批改了此处逻辑使其与浏览器尽量统一,也就是每个 timer 执行后都先去检查一下微工作队列,所以 NodeJS 11 之后的输入曾经和浏览器统一了。
nextTick、setImmediate 和 setTimeout
理论我的项目中咱们罕用 Promise 或者 setTimeout 来做一些须要延时的工作,比方一些耗时计算或者日志上传等,目标是不心愿它的执行占用主线程的工夫或者须要依赖整个同步代码执行实现后的后果。
NodeJS 中的 process.nextTick() 和 setImmediate() 也有相似成果。其中 setImmediate() 咱们后面曾经讲了是在 check 阶段执行的,而 process.nextTick() 的执行机会不太一样,它比 promise.then() 的执行还早,在同步工作之后,其余所有异步工作之前,会优先执行 nextTick。能够设想是把 nextTick 的工作放到了以后循环的前面,与 promise.then() 相似,但比 promise.then() 更后面。意思就是在以后同步代码执行实现后,不论其余异步工作,先尽快执行 nextTick。
etTimeout(() => {console.log('timeout');
}, 0);
Promise.resolve().then(() => {console.error('promise')
})
process.nextTick(() => {console.error('nextTick')
})
// 输入:nextTick、promise、timeout
接下来咱们再来看看 setImmediate 和 setTimeout,它们是属于不同的执行阶段了,别离是 timers 阶段和 check 阶段。
setTimeout(() => {console.log('timeout');
}, 0);
setImmediate(() => {console.log('setImmediate');
});
// 输入:timeout、setImmediate
剖析下面代码,第一轮循环后,别离将 setTimeout 和 setImmediate 退出了各自阶段的工作队列。第二轮循环首先进入 timers 阶段,执行定时器队列回调,而后 pending callbacks 和 poll 阶段没有工作,因而进入 check 阶段执行 setImmediate 回调。所以最初输入为“timeout”、“setImmediate”。当然这里还有种实践上的极其状况,就是第一轮循环完结后耗时很短,导致 setTimeout 的计时还没完结,此时第二轮循环则会先执行 setImmediate 回调。
再看这上面一段代码,它只是把上一段代码放在了一个 I/O 工作回调中,它的输入将与上一段代码相同。
const fs = require('fs');
fs.readFile(__filename, (data) => {console.log('readFile');
setTimeout(() => {console.log('timeout');
}, 0);
setImmediate(() => {console.log('setImmediate');
});
});
// 输入:readFile、setImmediate、timeout
如下面代码所示:
- 第一轮循环没有须要执行的异步工作队列;
- 第二轮循环 timers 等阶段都没有工作,只有 poll 阶段有 I/O 回调工作,即输入“readFile”;
- 参考后面事件阶段的阐明,接下来,poll 阶段会检测如果有 setImmediate 的工作队列则进入 check 阶段,否则再进行判断,如果有定时器工作回调,则回到 timers 阶段,所以应该进入 check 阶段执行 setImmediate,输入“setImmediate”;
- 而后进入最初的 close callbacks 阶段,本次循环完结;
- 最初进行第三轮循环,进入 timers 阶段,输入“timeout”。
所以最终输入“setImmediate”在“timeout”之前。可见这两者的执行程序与以后执行的阶段有关系。
总结
本文具体解说了浏览器和 NodeJS 中事件循环的流程,尽管底层机制不一样,但在最终体现上是基本一致的。了解事件循环的原理,能够帮忙咱们精确剖析和使用各种异步模式,缩小代码的不确定性,在一些执行效率优化上也能有明确的思路。