深入了解事件循环、定时器

吐槽君 分类:javascript

浏览器的生命周期

浏览器输入url到渲染到过程需要经历很多的步骤,下图介绍了输入https://www.ghzs.com/时,生命周期从开始到结束到过程:

生命周期流程

浏览器输入URL之后浏览器会解析URL -> 解析DNS,返回IP地址 -> 发起TCP请求,进行三次握手 -> 资源相应 -> 页面构建,包括DOM Tree节点生成,事件处理 -> 构建完毕,生命周期结束

页面构建阶段

这里我主要介绍一下页面构建阶段。

页面构建阶段的目标是建立Web应用的UI,其主要包括两个步骤:

  1. 解析HTML代码并构建文档对象模型(DOM);

  2. 执行Javascript代码;

浏览器处理HTML节点的过程中会交替的执行上面的两个步骤,即构建DOM和脚本执行。

构建阶段

执行Javascript代码

所有包含在脚本中的js代码由浏览器的js引擎执行,例如,Firefox的Spidermonkey引擎, Chrome 和 Opera 的 V8引擎 和Edge的(IE的)Chakra引擎。由于代码的主要目的是提供动态页面, 故而浏览器通过全局对象提供了一个API 使JavaScript引擎可以与之交互 并改变页面内容。

js代码会有两种类型,区分两种类型的方式就是js代码所在的位置:

  • 包括在函数内的叫做函数执行上文代码;
  • 包括在脚本上下午(window)的叫做全局代码;

全局代码在执行到脚本的时候会从上往下一行一行的执行,函数的代码会在被全局代码调用的时候执行。

当浏览器在页面构建阶段遇到了脚本节点,它会停止HTML到DOM 的构建,转而开始执行JavaScript代码,也就是执行包含在脚本元素的全局JavaScript代码。

最后,当浏览器处理完所有HTML元素后,页面构建阶段就结束 了。随后浏览器就会进入应用生命周期的第二部分:事件处理。

事件处理

我们一般所说的web应用可以统称为GUI应用,也就是说这种应用会对不同类型 的事件作响应,如鼠标移动、单击和键盘按压等。因此,在页面构建阶 段执行的JavaScript代码,除了会影响全局应用状态和修改DOM外,还会注册事件监听器(或处理器)。这类监听器会在事件发生时,由浏览器调用执行。有了这些事件处理器,我们的应用也就有了交互能力。在详细探讨注册事件处理器之前,让我们先从头到尾看一遍事件处理器的总体 思想。

事件处理器概述

JavaScript的执行是单线程的,也就是说同一时刻只能执行一个代码片段,即所谓的单线程执行模型。

想象一下在天河公园排队相亲,很多人都看上了一个相亲对象,所以一窝蜂的单身汪排队。每个单身汪加入队伍等待叫号并“处理”。只有一个相亲对象和一堆单身汪进行交流,每当轮到某个单身汪时(某个事件),相亲对象只和一个单身汪进行交谈(处理)。

单身汪要做的就是安静的排队,等待被叫号,当一个事件抵达后,浏览器需要执行相应的事件处理函数。这里不保证每个单身汪总会极富耐心地 等待很长时间,直到下一个事件触发。所以浏览器需要一种方式跟踪发生但未处理的事件。为了实现这个目标,浏览器使用了事件队列。

事件队列

所有已生成的事件(无论是用户生成的,例如鼠标移动或键盘按压,还是服务器生成的,例如Ajax,Fetch事件)都会放在同一个事件队列中,以它们被浏览器检测到的顺序排列。事件处理的过程可以描述为一个简单的流程图(如上图)。

  • 浏览器检查事件队列头部的对首事件
  • 如果浏览器没有队列对首,则继续检查
  • 如果浏览器在队列对首中检查到了事件,则取出该事件并执行相应的事件处理器。在这个过程中,余下的事件在事件队列中耐心的等待,直到轮到它们被处理

重点注意浏览器在这个过程中的机制,其放置事件的队列是在页面构建阶段和事件处理阶段以外的。这个过程对于决定事件啥时候发生并将其推入事件队列很重要,这个过程不会参与事件处理线程。

提示:

由于一次只能处理一个事件,所以我们必须格外注意处理所有事件的总时间。执行需要花费大量时间执行的事件处理函数会导致应用卡屏、无响应、卡机等等!

如果js执行需要花费大量的时间可以考虑web work,参考阮一峰的Web Worker 使用教程,http://www.ruanyifeng.com/blog/2018/07/web-worker.html

异步事件

对于事件的处理,以及处理函数的调用是异步的。如下类型的事件会在其他类型事件中发生。

  • 浏览器事件,例如当页面加载完成后或无法加载时
  • 网络事件,例如来自服务器的相应(Ajax事件、Fetch和服务器事件)
  • 用户事件,例如鼠标单击‘鼠标移动和键盘事件
  • 计时器事件,当timeout事件到了或者又触发了一次事件间隔

我们平时遇到的web场景中,大部分内容都是对上面事件的处理。

在事件能被处理之前,代码必须要告诉浏览器我们要处理特定事件了。接下来就看看如何注册事件处理器。

注册事件处理器

啥是事件处理器?又怎么注册呢?

前面已经讲过了,事件处理器是当某个特定事件发生后我们希望执行的函数。为了达到这个目标,我们必须告知浏览器我们要处理哪个事件。这个过程叫作注册事件处理器。在咱们常见的Web应用中,有两种方式注册事件。

  • 函数调用,把函数赋值给某个属性或者IIFE(立即执行函数)
  • 事件监听,比如addEventListener

举个例子说明一下函数调用。将一个函数赋值给window的onload属性:

window.onload = function(){
  // do something
}
 

通过上面的方式,事件处理器就会注册到onload事件上。

举例例子说明一下事件监听。上面的方式很容易在日常开发的时候被不知情的小伙伴重写、覆盖。可以使用addEventListener来实现:

document.body.addEventListener("mousemove", function() {
  // do something
})
 

通过上面的方式,为mousemove事件注册处理器。

处理事件

我总结的事件处理背后的主要思想是:当事件发生时,浏览器调用相应的事件处理器。

由于浏览器是单线程执行模型,所以同一时刻只能处理一个事件。任何后面的事件都只能在当前事件完全结束后才能被处理。

事件处理

我们以上面图片的例子来讲解一下事件处理。浏览器为了响应用户的动作,把鼠标移动和单击事件以它们发生的次序放入事件队列:第一个是鼠标移动事件,第二个单击事件。

在事件处理阶段中,事件循环会检查队列,发现队列的前面有一个鼠标移动事件,然后执行了相应的事件处理(序号2)。当鼠标移动事件处理器处理完毕后,轮到了等待在队列中的单击事件。当鼠标移动事件处理球函数的最后一行代码执行完毕后,JavaScript引擎退出事件处理函数,鼠标移动事件就处理完成了(序号3)。事件循环再次检查队列,发现鼠标单击事件并对该事件进行处理。一旦单击事件执行完毕,任务队列中(宏任务)没有信的事件,事件循环会继续循环,等待新的事件到来。这个循环会一直执行,直到用户关闭了应用(结束了当前脚本的全局执行上下文)。

这里我们讲到了事件循环,下面就深入事件循环,探寻更广的视野!

跨平台开发能力

这里为什么我要先讲解一下跨平台能力,是因为JavaScript以及不至于浏览器这个执行环境了,还有Node.js。

两种执行环境提供的API会有一些差异,其中就包括后面要讲解的事件循环、异步、定时器。通过下图简单的了解一下两个执行环境的差异。

执行环境的差异

下面会先讲解浏览器的事件循环、事件处理。

深入事件循环

事件循环包含至少两个队列,除了事件队列,还有浏览器执行的其他操作任务的队列,这些操作任务分类两类,宏任务和为微任务。

属于宏任务事件有如下:

  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI交互事件

属于微任务事件有如下:

  • Promise.then
  • MutationObserver
  • Object.observe
  • Process.nextTick

在浏览器环境中,宏任务多例子,包括创建文档对象(DOM)、解析HTML、执行主线层JavaScript代码,页面加载、表单标签输入、网络事件、定时器等等。从浏览器等角度来看,宏任务像是一个离散的、独立的工作单元。执行完毕任务后,浏览器可以继续其他事件调度,如重新渲染页面的UI或者进行垃圾回收。

而微任务是更小的任务。微任务更新应用程序的状态(Promise),但必须要在浏览器任务(宏任务)继续执行其他任务之前执行。浏览器任务包括重新渲染页面的UI。微任务的例子包括promise回调函数、DOM节点发生变化等等。微任务要尽快的执行,通过异步的方式执行。微任务可以在重新渲染UI之前执行指定的行为,比如请求接口等等,避免不必要的UI重绘,UI重绘频率低的情况会产生视觉上的掉帧,每一帧低于16.6ms肉眼就能直观感受得到卡了。

事件循环会有两个任务队列,一个是宏任务,一个是微任务。两个队列在同一时刻只执行一个任务事件。看下图:

任务队列

事件循环给予两个基本原则:

  1. 一次处理一个任务
  2. 一个任务开始后直到任务完成,不会被其他任务打断

事件循环首先会检查宏任务队列,如果宏任务等待,则开始执行宏任务,直到该任务执行完毕。如果宏任务队列为空,事件循环会开始检查微任务队列。微任务队列有任务则事件循环将依次执行,直到所有的微任务都执行完毕。

这里我们要注意一下哈,一轮事件循环中,只执行一个宏任务,其余的在队列中等待,而微任务则全部执行。

当微任务队列处理完毕并清空时,事件循环会检查是否需要更新UI渲染,如果是,则会重新渲染UI试图。到这里为止,一轮事件循环已经结束了。事件循环又会开始检查宏任务队列,这个时候也是重新开启了一轮新的事件循环。

细节

通过上面对事件循环应该有了全面的了解,有一些小细节要再深入一下。

  • 两类任务队列都是独立于事件循环的,这意味着任务队列的添加行为也发生在事件循环之外。如果不这样设计,则会导致在执行 JavaScript代码时,发生的任何事件都将被忽略。正因为我们不希望看到这种情况,因此检测和添加任务的行为,是独立于事件循环完成的。你可以理解为当JavaScript执行环境上下午的时候把事件丢进了宏任务队列或者微任务队列,它跟事件循环是没啥交互的。

  • 因为JavaScript基于单线程执行模型,所以这两类任务都是逐个执行的。当一个任务开始执行后,在完成前,中间不会被任何其他任务中断。除非浏览器决定中止执行该任务,例如,某个任务执行时间过长或内存占用过大。

  • 所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用程序状态。

  • 浏览器通常会尝试每秒渲染60次页面,以达到每秒60帧(60 fps) 的速度。60fps通常是检验体验是否平滑流畅的标准,比方在动画里,这意味着浏览器会尝试在16ms内渲染一帧。就上面的图片所示的“更新渲染”是如何发生在事件循环内的?因为在页面渲染 时,任何任务都无法再进行修改。 这些设计和原则都意味着,如果想要实现平滑流畅的应用,我们是没有太多时间浪费在处理单个事件循环任务的。理想情况下,单个任务和该任务附属的所有微任务,都应在16ms内完成。

宏任务和微任务的例子

先写个代码,让它包含宏任务和微任务

<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
  const firstButton = document.getElementById("firstButton");
  const secondButton = document.getElementById("secondButton");   
  firstButton.addEventListener("click", function firstHandler(){    
    Promise.resolve().then(() => {
    	/* promise代码执行 4 ms*/   
    }); // 立即对象promise,并且执行then方法中的回调函数    
    /* 点击事件监听器代码执行 8 ms*/
 	});
 	secondButton.addEventListener("click", function secondHandler(){    
   /* 点击事件监听器代码执行 5ms*/
 	});
  
	/* 代码执行 15ms*/
</script>
 

上面的例子中,假设发生一下行为:

  • 第5ms单击firstButton。
  • 第12ms单击secondButton。
  • firstButton的单击事件处理函数firstHandler需要执行8ms。
  • secondButton的单击事件处理函数secondHandler需要执行5ms。

在firstHandler代码中我们创建立即兑现的promise,并需要运行4ms的传入回调函数。因为promise表示当前未知的一个未来值,因此promise处理函数总是异步执行。

我们创建立即兑现的promise。说实话,JavaScript引擎 本应立即调用回调函数,因为我们已知promise成功兑现。但是,为了 连续性,JavaScript引擎不会这么做,仍然会在firstHandler代码执行(需 要运行8ms)完成之后再异步调用回调函数。通过创建微任务,将回调 放入微任务队列。让我们看看本例执行的时间轴,看下面图所示:

微任务和宏任务

注意:为了方便观看,省略了UI渲染阶段。

如果微任务队列中含有微任务,不论队列中等待的其他任务,微任务都将获得优先执 行权。在本例中,promise微任务优先于secondButton单击任务开始执行。在微任务处理完成之后,当且仅当微任务队列中没有正在等待中的微任务,才可以重新渲染页面。在我们的示例中,当promise处理器运 行结束,在第二个按钮单击处理器执行之前,浏览器可以重新渲染页面。

注意到无法停止微任务运行,无法在微任务队列之前添加其他微任务,所有微任务的优先权高于secondButton单击任务。只有当微任务队列为空时,事件循环才会开始重新渲染页面,继续执行secondButton单 击任务,需要注意!

现在已经了解了事件循环的工作机制了,接下来开始讲解一下下一种特殊类型的事件:计时器。

计时器:setTimeout和setInterval

由上面的事件循环我们知道计时器是属于宏任务队列的。

浏览器提供两种创建计时器的方法,分别是setTimeoutsetInterval。浏览器还提供两个清除计时器的方法,分别是:clearTimeoutclearInterval。这些方法都是挂载在window对象(全局上下午)的方法。与事件循环类型不同的是,计时器是由宿主环境提供的,比如浏览器环境和Node.js环境。

之所以要讲计时器,是想要让我们理解,计时器的延迟时间是无法确保的,理解这一点非常重要。

在事件循环中执行计时器

咱们举个例子,在事件循环中执行个计时器,看下面代码:

<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*/
  });  // 为按钮单击事件注册事件处理器
  
  /* 代码执行 18ms*/ 
</script>
 

上面的例子只有一个按钮,但是注册了两个计时器,首先注册延迟执行计时器,延迟10ms。

延迟执行回调函数需要执行6ms。接着,我们也注册了一个间隔执 行计时器,每隔10ms执行一次。

间隔执行回调函数需要执行8ms。我们继续注册一个单击事件处理器,需要执行10ms。

本例中的同步代码块需要运行18ms(脑补一些复杂的代码)。

计时器

上图显示程序前18ms的执行状态。起初,当前运行中的任务是执行主线程JavaScript 代码。执行主线程代码需要耗时18ms。在执行主线程代码时,发生3个事件:鼠标单击事件、 延迟计时器到期事件和间隔计时器触发事件。

执行过程:

  1. 在0ms时,延迟计时器延迟10ms执行,间隔计时器也是间隔 10ms。计时器的引用保存在浏览器中。
  2. 在6ms时,单击鼠标。
  3. 在10ms时,延迟计时器到期,间隔计时器的第一个时间间隔触发。
  4. 18ms页面渲染结束。

在第18ms初始化代码结束执行时,3个代码片段正在等待执行:单击事件处理器、延迟计时处理器和间隔计时处理器。这意味着单击事件处理器开始执行。

咱们来看看下面的图:

计时器

setTimeout函数只到期一次,setInterval函数则不同,setInterval会持 续执行直到被清除。因此,在第20ms时,setInterval又一次触发。但是,此时间隔计时器的实例已经在队列中等待执行,该触发被中止。浏览器不会同时创建两个相同的间隔计时器。

还记得代码中的setTimeout吗,设定的是10ms后执行,但是看上面的图片,在第28ms才执行。

这就是为什么我们需要特别小心,计时器提供一种异步延迟执行代 码片段的能力,至少要延迟指定的毫秒数。因为JavaScript单线程的本 质,我们只能控制计时器何时被加入队列中,而无法控制何时执行。现 在,我们解开谜题了,让我们继续应用程序的剩余部分。

延迟计时处理器需要执行6ms,将会在第34ms时结束执行。在这段 时间内,第30ms时另一个间隔计时器到期。这一次仍然不会添加新的间 隔计时器到队列中,因为队列中已经有一个与之相匹配的间隔计时器。 在第34ms时,延迟计时处理器运行结束,浏览器又一次获得重新渲染页 面的机会,然后进入下一个事件循环迭代。

最后,间隔计时处理器在第34ms时开始执行,此时距离添加到队列 相差24ms。又一次强调传入setTimeout(fn, delay)和setInterval(fn, delay) 的参数,仅仅指定计时器添加到队列中的时间,而不是准确的执行时 间。

间隔计时处理器需要执行8ms,当它执行时,另一个间隔计时器在 40ms时到期。此时,由于间隔处理器正在执行(不是在队列中等待), 一个新的间隔计时任务添加到任务队列中,应用程序继续执行,如下面图所示。设置间隔时间10ms并不意味着每10ms处理器就会执行完 成。由于任务在队列中等待,每一个任务的执行时间有可能不同,一个 接一个地依次执行,如本例的第42ms和第50ms时。

计时器5

上图在间隔处理器开始按每10ms执行之前,由单击处理和延迟执行引起的周折,需要花费一些时间。

最终,50ms之后,时间间隔稳定在每10ms执行一次。

通过上面的案例,我们需要记住一个重要的概念是事件循环一次只能处理一个任务,我们永远不能确定定时器处理程序是否会执行我们期望的确切时间。间隔处理程序尤其如此。 在这个例子中我们看到,尽管我们预定间隔在10、20、30、40、50、60 和70ms时触发,回调函数却在34、42、50、60和70ms时执行。在本例 中,少执行了两次回调函数,有几次回调函数没有在预期的时间点执 行。可以看出,时间间隔需要特殊考虑,并不适用于延迟执行。让我们看得更仔细些。

延迟执行与间隔执行的区别

setTimeout() 内的代码在前一个回调函数执行完成之后,至少延迟10ms执行(取决于事件队列的状态,等待时间只会大于10ms);而setInterval()会尝试每 10ms执行回调函数,不关心前一个回调函数是否执行。

参考文献

  1. 《深入理解Node.js》,作者: 朴灵
  2. JavaScript忍者秘籍(第2版),作者: [[美] John Resig(莱西格)](https://book.douban.com/search/John Resig) / [[美] Bear Bibeault(贝比奥特)](https://book.douban.com/search/Bear Bibeault) / [[美] Josip Maras(马瑞斯)](https://book.douban.com/search/Josip Maras)

回复

我来回复
  • 暂无回复内容