处理事件(事件循环机制)
当事件发生时,浏览器调用相应的事件处理器,由于
js
是单线程执行,在用一个时刻只能处理一个事件。
在事件处理阶段,例如用户发生了移动和点击事件。会把这些事件放在事件队列里边,事件循环会检查队列,发现队列的前面有一个移动事件,移动事件处理完后,js
退出处理器函数,事件循环会再次检查队列,这一次,在队列的最前面,发生了点击事件,然后处理完在退出。一旦单击处理器执行完成。队列中不在有新的事件,事件循环会继续循环等待新的事件。这个循环会一直执行到用户关闭web应用。
深入事件循环
事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作。这些操作被称为任务,并且分为两类:宏任务和微任务。
宏任务:建立主文档对象、解析HTML、执行主线(或全局)JS
代码,更改当前URL以及各种事件,页面加载、输入、网络事件和定时器事件。从浏览器角度来看,宏任务代表一个个离散的、独立工作单元。运行完任务后,浏览器可以继续其他调度。
微任务:更小的任务,微任务更小应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面UI
。微任务的案例包括promise
回调函数、DOM发生变化等。微任务需要尽快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染UI
之前执行指定的行为,避免不必要的重绘,UI
重绘会使应用程序的状态不连续。
重绘:一些元素需要更新属性,而这些属性只是影响元素外观,风格,而不会影响布局,比如background-color。就称为重绘。
回流:因为元素的规模尺寸、布局,隐藏等改变而需要重新构建。这称为回流。
回流必将引起重绘,重绘不一定会引起回流。
JS
是单线程,事件循环基于两个基本原则:
- 一次处理一个任务
- 一个任务后知道运行完成,不会被其他任务中断。
从全局来看,事件循环首先检查宏任务队列,如果宏任务等待,则立即执行宏任务。直到该任务运行完(或者队列为空),事件循环将去处理微任务。如果有任务在该队列中等待,则事件循环将开始依次执行,直到队列所有微任务执行完毕。处理宏任务和微任务队列的区别是:单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的微任务都会被处理。
当微任务队列处理完后,事件循环会检查UI
是否需要更新渲染,如果是,则会重新渲染UI
视图。至此,当前事件循环结束,回到最初的第一个环节,在检查宏任务队列,开心新一轮事件。
- 两类任务都是独立于事件循环的,意味着任务队列的添加行为也发生在事件循环之外。
- 因为
JS
基于单线程执行模式,两类任务逐个执行。当一个任务开始后,在完成前不会中断。除非浏览器终止,如某个任务执行实践过长或内存占用大。 - 所有微任务会在下一次渲染之前执行完成,它们的目标是在渲染前更新应用装态。
<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
const firstButton = document.querySelector("#firstButton");
const secondButton = document.querySelector("#secondButton");
firstButton.addEventListener("click", function firstHandler() {
/*单击运行8ms的处理代码*/
Promise.resolve().then(() => {
/*运行4 ms在传入回调函数*/
}); // ⽴即对象promise,并且执⾏then⽅法中的回调函数
});
secondButton.addEventListener("click", function secondHandler() {
/*单击运行5ms的处理代码*/
});
</script>
在本例中,我们假设发生以下行为
- 第
5ms
单击firstButton
。 - 第
12ms
单击secondButton
。 firstButton
的单击事件处理函数firstHandler
需要执行8ms
。secondButton
的单击事件处理函数secondHandler
需要执行5ms
。Promise
回调函数执行4ms
。
在firstHandler
函数中创建立即兑现的Promise
,并需要4ms
传入回调函数。因为Promise
表示当前未知的一个未来值,因此Promise
处理函数总是异步执行。
在本例中,因为我们已知Promise
成功兑现。但是为了连续性,js
引擎会在firstHandler
代码执行完成后在异步调用回调函数。通过创建微任务,将回调放入微任务队列。
在执行主线程js
后,页面重新渲染,开启新一轮的事件循环。发现宏任务队列里有firstHandler
宏任务要处理,在15ms
添加了一个微任务Promise
成功兑现进入微任务队列,继续执行宏任务8ms
后,23ms
发现有微任务,处理Promise
回调函数(执行4ms
)。微任务队列为空。页面可以重新渲染。又开启新一轮的事件循环。处理宏任务secondHandler
,5ms
结束。微任务队列为空。页面重新渲染。这段代码的事件循环就结束了。
事件循环中执行计时器
<button id="myButton"></button>
<script>
setTimeout(function timeoutHandler(){
/*执行回调函数需要6ms*/
}, 10); //注册10ms后延迟执⾏函数
setInterval(function intervalHandler(){
/*执行回调函数需要8ms*/
}, 10); //注册每10ms执⾏的周期函数
const myButton = document.getElementById("myButton");
myButton.addEventListener("click", function clickHandler(){
/*单击运行10ms的处理代码*/
}); //为按钮单击事件注册事件处理器
/*运行18毫秒的代码*/
</script>
现在假设用户在程序执行6ms
时快速单击按钮。
在执行过程中,发生3个重要事件:
0ms
,setTimeout
和setInterval
时间间隔是10ms
,计时器的引用保存在浏览器中。6ms
,单击鼠标。10ms
,setTimeout
和setInterval
第一个时间间隔触发。
6ms
单击按钮,该任务被添加到队列中,10ms
时,setTimeout
和setInterval
触发。也都被添加到队列中去。代码运行18ms
之后,初始化代码结束。微任务中没有任务,可以重新渲染。进行下一个事件循环迭代。
18ms
结束时,3个任务正在等待执行:单击事件处理器,setTimeout
和setInterval
时间处理器。单击事件在队列的前方最先执行(假设需要耗时10ms
)。
到28ms
单击事件处理器完成,可以从新渲染,事件循环开始下一次迭代,处理setTimeout
事件(假设耗时6ms
),处理完成到34ms
。
在这段时间内,第30ms
时setInterval
计时器时间间隔触发。这一次不会添加新的任务到队列中去,因为队列中已经有一个与之相匹配的间隔计时器。34ms
时第一次添加的setInterval
任务才执行(假设耗时8ms
)。42ms
执行完成。在40ms
的时候setInterval
计时器时间间隔触发,由于间隔处理器正在执行(不是在队列中等待),一个新的setInterval
任务添加到任务队列中去,程序继续执行。
可以看出,事件循环一次只能处理一个任务,不能确定定时器处理程序是否会执行我们期望的确切时间。间隔处理程序尤其如此。在本例中少执行了一次回调函数。回调函数没有预期的时间10、20、30、40ms
去执行。setTimeout
代码也在10ms
后才执行(等待时间是会大于10ms
,取决于事件队列状态)。
事件循环从web应用开始时就存在,首先检查宏任务队列(建立主文档对象、解析HTML、执行主线(或全局)JS
代码,更改当前URL以及各种事件,页面加载、输入、网络事件和定时器事件),执行队列第一个任务(宏任务一次迭代中只会执行一个),在检查微任务队列(promise
、DOM发生变化等),把当前所有的微任务队列执行完。告诉浏览器可以从新渲染。然后又开始新的一轮事件循环。这个循环会一直执行到用户关闭web应用。