阅读 3011

彻底搞懂 JS 事件轮询

写此篇文章旨在记录在油管上的看的一个关于事件循环的演讲,其中剖析了事件环、轮询机制、RAF与微任务,以一篇教程的形式来书写,其中也包含了我对于事件循环的一点见解。

1.事件环

1.1 概念

在了解事件轮询之前,我们需要先了解一下 JS 中的事件环机制,先看下面一段代码:

document.body.appendChild(el)
el.style.display = 'none'
复制代码

我们想要给页面添加一个元素,同时该元素出现在页面一开始应该是隐藏的。但是是否你会感觉看起来很难受,你不知道是否用户会看到在该元素在隐藏之前发生闪现。

所以在很多情况下你会将它改成这样:

el.style.display = 'none'
document.body.appendChild(el)
复制代码

调换一下位置,看起来舒服多了不是吗?

但实际上,上面的两种写法的效果是一样的,它们没有竞争关系, 在 JS 运行和渲染页面在浏览器中都有着规定且确定的时间段, 这些东西都存在于同一个线程中依次执行。

在网页中存在一个主线程 ,在这里发生大量事件,JS 在这里面运行、网页在这开始渲染、DOM 存在于此。 这意味着网页大部分的活动都具有确定性的顺序。

但是,这也意味着如果在主线程上的任务需要很长时间,比如200ms,这从与用户交互的体验来说确是一段很长的时间,用户就会注意到网页的加载、渲染与交互。

因此,在网页中还需要其它的线程来处理诸如网络请求、编码解码、加密、监控输入设备。一旦这些线程有了需要页面响应的操作,它们就需要通知主线程来处理这些响应,事件环就负责协调所有这些响应活动。

1.2 深入

现在,假如你不知道事件环机制,以setTimeout()为例,有没有想过这个函数是如何运作的?现在我们为其写一个新的标准:

setTimeout方法,在调用时运行以下步骤:

  1. 等待指定时间
  2. 触发回调函数

但是,这样定义后setTimeout方法与其回调函数是在同一个线程上运行的,这段 JS 代码在主线程上运行时实质是要求主线程上等待指定时间后继续运行,这样会阻止主线程上的其它活动。

于是,我们将其改成同时运行这两个步骤:

setTimeout方法,在调用时运行以下步骤:

  1. 运行下面的步骤
    1. 等待指定时间
    2. 触发回调函数

换句话说,我们让这个任务离开当前线程,同时运行它。

但是,有一个新的问题出现了,我们现在在主线程以外的地方触发该回调函数,这样的结果造成的就是出现大量同时并行运行的 JS 代码,编译相同的 DOM,最后又会存在有竞争关系,这显然也是行不通的。

那么,如何才能不耽误当前线程后续任务的执行的,又能不开出另外的线程来对同一对象作出操作呢?

我们要做的就是创建一个任务添加到任务队列,以便在某个时刻回到主线程继续执行。

setTimeout方法,在调用时运行以下步骤:

  1. 运行下面的步骤
    1. 等待指定时间
    2. 创建一个任务运行下面的步骤,该任务会添加到当前的任务队列中
      1. 触发回调函数

这样,我们就能在当前代码所在的线程中调用回调函数中的代码(新开一个线程来等待,等待完成后添加到当前线程),这也正是原来setTimeout所执行的方法。

于是,我们在页面上的所有响应操作,诸如点击 DOM 元素、请求响应数据、从页面发送消息都可以通过向任务队列中加入新的任务来实现。任务环中首先关注任务队列,这也是任务环中历史最悠久的部分。

注: 其中的任务环与任务队列就是事件环与事件队列。

2.轮询机制

2.1 简单执行

现在,我们只以作普通的任务来讲,在没有任务需要执行的时候,事件环只会出于空转状态,每当有任务加入,它会被添加到任务队列中,以便之后执行。

现在我们向任务中用setTimeout添加两个回调函数到任务队列中:

setTimeout(callback1, 1000)
setTimeout(callback2, 1000)
复制代码

当我们为任务队列添加任务的时候,事件环会离开空转的状态,进入执行事件的阶段,然后执行第一个回调函数,然后再绕一圈后回来执行第二个回调函数

如上所示,这就是执行普通事件的任务队列

2.2 渲染阶段

如果只考虑普通的事件环轮询,这很好理解。但是,当我们要考虑到浏览器渲染网页时,情况就变得复杂了。

正如我们所知,浏览器通过渲染来更新网页显示 。渲染步骤涉及到三个阶段:

  • 样式(S): 负责样式计算,收集所有的 CSS、计算机应用到元素上的样式
  • 布局(L): 创建一个渲染树,找到页面上的所有内容以及元素的位置
  • 绘制(P): 创建实际的像素数据,绘制内容到页面上

现在,渲染阶段在事件环外另开的了一个分支,当需要执行渲染时,浏览器会让事件环下一次循环时来执行渲染任务,就像上图一样。

2.3 发生冲突

2.3.1 同步循环

我们创建一个会执行无限循环代码的按钮在页面上,当我们点击该按钮时会执行一个无限循环的代码:

button.addEventListener('click', e => {
    while (true) {
        // do nothing
    }
})
复制代码

当这个按钮被点击时,我们会发现网页瞬间停止了。没错,不管做什么操作都无济于事,动态 gif 图不会响应,文字也无法被选取,仿佛所有的动作都被堵塞了一样。 1569136173404.png 对于事件环来说是这样表示的:点击按钮时向任务队列加入一个任务,事件环轮询过来执行该任务,但这个事件永远也不会结束,于是事件环就一直停在这执行这个新添加的任务。 1569136740072.png

之所以上面没有用动图就是因为这本就是没有动的图片,没错,这就是由于无限循环造成事件环堵塞,在解决堵塞之前,一切程序都不会继续运行。

当网页又需要更新渲染的时候,比如更新 gif 图的动态效果,会通知事件环,于是事件环又打开渲染任务的入口,然后用户试着选取文本,这涉及了获取点击的事件,涉及查看 DOM 文本的事件,于是又向任务队列添加新的任务。

不过这时,因为事件环正在做无限循环的事件,所以无论浏览器要求的任务还是我们向事件环中新添加的任务都不会生效。 于是就有我们看见的网页停止的效果。

好了,我们再来看看刚开始的代码:

document.body.appendChild(el)
el.style.display = 'none'
复制代码

我们总会认为这个元素会发生闪现,实质上并不会发生,因为这段代码是在一个任务中执行的,必须要等到这个任务执行结束后,浏览器才会执行渲染,事件环会确保我们的任务在进行下一次网页渲染前完成。

2.3.2 异步循环

既然使用while(true)这样的代码会阻止循环,那么我们使用这个呢?

function loop() {
    setTimeout(loop, 0)
}
loop()
复制代码

这也是一个无限循环的代码,不是吗?然而,当我们再次点击按钮时,确是一切正常,在页面上感觉不到任何的变化,gif 图能正常响应,也能够选择文字。

这段代码没有起作用?显然不是,在事件环上实际上也会一直循环的做同一件事,不过这次与上次不同。我们向任务队列添加一个任务,绕过事件环执行此任务,该任务是让我们再次向任务队列中添加一个任务,于是我们又再一次加入另一个任务,等事件环绕回来继续执行这次添加的任务,循环往复。

正如我们看到的那样,一次只能执行一个任务,当它处理一个任务时,它必须绕事件环一圈来接收下一个任务。这意味着某些时候浏览器告诉事件环要更新 gif 图的时候,它可以绕到渲染那一侧去做渲染,所以setTimeout做的无限循环无法阻止渲染。

注: 关于循环,后面还有一个Promise实现的循环,因为在微任务中,就不在这里提出来了。

3.RAF

3.1 概念

如果我们要执行于渲染相关的代码时,不要将它放在 JS 的任务队列中,因为任务在渲染的另外一边,我们需要将负责渲染的代码放在渲染阶段之前。在浏览器中,使用requestAnimationFrame 方法就能做到这一点,该方法中的函数(RAF 回调)会作为渲染步骤的一部分发生。 1569138782618.png

现在,我们用代码来移动一个盒子,每次将盒子向前移动一个像素,然后使用requestAnimationFrame来创建一个循环:

function callback() {
    move()
    requestAnimationFrame(callback)
}
callback()
复制代码

这段代码运行的结果也如我们想的那样,每次都能看见向前移动一个像素,频率是浏览器的渲染频率,因为该函数是在浏览器执行渲染阶段的时候调用,会根据浏览器的渲染频率执行。

那么,我们将requestAnimationFrame换成setTimeout呢?

function callback() {
    move()
    setTimeout(callback, 0)
}
callback()
复制代码

如果你测试过,会发现下面的盒子速度更快了,并且快了大概3.5倍,这意味着move()回调被更加频繁地被调用了。这并不是一件好事,因为这可能并不符合你的预期,让用户看到盒子每次只移动一个像素,不是吗?

在之前我们知道了渲染可能在任务之间执行,但是这并不意味着当有一个新的任务加入任务队列的时候会立刻发生渲染。 事件环将发生渲染事件的入口关闭了,只有到了固定的周期(也就是依靠浏览器的渲染频率)才会进行渲染。于是渲染阶段反客为主,可能在执行了多个事件后才会打开渲染阶段的入口,更新页面。

由浏览器决定何时渲染并且尽可能高效,只有值有发生更新才会进行渲染,如果没有改变就不会。 如果浏览器运行在后台没有显示,浏览器就不会进行渲染,因为并没有任何意义。

大部分情况,页面会以一个固定的频率更新,每秒60次,根据屏幕的不同有所区别,不过一般都是每秒60次。如果我们每秒钟改变页面样式1000次,浏览器不会渲染1000次,而是与显示器同步,仅会渲染到显示器能够到达的频率,否则就是浪费时间,因为渲染了多余的东西用户也无法看见。

而正如我们看到的,前面例子的setTimeout就是做了多余的样式改变。盒子的移动速度更快,因为它调用的次数更多,多余用户所能看到的,也多于浏览器能够显示的,于是浏览器在每次渲染的时候不止是移动了一个像素,而是多个像素。 但是,实际上我们例子中的setTimeout设置的延迟时间也并不是0, 即使我们将延迟时间设置为0,浏览器也会选择任意数字作为延迟时间,这个延迟时间一般是4.7ms,也就是说,它实际下面这样的:

function callback() {
    move()
    setTimeout(callback, 4.7)
}
callback()
复制代码

上面的延迟至少我们看到页面上的盒子还是连续移动的,如果我们将延迟时间再次压缩,盒子看起来更像是在瞬移,因为调用的次数太多,看起来盒子就像出现在了随机的位置。

正如我们所说,渲染可以发生在任务之间,而两次渲染之间可能执行成千上万个任务。

3.2 动画帧

我们将浏览器的两次渲染之间的阶段叫做一帧,浏览器的渲染发生在每个帧的开头,包括样式计算、布局和绘制,这三者的出现取决于实际需要更新的内容,不过这并不是重点。重点在于任务,任务可以出现在任何地方,就帧内的时间段而言没有任何顺序。 1569142681099.png

还是以上一个例子为例,我们通过setTimeout为每帧设置了三到四个任务。对于上一个例子来说,这意味着有四分之三个任务是不必要的,因为它们根本不会被浏览器渲染。 1569142754110.png

也许在之前你就想过这样做:

function callback() {
    move()
    setTimeout(callback, 1000 / 60)
}
callback()
复制代码

使用一个每秒执行60次函数的毫秒值不就行了吗,你这样想。但是,这是你必须确保用户现在的屏幕是一个60赫兹的屏幕,并且商足够的精确。在更多的情况下,这更是一个无奈之举,因为setTimeout函数并不是为了做动画而生的,这种做法由于不精确会造成飘移,可能会显示在一帧没有任务执行而在下一帧执行两个任务的情况。 1569143099088.png

这样对用户的体验并不好。同时,如果某个时间运行的时间过长,浏览器还会推迟渲染,因为它们都在同一个线程上运行, 这样就破坏了既定的程序。 1569143218779.png

如果我们使用requestAnimationFrame,因为它在渲染阶段的时候才会执行,看起来更像是这样: 1569143326780.png

即使有的渲染耗时过长也会在一帧中渲染出来结果,一切都变得井然有序起来。

当然,我们不可能避免有出现在渲染阶段另一侧的任务出现,比如点击事件会在任务中传递,通常我们希望浏览器会尽快完成任务。但是如果有像计时器这样的东西或者是来着网络的响应,使用requestAnimationFrame将动画的工作打包起来绝对是一个很好的选择。特别是如果已经有动画在运行,因为这样可以节省很多重复的工作。

3.3 执行时期

还有一个细节,requestAnimationFrame的回调函数是运行在渲染阶段处理 CSS 之前和绘制操作之前,所以无论我们在其中做了什么,浏览器都只会渲染出最后结果。像下面这样:

button.addEventListener('click', (e)=>{
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
})
复制代码

看起来我们多次隐藏和显示了一个盒子,应该会照成很大的开销,但实际上浏览器这是还没有任何的处理,因为在执行任务的阶段,浏览器根本不会考虑到 CSS 的变化,只有到真正执行渲染的时候才会考虑到。

现在,我们又有一个盒子,我们想将它的位置从 0 移动到 1000px 再移动到 500px。

button.addEventListener('click', (e)=>{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    box.style.display = 'translateX(500px)'
})
复制代码

相同的原因,上面的代码会立刻移动到 500px 处。现在,我们将第二部分动画放在requestAnimationFrame中:

button.addEventListener('click', (e)=>{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    requestAnimationFrame(() => {
        box.style.display = 'translateX(500px)'
    })
})
复制代码

但是!它仍然会直接从 0 到 500px。为什么会这样?我们来仔细分析一下:

  • 首先,点击按钮时会向任务队列添加一个任务,所有的事件在任务中执行,最后一步加入了一个动画帧在渲染阶段

1569144625620.png

  • 然后,事件环到达渲染阶段,开始执行动画帧任务,我们在这设置了移动的终点

  • 后面设置的终点(rAF 处)直接将前面设置的位置覆盖掉,浏览器没有考虑到中间样式的变化,直到进入 S 区域才对 CSS 进行计算

就是这样,在真正计算 CSS 之前进行了最后的位置变化,所以最后没有我们想要的结果。

那么,我们怎么让这个动画如我们所想的变化呢?要让这个动画显示,我们需要用到两个requestAnimationFrame 像下面这样:

button.addEventListener('click', (e)=>{
    box.style.display = 'translateX(1000px)'
    box.style.display = 'transform 1s ease-in-out'
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            box.style.display = 'translateX(500px)'  
        })
    })
})
复制代码

这样,我们循环到下一个渲染阶段才会执行第二个变化,我们就能看到动效了。

顺带一提,其实还有一个替代方案,可以使用诸如getComputedStyle这样的方法,只需要访问其中一个属性,这样就能迫使浏览器更早地进行样式的计算,会让浏览器记录下此前设置的所有内容。但是,使用这个方法的时候需要小心,因为这样的做法可能会让浏览器在一帧的时间内做多余的工作,可能会破坏我们真正想要的效果。

然而,要说的是,上面两种都不是最终方案,最终方法是使用web animation API,使用这个方案可以轻松地指定我们想要的操作,但是目前只有 Chrome 支持该方案,我们暂且不提。同时,Edge(旧版)和Safari中,requestAnimationFrame可能现在还不是在渲染 CSS 之前执行, 这意味着很难批量更新页面,用户可能会延迟看到页面,到了下一帧才能看到页面变化,屏幕的显示会有很大的延迟。注意这并不是符合网络标准的,我们期待它们后面会有所整改。

4.宏任务与微任务

我们已经知道了事件环、任务队列并且知晓事件环轮询队列时的操作。现在,我们再深入一点,拆分任务队列,再了解任务队列中的具体分布。

首先,我们需要明确一点,JS 是单线程的,事件环是这个线程拥有的唯一事件循环。一个线程中,事件循环是唯一的,但是任务队列可以拥有多个,任务队列细分的话主要分为宏任务队列(macro-task)与微任务队列(micro-task)(RAF 回调队列可以看做是一种特殊的队列,细分的话应该是属于宏任务队列的,但是我们将其单独拿出),在最新标准中,它们被分别称为taskjobs

宏任务的主要模块

  • setTimeout
  • setInterval
  • I/O
  • script代码块

微任务的主要模块

  • nextTick
  • callback
  • Promise
  • process.nextTick
  • Object.observe
  • MutationObserver

可能你还不知道在任务队列中有微任务的概念,这可能是事件环中最不为人知的一部分。现在我们大多只需要将其与Promise关联起来就行,但是这并不是原来微任务创建的初衷。

在最开始时,我们是希望当要为浏览器添加很多事件的时候,与 RAF 一样,我们希望浏览器暂时不做任何处理,而是等到一个合适的时间点产生一个事件或其它东西来代表所有的变化,解决方案为使用一个观察者,这些观察者会创建一个新的任务队列,这个队列就是微任务队列。

在事件环中有特定的地方处理微任务,并且这个特定的地方是动态的,随时可能会出现。它也许会在 JS 结束后执行,也可能在渲染阶段作为 RAF 的一部分执行。可以这么说,任何 JS 运行的地方都可能执行微任务。

Promise为什么使用微任务呢。当 JS 执行结束时,执行Promise的回调函数。这意味着当Promise回调执行时,能确定中间没有 JS 执行,而Promise的回调是位于栈堆的底部的,这也正是Promise使用微任务的原因。

4.1 处理机制

现在,我们又创建一个无限循环,这次使用的是Promise,像之前setTimeout做的一样:

function loop() {
    Promise.resolve().then(loop)
}
loop()
复制代码

效果可能会与你猜测的不同,这次的结果是 gif 图无法响应,用户也无法选取文字,效果同使用同步循环一样。

在这里我们要提到的是,Promise会产生一个异步函数,但是异步函数也分不同种类,根据不同种类会分别加在宏任务与微任务中,所有异步并不意味着它必须要屈服于渲染已经事件环循环的任何特定部分。

到目前为止,我们已经提到了三个不同队列:任务队列(宏任务队列)、RAF 回调队列、微任务队列。 三个队列的处理方式有着细微的不同:

  • 任务队列:每次只执行一个任务,如果另一个任务添加进来,就添加到队列尾部。
  • RAF 回调队列:一直执行回调队列中的所有任务,直到队列都完成,如果动画回调内部又有动画回调,它们会在下一帧执行。
  • 微任务队列:同样一直执行直到队列为空,但是,如果处理微任务的过程中有新的微任务加入,并且加入的速度大于执行的速度,那么就会永远执行微任务。

知道上面微任务处理的特性,我们就能知道为什么使用Promise进行无限循环会造成事件环堵塞,阻止渲染。

4.2 执行顺序

事件循环的顺序,决定 JS 代码的执行顺序。一段代码块就是一个宏任务。 进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。具体的图解是这样的: 1569155224437.png

来看看下面这段代码:

button.addEventListener('click',() => {
    Promise.resolve().then(() => console.log('Microtask 1'))
    console.log('Listener 1')
})

button.addEventListener('click',() => {
    Promise.resolve().then(() => console.log('Microtask 2'))
    console.log('Listener 2')
})
复制代码

我们在同一个按钮上绑定两个点击事件,用户点击按钮,控制台上会打印四次输出,那么具体的顺序是如何?

如果仔细看了之前宏任务的定义,相信你很快就能得到答案。在控制台上的打印顺序依次为:Listener 1Microtask 1Listener 2Microtask 2

现在来解释一下:首先,我们的第一个点击监听器执行,打印第一个输出后 JS 堆栈已经清空,于是现在轮到微任务执行,我们将执行Promise的处理方法,输出Microtask 1,之后第二个监听器执行。

然后,我们现在不使用监听器监听用户点击,我们使用 JS 代码来让这个按钮被点击,有没有想过结果是否会发生变化?

button.addEventListener('click',() => {
    Promise.resolve().then(() => console.log('Microtask 1'))
    console.log('Listener 1')
})

button.addEventListener('click',() => {
    Promise.resolve().then(() => console.log('Microtask 2'))
    console.log('Listener 2')
})

button.click()
复制代码

这样运行后打印的结果为:Listener 1Listener 2Microtask 1Microtask 2。为什么两次点击事件的结果不一致?其实还是在宏任务与微任务的执行范围之类。

首先,调度事件,首先答应出Listener 1,然后排队一个微任务。按照宏任务的定义,我们现在应该执行微任务了吧?还不行,与用户点击不同的是,我们的 JS 栈堆现在不是空的,因为我们还在执行click()方法中,所以我们还会继续执行另外一个监听,打印Listener 2,然后再排队一个微任务,这就是不同的地方。执行完第二个点击监听后,JS 栈堆请求,现在才是执行微任务的时间。

再来加强一下理解:

const nextClick = new Promise(resolve => {
    link.addEventListener('click', resolve, { once:true })
})

nextClick.then(e => {
    e.preventDefault()
    // Handle event
})
复制代码

我们有一个Promise代表特定链接的下一次点击,我们可以用这个Promise并调用e.preventDefault(),那么我们是否会错过这次点击的阻止默认事件的方法。根据之前的理解,当我们点击这个链接时,JS 的堆栈中已经清空,这时点击链接时会立刻触发阻止默认事件,链接并不会跳转。

const nextClick = new Promise(resolve => {
    link.addEventListener('click', resolve, { once:true })
})

nextClick.then(e => {
    e.preventDefault()
    // Handle event
})

link.click()
复制代码

而是用 JS 代码点击时,因为此时 JS 堆栈中并没有被清空,所以在点击事件结束前微任务都不会执行,无法阻止默认事件,页面跳转,就是这么简单。

总的来说,微任务的执行会因为 JS 堆栈的情况有所不同。

参考

文章分类
前端
文章标签