Event Loop in Browser
Contents
事件循环(event loop)
事件循环包含:
- 事件队列;
- 记录浏览器中其它动作的队列。
浏览器的动作称为任务(tasks),分为宏任务(macrotasks,也简称任务)和微任务(microtasks)。
- 从浏览器的角度来看,宏任务是一种独立的工作单元,完成一个主任务后,浏览器可以继续进行其它任务,如重新渲染 UI、执行垃圾回收等。
- 宏任务的例子有创建主要的文档对象,解析 HTML,执行主线(全局)JavaScript 代码,更改当前 URL,事件(如页面加载、输入、网络事件、定时器)等。
- 微任务是负责更新应用状态的较小的任务,在浏览器继续执行渲染 UI 等任务前,微任务要先被执行。
- 微任务的例子有执行 promise 的回调,DOM 的变动等。微任务应该以异步的方式被尽早执行(在渲染或其它宏任务之前),它的开销低于执行整个新的宏任务的开销。微任务让一些动作在 UI 重新渲染之前得到执行,避免了可能导致应用状态不一致的不必要更新。
- 微任务的优先级高于渲染和其它宏任务,哪怕其它宏任务更早被加入队列;微任务中创建的子微任务的优先级也高于渲染和其它宏任务;渲染只有在微任务队列为空时才能进行。
事件循环的实现应该至少使用一个宏任务队列和一个微任务队列,通常的实现中往往用多个队列来记录不同类别的宏任务和微任务,这样可以给对性能敏感的任务(如用户输入)更高的优先级。
事件循环的两个基本原则(由单线程模型决定):
- 一次只能处理一个任务。
- 两种类型的任务都是一次只能执行一个。
- 一个任务执行完毕之前不能被其它任务打断。
- 只有浏览器可以停止一个任务的执行(如发生任务的运行占用太多的时间或内存的情况)。
事件循环的一轮迭代:
- 首先会检查宏任务队列,若有等待中的宏任务,则去执行它;
- 当前宏任务执行完毕后,事件循环转去处理微任务队列,若有等待中的微任务,则去执行它,一个微任务执行完毕后再去执行另一个,直到将队列中的微任务全部执行完;
- 当微任务队列清空后,事件循环检查 UI 是否需要重新渲染,若需要则执行渲染;
- 在更新渲染之前所有的微任务都应该被执行,目的是在渲染前把应用的状态更新。
- 重新渲染发生在每一轮事件循环里,加上是单线程模型,这样就保证了在渲染过程中页面的内容不会被其它任务修改。
- 至此一轮事件循环结束,重复此过程开始下一轮。
- 事件循环的一轮迭代中最多会执行一个宏任务,而整个微任务队列中的微任务都会被执行。
- 浏览器的渲染频率是 60 fps,即浏览器会试图每 16 ms 渲染一帧,要实现顺滑的应用,一次事件循环的迭代的执行时间应该在 16 ms 以内。
一轮事件循环中可能出现的 3 种情况:
- 事件循环在下一个 16 ms 到来之前任务就已执行完毕,来到判断页面是否需要重新渲染的阶段。
- 更新 UI 是复杂的操作,若非必要,浏览器会选择不去重新渲染页面。
- 事件循环在判断页面是否需要重新渲染时,距上一次渲染差不多过了 16 ms。
- 浏览器更新 UI,页面顺滑。
- 执行一个宏任务和所有的微任务的时间超过 16 ms。
- 浏览器不能以 16 ms 的频率渲染页面,UI 不会更新。
- 若执行时间不超过几百毫秒,页面不包含动画,则延迟不易被感知;若执行时间过长,浏览器会提示 “Unresponsive script” 消息。
将任务加入到对应的队列的线程独立于事件循环的线程。
- 若两者不独立,JavaScript 代码执行期间发生的事件就会丢失。
定时器
定时器事件是宏任务。
定时器方法里指定的时间过去之后,就将对应的任务加入到宏任务队列(由独立于事件循环的线程来添加),但任务何时得到执行,不由定时器方法里指定的时间决定。
- 定时器控制的是定时器任务加入到宏任务队列的时间,而不是任务最终被执行的时间。
定时器不是 JavaScript 本身提供的,而是由 JavaScript 的宿主环境提供,如浏览器或 Node.js 提供。
- 浏览器提供的定时器方法在
window
对象里,包括setTimeout
,clearTimeout
,setInterval
,clearInterval
。
对于 setInterval
方法,一个时间间隔过去后,会生成一个新的 setInterval
事件,此时若前一个 setInterval
的事件未得到处理,新的 setInterval
事件会被丢弃。
-
浏览器不会将同一个间隔处理器(interval handler)的多个实例加入到队列中。
// 两个回调函数被调用的间隔可能小于 10 ms(和其它宏任务的执行时长、回调函数本身的执行时长、间隔处理器被丢弃有关) setInterval(() => { /* some long block of code... */ }, 10) // 两个回调函数被调用的间隔至少是 10 ms(内部 setTimeout 之前的代码执行需要时间,也可能还存在其它排在前面的任务,所以内部的 setTimeout 必定是外部 setTimeout 的 handler 被调用后的 10ms 以后被加入到任务队列) setTimeout(function repeatMe(){ /* some long block of code... */ setTimeout(repeatMe, 10) }, 10)
处理计算耗时的任务
为了保证页面的响应性,将耗时超过几百毫秒的复杂操作拆解很有必要,此时定时器可以派上用场。
<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.createTextNode(i + "," + t))
tr.appendChild(td)
}
tbody.appendChild(tr)
}
</script>
<!-- 拆解 -->
<script>
const rowCount = 20000
const divideInto = 4 // 拆成 4 个子任务
const chunkSize = rowCount/divideInto
let iteration = 0
const table = document.getElementsByTagName("tbody")[0]
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)
</script>
setTimeout
的延时设为 0 指示浏览器尽早执行传入的回调(两次执行之间浏览器会重新渲染 UI)。
事件处理
事件对象的 target
属性指向发生该事件的元素。
事件处理器中 this
指向注册该事件处理器的元素,而不是发生该事件的元素。
-
事件在 DOM 中的传播机制决定,发生的事件不一定要在事件发生的元素处处理。
<html> <head> <style> #outerContainer {width:100px; height:100px; background-color: blue;} #innerContainer {width:50px; height:50px; background-color: red;} </style> </head> <body> <div id="outerContainer"> <div id="innerContainer"></div> </div> <script> const outerContainer = document.getElementById("outerContainer") const innerContainer = document.getElementById("innerContainer") outerContainer.addEventListener("click", function(e) { console.log("Outer container click") console.log(this === outerContainer) // * console.log(e.target === innerContainer) }) innerContainer.addEventListener("click", function(e) { console.log("Inner container click") console.log(this === innerContainer) // * console.log(e.target === innerContainer) }) </script> </body> </html>
事件在 DOM 中的传播
事件捕获(event capturing):事件从顶层元素开始沿着 DOM 树向下传播到事件的目标元素。
事件冒泡(event bubbling):事件从目标元素开始沿着 DOM 树向上传播。
W3C 标准化了事件的处理模型,同时支持以上两种模式,所有现代浏览器都实现了 W3C 制定的事件处理标准。
一个事件的处理包含两个阶段:
- 捕获阶段:事件先被顶层元素捕获,然后向下传递至目标元素。
- 对于某个事件,这个过程可以找到所有注册了采用捕获模式的事件处理器的元素。
- 冒泡阶段:捕获阶段的事件到达目标元素后,冒泡模式被激活,事件又向上冒泡传递到顶层元素。
向 addEventListener
方法传入一个布尔值作为第三个参数可以选择事件处理的顺序:传入 true
则使用捕获模式,false
(或不传)则使用冒泡模式。
一个事件可以触发多个事件处理器的执行。
一个事件处理器只能采用捕获模式或冒泡模式中的一种。
事件在传递到某个元素时,若该元素注册了相应的事件处理器,且事件处理的模式(捕获或冒泡)与事件当前所处的阶段也正好匹配,就会触发事件处理器的执行。
可以通过将事件委托给祖先元素,避免在大量的子元素上添加事件处理器,减少内存的分配。
// delegate
const table = document.getElementById('someTable')
table.addEventListener('click', function(event) {
if (event.target.tagName.toLowerCase() === 'td')
event.target.style.backgroundColor = 'yellow'
})
// bad
const cells = document.querySelectorAll('td')
for (let n = 0; n < cells.length; n++) {
cells[n].addEventListener('click', function() {
this.style.backgroundColor = 'yellow'
})
}
自定义事件
<style>
#whirlyThing { display: none; }
</style>
<button type="button" id="clickMe">Start</button>
<p id="whirlyThing">Gone in 3s...</p>
<script>
function triggerEvent(target, eventType, eventDetail) {
const event = new CustomEvent(eventType, {
detail: eventDetail
})
target.dispatchEvent(event)
}
function performAjaxOperation() {
triggerEvent(document, 'ajax-start', { url: 'my-url' })
setTimeout(() => {
triggerEvent(document, 'ajax-complete')
}, 3000)
}
const button = document.getElementById('clickMe')
button.addEventListener('click', () => {
performAjaxOperation()
})
document.addEventListener('ajax-start', e => {
document.getElementById('whirlyThing').style.display = 'inline-block'
console.log(`e.detail.url: ${e.detail.url}`)
})
document.addEventListener('ajax-complete', e => {
document.getElementById('whirlyThing').style.display = 'none'
})
</script>
References