Skip to content

之前阮一峰老师写了关于Nodejs事件循环的文章。读了之后若有所悟,最近也有看到相关知识点的介绍,趁着热劲儿,也顺便学习一下巩固加深印象。

深入事件循环

事件循环包含着事件队列,而事件队列分为两部分:宏任务和微任务。下面来介绍一下:

宏任务

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

微任务

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


事件循环的实现至少应该含有一个用于宏任务的队列和至少一个用于微任务的队列。事件循环基于两个基本原则:

  • 一次处理一个任务
  • 一个任务开始后直到运行完成,不会被其他任务中断

图 (暂缺)

宏任务队列:鼠标事件 键盘事件 网络事件 HTML解析等,若有事件排在队列中则依次执行一项,然后进入微队列。 微任务队列:DOM变化 Promise等,若队列不为空,则依次执行一项,直到队列空,判断是否需要渲染决定更新渲染。

全局来看,事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务。直到该任务运行完成(或队列为空),事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。

处理宏任务与微任务之间的区别: 单词循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。

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


现在,我们来看事件循环中的一些细节:

  • 两类任务队列都是独立于事件循环的,这意味着任务队列的添加行为也发生在事件循环之外
  • JS是基于单线程执行模型,所以这两类任务是逐个执行的(当一个任务开始执行,在完成前不会被打断,除非浏览器决定中断,如执行时间过长,内存过大等)
  • 所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用的状态
  • 浏览器通常会尝试渲染60次页面,以达到60fps。理想情况下,单个任务和该任务附属的所有微任务都应在16ms内完成

在浏览器完成页面渲染,进入一下轮事件循环迭代后,可能发生3种情况:

  1. 在另一个16ms结束前,事件循环执行到“是否需要渲染”的决策环节。此时,若无显式执行需要页面渲染,浏览器不会选择在当前循环中执行UI渲染
  2. 在最后一次渲染完成后约16ms,事件循环执行到“是否需要进行渲染”决策环节。此时浏览器会进行UI更新
  3. 执行下一任务及相关微任务耗时超16ms。浏览器将无法以目标帧率重新渲染页面,且UI无法更新

上述第3种情况,若任务执行不耗费过多时间(几百毫秒)延迟基本察觉不到。若耗时过多,可能造成页面卡顿等。

我们需要注意事件处理函数发生的频率以及执行耗时。如mousemove、resize、onscroll这类应当特别小心。解决办法是函数节流。


JS是单线程执行模型,同一时刻只能执行一个任务。任务一旦执行,就不会被另一任务中断。事件检测和添加任务是独立于事件循环的,尽管主线程仍在执行,依然可以向队列添加任务。

示例

  1. 仅含两个宏任务: 按顺序等待执行,先执行,回调放事件循环处理,依次执行队列中的回调

  2. 同时含有宏任务和微任务的示例 当一个任务执行完成时,事件循环优先处理微任务队列中的所有任务。宏任务开始执行后,事件循环立即执行为微任务,而不需等待页面渲染,直到微任务队列为空。

计时器

延迟执行:setTimeout clearTimeout 间隔执行:setInterval clearInterval

与事件类型不同,这些方法不是JS本身定义的,而是宿主环境提供的(如浏览器或Node.js)。

注:我们无法确保计时器延迟的时间

js
setTimeout(function repeatMe() {
  // do sth...
  setTimeout(repeatMe, 10)
})
setInterval(() => { /* do sth... */}, 10)

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

我们知道 当超过时间结束时,无法保证超时回调函数精准执行。不是像间隔函数那样一定时间触发一次。了解JS引擎是如何处理异步代码的,尤其是一个页面中存在大量异步事件时,它是创建良好应用程序的基础。接下来我们了解定时器和事件循环如何有助于避免性能缺陷。

处理计算复杂度高的任务

接下来终于要上代码了。 一个长时间运行的任务,代码如下:

html
<table><tbody></tbody></table>
<script>
const tbody = document.querySelector('tbody')
for (let i = 0; i < 20000; i++) {
  const tr = document.createElement('tr')
  for (let t = 0; t < 6; t++) {
    const td = document.createElement('td')
    td.appendChild(document.creatTextNode(i + ', ' + t))
    tr.appendChild(td)
  }
  tbody.appendChild(tr)
}
</script>

上述代码的执行会耗费很长事件,由于队列任务一直在执行,我们无法进行点击事件等。接下来,我们使用一个计时器来中断长时间运行的任务,代码如下:

js
const rowCount = 20000
const divideInto = 5
const chunkSize = rowCount / divideInto
let iteration = 0
const table = document.querySelector('tbody')
setTimeout(function generateRows() {
  const base = chunkSize * iteration // 计算上一次离开的时间
  for (let i = 0; i < chunkSize; i++) {
    const tr = document.createElement('tr')
    for (let t = 0; t < 6; t++) {
      const td = document.createElement('td')
      td.appendChild(document.createTextNode(i + base) + ',' + t + ', ' + iteration)
      tr.appendChild(td)
    }
    table.appendChild(tr)
  }
  iteration++
  if (iteration < divideInto) {
    setTimeout(generateRows, 0)
    // 将超时时刻设置为0来表示下一次迭代应该尽快执行
    // 尽管如此,但仍然必须在UI更新之后执行
  }
}, 0)

在上述的修改中,我们将冗长的操作分解为5个小动作,每个操作分别创建DOM节点。这些较小的操作不太可能打断浏览器的运行流。

通过这种技术,从用户的角度可察觉最明显的变化是,一个长时间的浏览器挂起,替代为5次页面更新。尽管浏览器尝试尽可能快地执行代码片段,但仍然是一次执行DOM渲染。通过理解事件循环的机制,我们可以越过浏览器环境中单线程的限制,为用户提供友好的交互体验。

处理事件

事件捕获:事件处理器从顶部元素开始,直到事件发现目标元素。由window -> document -> 一直下发到目标元素 事件冒泡:从目标元素开始,按DOM树向上冒泡。从内到外,目标元素 -> document -> window

elem.addEventListener(event, fn, false)

第三个参数true开启事件捕获,默认为事件冒泡机制

事件处理器中 this 与 event.target 的区别

js
const outer = document.getElementById('outerContainer')
const inner = document.getElementById('innerContainer')
outer.addEventListener('click', e => {})
inner.addEventListener('click', e => {})

当单击事件发生在inner元素上时。 事件均采用冒泡模式,先调用inner的单击处理器,检查this和event.target均指向inner元素。 接着,事件冒泡到outer元素,这一次 this 和 event.target 指向了不同的元素。

this指向outer,是因为this指向注册的元素。而event.target指向inner是因为指向事件发生的元素

在祖先元素上代理事件

我们先来看一段代码:

js
const li = document.querySelectorAll('ul li')
for (let i = 0; i < li.length; i++) {
  li[i].addEventListener('click', () => {})
}

虽然可到达目的,但这种写法缺点很多:绑定多次事件,若新添加li,不会绑定点击事件。 更优雅的做法是创建唯一的处理器,注册到比单元格更高层级的元素上,通过事件冒泡可以处理所有的li点击事件。 改良后的代码如下:

js
const ul = document.querySelector('ul')
ul.addEventListener('click', e => {
  if (e.target.tagName.toLowercase() === 'li') {
    // 处理逻辑
  }
})

通过事件代理的方式,确保代理的元素是目标元素的祖先元素。这样,我们可以确定单击事件最终会冒泡到事件代理注册的元素上。

自定义事件

松耦合

当代码触发匹配条件时,不需要指定关于条件的细节代码。事件处理器的优点之一是,我们可以创建任意数量的事件处理器,并且事件处理器之间是完全独立的。

使用自定义事件

下面我们来看一个触发自定义事件的例子,代码如下:

html
<style>
  #loading {display: none;}
</style>
<button id="clickMe">start</button>
<img id="loading" src="loading.gif" />
<script>
  function triggerEvent(target, eventType, eventDetail) {
    // 使用CustomEvent构造器创建一个新事件
    const event = new CustomEvent(eventType, {
      // 通过detail属性为事件对象传入信息
      detail: eventDetail
    })
    // 使用内置的dispatchEvent方法解除事件绑定
    target.dispatchEvent(event)
  }

  function performAjaxOperation() {
    // 定时器模拟ajax请求,触发ajax-start事件。
    // 完成后触发ajax-complete事件,传入URL作为事件额外信息
    triggerEvent(document, 'ajax-start', {url: 'my-url'})
    setTimeout(() => {
      triggerEvent(document, 'ajax-complete')
    }, 5000)
  }

  const btn = document.getElementById('clickMe')
  btn.addEventListener('click', () => {
    performAjaxOperation() // 单击后开始请求系列事件
  })
  // 显示loading图
  document.addEventListener('ajax-start', e => {
    document.getElementById('loading').style.display = 'inline-block'
  })
  // 处理ajax-complete事件,隐藏图片
  document.addEventListener('ajax-complete', e => {
    document.getElementById('loading').style.display = 'none'
  })
</script>

这便是一个解耦思想的例子。当事件触发时,共享的Ajax操作代码不知道页面要作的事情。页面代码模块化为小的处理程序,彼此不知。且页面代码不知道共享代码所作的事,页面只响应可能触发,也可能不触发的事件。 使用自定义事件的基本优势是解耦,这能让我们的开发变得更为灵活。

总结

  • 事件循环任务代表浏览器执行的行为:
    • 宏任务是分散、独立的浏览器操作,如创建文档、处理事件、更改URL等
    • 微任务是该尽快执行的任务,如Promise回调、DOM改变等
  • 单线程模型一次只处理一个任务。任务开始执行,不会被其他任务中断,事件循环通常至少有两个事件队列:宏队列和微队列
  • 异步定时器提供延迟执行代码的能力
  • setTimeout和setInterval执行返回对应的计时器ID,可以用clearTimeout和clearInterval来清除
  • 使用计时器将开销很高的代码分解成可管理、不阻塞浏览器的代码块
  • DOM是元素的分层树,发生在一个元素上的事件通过DOM进行代理,有两种机制:
    • 事件捕获:事件从顶部元素向下传递到目标元素
    • 事件冒泡:事件从目标元素向上传递到顶部元素
  • 当调用事件处理器时,浏览器会传入一个事件对象。通过该对象可访问发生事件的目标元素
  • 通过内置的CustomEvent构造函数和dispatchEvent方法,创建和分发自定义事件,减少应用程序不同部分间的耦合

共 20 个模块,1301 篇 Markdown 文档。