《JavaScript忍者秘籍》深度阅读记录(二)

1,144 阅读6分钟

《JavaScript忍者秘籍》深度阅读记录(一)

13、 历久弥新的事件

13.1 深入事件循环

对于初学者,事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作。这些操作被称为任务,并且分为两类: 宏任务和微任务。

宏任务的例子有很多,包括创建主文档对象、解析HTML、执行主线(或全局)JavaScript代码,更改当前URL以及各种事件,如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,宏任务代表一个个离散的、独立的工作单元。运行完任务后,浏览器可以继续其他调度,如重新渲染页面的UI或执行垃圾回收。

而微任务是更小的任务。微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI。微任务的案例包括promise回调函数、DOM发生变化等。微任务需要尽快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染UI之前执行指定行为,避免不必要的UI重绘,UI重绘会使应用程序的状态不连续。

事件循环基于两个基本原则:

  1. 一次处理一个任务
  2. 一个任务开始后直到运行完成,不会被其他任务中断 事件循环通常至少需要两个任务队列:宏任务队列和微任务队列,两种队列在同一时刻只执行一个任务。

全局来看,上图在一次迭代中,事件循环将首先检查宏任务队列,如果宏任务等待,则立即执行宏任务。直到该任务完成,事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中的所有微任务执行完毕。注意处理宏任务和微任务队列之间的区别:单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。

当微任务队列处理完成并清空时,事件循环会检查是否需要更新UI渲染,如果是,则会重新渲染UI视图。至此,当前事件循环结束,之后将回到最初的第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。

  • 两类任务队列都是独立于事件循环的,这意味这任务队列的添加行为也发生在事件循环之外。如果不这样设计,则会导致在执行Js代码时,发生的任何事件都将被忽略。正因为我们不希望看到这种情况,因此检测和添加任务的行为,是独立于事件循环完成的。

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

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

13.1.2 同时含有宏任务和微任务的示例

  <button id="button1">button1</button>
  <button id="button2">button2</button>

  
  <script>
    document.querySelector('#button1').addEventListener('click', () => {
      console.log('button1 click')
      
      console.time()

      for(let i = 0; i < 10000000000; i++ ) {}

      new Promise(resolve => {
        resolve('hello result')

      }).then(res => {
        console.timeEnd()
        console.log(res)
      })
    })

    document.querySelector('#button2').addEventListener('click', () => {
       console.log('button2 click')
    })

    // 依次点击 button1 和button2

我们快速依次点击 button1 和 button2

输出结果为:

  1. 首先执行宏任务队列主线程JS,在执行主线程JS过程点击了两个按钮,这两个按钮的点击事件在宏任务队列中处于等待执行状态,这个时候微任务队列为空

  2. 主线程代码运行结束后,每当完成执行了一个宏任务后,事件循环会检查微任务队列,若微任务队列为空,则按需进行渲染页面。

  3. 接下来执行第一次点击事件(点击button1),button1 中 for循环运行时间大概10s左右, button1 点击事件中for循环结束后,添加了一个微任务处理promise成功,(此时第二次点击事件已经在等待执行状态),如果按照先后顺序,那么应该先执行第二次点击事件。但是我们之前提到,微任务具有优先执行权,我们会发现,事件循环总是首先会检查微任务队列,目的是在处理其他任务之前把所有的微任务执行完毕。

正因如此: 当第一次点击事件完成之后,立即执行promise对象成功的回调函数,而更早在队列中的第二次点击事件则继续等待。

当微任务处理完成之后,当且仅当微任务队列中没有正在等待中的微任务,才可以重新渲染页面。在我们的示例中,当promise处理器运行结束,在第二个按钮单击处理器执行之前,浏览器可以重新渲染页面。

13.2 玩转计时器: 延迟执行和间隔执行

计时器常常被误用,它是一种疏于理解的JavaScript特性,但若使用得当,有助于开发复杂应用。计时器能延迟一段代码的运行,延迟时长至少是特定时长(单位是ms)。

首先,让我们看看创建的控制计时器的函数。浏览器提供两种创建计时器的方法: setTimeout和serInterval。浏览器还提供了两个对应的清除计时器的方法: clearTimeout和clearInterval。这些方法都是挂载在window对象(全局上下文)的方法。这些方法不是Js本身定义的,而是宿主环境提供的(如浏览器或Node.js)。

注意: 无法确保计时器延迟器 延迟的时间,理解这一点非常重要.

13.2.1 在事件循环中执行计时器

	<button id="testButton"></button>
    
    <script>
    	setTimeout(() => {
        	// 处理事件 6ms
        }, 10)
        
        
        setInterval( () => {
        	// 处理事件 10ms
        }, 10)
        
        const testButton = document.getElementById('testButton')
        
        testButton.addEventListener('click', () => {
        	// 处理事件 10ms
        })
    </script>

现在假设本例中代码块需要运行18ms,假设某用户在程序执行6ms时快速单击按钮。

在队列中的第一个任务是执行主线程js代码,需要运行18ms。在执行过程中,发生了3个重要事件:

  1. 在0ms时,延迟计时器延迟10ms执行,间隔计时器也是间隔10ms。计时器的引用保存在浏览器中。

  2. 在6ms时,单击鼠标

  3. 10ms时延迟计时器到期,间隔计时器的第一个时间间隔触发

注意: 如果interval事件触发,并且队列中已经有对应的任务等待执行时,则不会再添加新任务。反之不会进行任何处理,如20ms和30ms的队列所示。

因为Js的单线程的本质,我们只能控制计时器何时被加入队列中,而无法控制何时执行。

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

乍一看,间隔执行看起来像一个延迟执行的定期重复。但二者差异不止如此。

// 注册延迟任务,每10ms重新执行自身
setTimeout(function repeatMe() {
	setTimeout(repeatMe, 10)
})

// 注册周期任务,每10ms执行一次任务
setInterval(() => {
	// do something
})

两段代码 看起来功能是等价的,但实际未必。很显然,setTimeout内的代码在前一个回调函数执行完成之后,至少延迟10ms执行(取决于事件队列的状态,等待时间只会大于10ms)。而setInterval会尝试每10ms执行回调函数,不关心前一个回调函数是否执行。

13.3 事件处理

W3C委员会设立标准,它同时包含两种方式,所有现代浏览器都实现了该标准。一个事件的处理有两种方式:

  1. 捕获: 首先被顶部元素捕获,并依次向下传递
  2. 冒泡: 目标元素捕获之后,事件处理转向冒泡,从目标元素向顶部元素冒泡

我们可以想addEventListenner传递参数,很容易地选择希望的事件处理顺序。如果传入true,将采用事件捕获,如果传入false,则采用事件冒泡。因此某种意义上说,W3C标准更倾向于优先选择事件冒泡。

注意: e.target 指向发生事件的对象, this一般指向绑定事件的对象(箭头函数除外)

13.4 小结

  • 事件循环任务代表浏览器执行的行为。任务分为以下两类。
  1. 宏任务是分散的、独立的浏览器操作,如创建主文档对象、处理各种事件、更改URL等
  2. 微任务是应该尽快执行的任务。包括promise回调和DOM突变
  • 由于单线程的执行模型,一次只能处理一个任务,一个任务开始执行后不能被另一个任务中断。事件循环通常至少有两个事件队列:宏任务队列和微任务队列。
  • 异步定时器提供延迟执行一段代码的能力,至少延迟指定的毫秒数
  • 使用setTimeout函数在指定的延迟事件后执行回调
  • 使用setInterval函数来启动一个计时器,将尝试在指定的延迟间隔执行回调,直至被清除。
  • 两个函数均返回对应的计时器ID,通过clearTimeout 和 clearInterval 函数,我们可以使用计时器ID来取消计时器。
  • DOM是元素的分层树,发生在一个元素上的事件通常是通过DOM进行代理的,有以下两种机制:
  1. 事件捕获模式:事件从顶部元素向下传递到目标元素
  2. 事件冒泡模式:事件从目标元素向上冒泡到顶部元素

END!!!