事件循环(event loop)

事件循环包含:

  1. 事件队列;
  2. 记录浏览器中其它动作的队列。

浏览器的动作称为任务(tasks),分为宏任务(macrotasks,也简称任务)和微任务(microtasks)。

  • 从浏览器的角度来看,宏任务是一种独立的工作单元,完成一个主任务后,浏览器可以继续进行其它任务,如重新渲染 UI、执行垃圾回收等。
    • 宏任务的例子有创建主要的文档对象,解析 HTML,执行主线(全局)JavaScript 代码,更改当前 URL,事件(如页面加载、输入、网络事件、定时器)等。
  • 微任务是负责更新应用状态的较小的任务,在浏览器继续执行渲染 UI 等任务前,微任务要先被执行。
    • 微任务的例子有执行 promise 的回调,DOM 的变动等。微任务应该以异步的方式被尽早执行(在渲染或其它宏任务之前),它的开销低于执行整个新的宏任务的开销。微任务让一些动作在 UI 重新渲染之前得到执行,避免了可能导致应用状态不一致的不必要更新。
    • 微任务的优先级高于渲染和其它宏任务,哪怕其它宏任务更早被加入队列;微任务中创建的子微任务的优先级也高于渲染和其它宏任务;渲染只有在微任务队列为空时才能进行。

事件循环的实现应该至少使用一个宏任务队列和一个微任务队列,通常的实现中往往用多个队列来记录不同类别的宏任务和微任务,这样可以给对性能敏感的任务(如用户输入)更高的优先级。

事件循环的两个基本原则(由单线程模型决定):

  1. 一次只能处理一个任务。
    • 两种类型的任务都是一次只能执行一个。
  2. 一个任务执行完毕之前不能被其它任务打断。
    • 只有浏览器可以停止一个任务的执行(如发生任务的运行占用太多的时间或内存的情况)。

事件循环的一轮迭代

  1. 首先会检查宏任务队列,若有等待中的宏任务,则去执行它;
  2. 当前宏任务执行完毕后,事件循环转去处理微任务队列,若有等待中的微任务,则去执行它,一个微任务执行完毕后再去执行另一个,直到将队列中的微任务全部执行完
  3. 当微任务队列清空后,事件循环检查 UI 是否需要重新渲染,若需要则执行渲染;
    • 在更新渲染之前所有的微任务都应该被执行,目的是在渲染前把应用的状态更新。
    • 重新渲染发生在每一轮事件循环里,加上是单线程模型,这样就保证了在渲染过程中页面的内容不会被其它任务修改。
  4. 至此一轮事件循环结束,重复此过程开始下一轮。
    • 事件循环的一轮迭代中最多会执行一个宏任务,而整个微任务队列中的微任务都会被执行
    • 浏览器的渲染频率是 60 fps,即浏览器会试图每 16 ms 渲染一帧,要实现顺滑的应用,一次事件循环的迭代的执行时间应该在 16 ms 以内。

一轮事件循环中可能出现的 3 种情况:

  1. 事件循环在下一个 16 ms 到来之前任务就已执行完毕,来到判断页面是否需要重新渲染的阶段。
    • 更新 UI 是复杂的操作,若非必要,浏览器会选择不去重新渲染页面。
  2. 事件循环在判断页面是否需要重新渲染时,距上一次渲染差不多过了 16 ms。
    • 浏览器更新 UI,页面顺滑。
  3. 执行一个宏任务和所有的微任务的时间超过 16 ms。
    • 浏览器不能以 16 ms 的频率渲染页面,UI 不会更新。
    • 若执行时间不超过几百毫秒,页面不包含动画,则延迟不易被感知;若执行时间过长,浏览器会提示 “Unresponsive script” 消息。

将任务加入到对应的队列的线程独立于事件循环的线程

  • 若两者不独立,JavaScript 代码执行期间发生的事件就会丢失。

定时器

定时器事件是宏任务

定时器方法里指定的时间过去之后,就将对应的任务加入到宏任务队列(由独立于事件循环的线程来添加),但任务何时得到执行,不由定时器方法里指定的时间决定。

  • 定时器控制的是定时器任务加入到宏任务队列的时间,而不是任务最终被执行的时间。

定时器不是 JavaScript 本身提供的,而是由 JavaScript 的宿主环境提供,如浏览器或 Node.js 提供。

  • 浏览器提供的定时器方法在 window 对象里,包括 setTimeoutclearTimeoutsetIntervalclearInterval

对于 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 制定的事件处理标准。

一个事件的处理包含两个阶段:

  1. 捕获阶段:事件先被顶层元素捕获,然后向下传递至目标元素。
    • 对于某个事件,这个过程可以找到所有注册了采用捕获模式的事件处理器的元素
  2. 冒泡阶段:捕获阶段的事件到达目标元素后,冒泡模式被激活,事件又向上冒泡传递到顶层元素。

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