浏览器的 Event Loop

本文的内容是浏览器的事件循环,并非 nodejs 的事件循环,不要将二者混淆。javascript

文章原始内容来自 Google Developer Day China 2018 的一个讲座,做者 Jake Archibald,我只是记录并翻译一下而已。其实这不是他首次分享这个内容,所以在 youtube 上搜他的名字和 Event Loop 能搜到讲座录像,有条件的开发者能够听听原版java

咱们先从一段代码开始node

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

这两句代码先把一个元素添加到 body,而后隐藏它。从直观上来理解,可能大部分人以为如此操做会致使页面闪动,所以编码时常常会交换两句的顺序:先隐藏再添加。react

但实际上二者写法都不会形成闪动,由于他们都是同步代码。浏览器会把同步代码捆绑在一块儿执行,而后以执行结果为当前状态进行渲染。所以不管两句是什么顺序,浏览器都会执行完成后再一块儿渲染,所以结果是相同的。(除非同步代码中有获取当前计算样式的代码,后面会提到)promise

从本质上看,JS 是单进程的,也就是一次只能执行一个任务(或者说方法)。与之相对人不是单进程的,咱们能够一边动手一边动脚;一边跑步一边说话,所以咱们很难体会“阻塞”的概念。在 JS 中,阻塞值得就是由于某个任务(方法)执行时间太长,致使其余任务难以被执行的状况。浏览器

单进程

异步队列

但事实上有些任务的确是须要等待一下子再处理的,例如 setTimeout,或者异步请求等。所以把主进程卡住等待返回会严重影响效率和体验,因此 JS 还增长了异步队列 (task queue) 来解决这个问题。bash

每次碰到异步操做,就把操做添加到异步队列中。等待主进程为空(即没有同步代码须要执行了),就去执行异步队列。执行完成后再回到主进程。app

setTimeout(callback, ms) 为例:dom

setTimeout

初始状态:异步开关关闭(由于异步队列为空)。而后 ms 毫秒后添加一个任务 T 到队列中异步

setTimeout2

如今异步队列不为空了,异步开关打开,而后主进程(白色方块)进入到异步队列,准备去执行黄色的 timeout 任务。

渲染过程

页面并非时时刻刻被渲染的,浏览器会有固定的节奏去渲染页面,称为 render steps。它内部分为 3 个小步骤,分别是

  • Structure - 构建 DOM 树的结构
  • Layout - 确认每一个 DOM 的大体位置(排版)
  • Paint - 绘制每一个 DOM 具体的内容(绘制)

咱们考虑以下的代码:

button.addEventListener('click', () => {
  while(true);
})
复制代码

点击后会致使异步队列永远执行,所以不仅仅主进程,渲染过程也一样被阻塞而没法执行,所以页面没法再选中(由于选中时页面表现有所变化,文字有背景色,鼠标也变成 text),也没法再更换内容。(但鼠标却能够动!)

异步队列阻塞

若是咱们把代码改为这样

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

每一个异步任务的执行效果都是加入一个新的异步任务,新的异步任务将在下一次被执行,所以就不会存在阻塞。主进程和渲染过程都能正常进行。

requestAnimationFrame

是一个特别的异步任务,只是注册的方法不加入异步队列,而是加入渲染这一边的队列中,它在渲染的三个步骤以前被执行。一般用来处理渲染相关的工做。

raf

咱们来看一下 setTimeoutrequestAnimationFrame 的差异。假设咱们有一个元素 box,而且有一个 moveBoxForwardOnePixel 方法,做用是让这个元素向右移动 1 像素。

// 方法 1
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback)
}
callback()

// 方法 2
function callback() {
  moveBoxForwardOnePixel();
  setTimeout(callback, 0)
}
callback()
复制代码

有这样两种方法来让 box 移动起来。但实际测试发现,使用 setTimeout 移动的 box 要比 requestAnimationFrame 速度快得多。这代表单位时间内 callback 被调用的次数是不同的。

这是由于 setTimeout 在每次运行结束时都把本身添加到异步队列。等渲染过程的时候(不是每次执行异步队列都会进到渲染循环)异步队列已经运行过不少次了,因此渲染部分会一下会更新不少像素,而不是 1 像素。requestAnimationFrame 只在渲染过程以前运行,所以严格遵照“执行一次渲染一次”,因此一次只移动 1 像素,是咱们预期的方式。

若是在低端环境兼容,常规也会写做 setTimeout(callback, 1000 / 60) 来大体模拟 60 fps 的状况,但本质上 setTimeout 并不适合用来处理渲染相关的工做。所以和渲染动画相关的,多用 requestAnimationFrame,不会有掉帧的问题(即某一帧没有渲染,下一帧把两次的结果一块儿渲染了)

同步代码的合并

开头说过,一段同步代码修改同一个元素的属性,浏览器会直接优化到最后一个。例如

box.style.display = 'none'
box.style.display = 'block'
box.style.display = 'none'
复制代码

浏览器会直接隐藏元素,至关于只运行了最后一句。这是一种优化策略。

但有时候也会给咱们形成困扰。例如以下代码:

box.style.transform = 'translateX(1000px)'
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
复制代码

咱们的本意是从让 box 元素的位置从 0 一会儿 移动到 1000,而后 动画移动 到 500。

但实际状况是从 0 动画移动 到 500。这也是因为浏览器的合并优化形成的。第一句设置位置到 1000 的代码被忽略了。

解决方法有 2 个:

  1. 咱们刚才提过的 requestAnimationFrame。思路是让设置 box 的初始位置(第一句代码)在同步代码执行;让设置 box 的动画效果(第二句代码)和设置 box 的重点位置(第三句代码)放到下一帧执行。

    但要注意,requestAnimationFrame 是在渲染过程 以前 执行的,所以直接写成

    box.style.transform = 'translateX(1000px)'
    requestAnimationFrame(() => {
      box.style.tranition = 'transform 1s ease'
      box.style.transform = 'translateX(500px)'
    })
    复制代码

    是无效的,由于这样这三句代码依然是在同一帧中出现。那如何让后两句代码放到下一帧呢?这时候咱们想到一句话:没有什么问题是一个 requestAnimationFrame 解决不了的,若是有,那就用两个:

    box.style.transform = 'translateX(1000px)'
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        box.style.transition = 'transform 1s ease'
        box.style.transform = 'translateX(500px)'
      })
    })
    复制代码

    在渲染过程以前,再一次注册 requestAnimationFrame,这就可以让后两句代码放到下一帧去执行了,问题解决。(固然代码看上去有点奇怪)

  2. 你之因此没有在平时的代码中看到这样奇葩的嵌套用法,是由于还有更简单的实现方式,而且一样可以解决问题。这个问题的根源在于浏览器的合并优化,那么打断它的优化,就能解决问题。

    box.style.transform = 'translateX(1000px)'
    getComputedStyle(box) // 伪代码,只要获取一下当前的计算样式便可
    box.style.transition = 'transform 1s ease'
    box.style.transform = 'translateX(500px)'
    复制代码

Microtasks

如今咱们要引入“第三个”异步队列,叫作 Microtasks (规范中也称为 Jobs)。

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.

简单来讲, Microtasks 就是在 当次 事件循环的 结尾 马上执行 的任务。Promise.then() 内部的代码就属于 microtasks。相对而言,以前的异步队列 (Task queue) 也叫作 macrotasks,不过通常仍是简称为 tasks。

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

这段代码是在执行 microtasks 的时候,又把本身添加到了 microtasks 中,看上去是和那个 setTimeout 内部继续 setTimeout 相似。但实际效果却和第一段 addEventListener 内部 while(true) 同样,是会阻塞主进程的。这和 microtasks 内部的执行机制有关。

咱们如今已经有了 3 个异步队列了,它们是

  • Tasks (in setTimeout)
  • Animation callbacks (in requestAnimationFrame)
  • Microtasks (in Promise.then)

他们的执行特色是:

  • Tasks 只执行一个。执行完了就进入主进程,主进程可能决定进入其余两个异步队列,也可能本身执行到空了再回来。

    补充:对于“只执行一个”的理解,能够考虑设置 2 个相同时间的 timeout,两个并不会一块儿执行,而依然是分批的。

  • Animation callbacks 执行队列里的所有任务,但若是任务自己又新增 Animation callback 就不会当场执行了,由于那是下一个循环

    补充:同 Tasks,能够考虑连续调用两句 requestAnimationFrame,它们会在同一次事件循环内执行,有别于 Tasks

  • Microtasks 直接执行到空队列才继续。所以若是任务自己又新增 Microtasks,也会一直执行下去。因此上面的例子才会产生阻塞。

    补充:由于是当次执行,所以若是既设置了 setTimeout(0) 又设置了 Promise.then(),优先执行 Microtasks。

一段神奇的代码

考虑以下的代码:

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 1
microtask 1
listener 2
microtask 2
复制代码

但若是在上面代码的最后加上 button.click() 打印顺序会 有所区别

listener 1
listener 2
microtask 1
microtask 2
复制代码

主要是 listener 2microtask 1 次序的问题,缘由以下:

  • 用户直接点击的时候,浏览器前后触发 2 个 listener。第一个 listener 触发完成 (listener 1) 以后,队列空了,就先打印了 microtask 1。而后再执行下一个 listener。重点在于浏览器并不实现知道有几个 listener,所以它发现一个执行一个,执行完了再看后面还有没有。

  • 而使用 button.click() 时,浏览器的内部实现是把 2 个 listener 都同步执行。所以 listener 1 以后,执行队列还没空,还要继续执行 listener 2 以后才行。因此 listener 2 会早于 microtask 1重点在于浏览器的内部实现,click 方法会先采集有哪些 listener,再依次触发。

这个差异最大的应用在于自动化测试脚本。在这里能够看出,使用自动化脚本测试和真正的用户操做仍是有细微的差异。若是代码中有相似的状况,要格外注意了。

针对其余浏览器如何表现这个问题,在原做者的一篇 2015 年的博客中有所说起。其中设计的 case 更加完整,但当时各类浏览器给出了不同的输出结果,所以他还在博客中分析了一波谁对谁错。直到今天虽然没有标准指明应该怎样,但全部浏览器都以如上分析的方式运行。

再来两个测试题

第一题:

console.log('Start')

setTimeout(() => console.log('Timeout 1'), 0)
setTimeout(() => console.log('Timeout 2'), 0)

Promise.resolve().then(() => {
  for(let i=0; i<100000; i++) {}
  console.log('Promise 1')
})
Promise.resolve().then(() => console.log('Promise 2'))

console.log('End');
复制代码

第二题:(在浏览器上点击按钮)

let button = document.querySelector('#button');

button.addEventListener('click', function CB1() {
  console.log('Listener 1');

  setTimeout(() => console.log('Timeout 1'))

  Promise.resolve().then(() => console.log('Promise 1'))
});

button.addEventListener('click', function CB1() {
  console.log('Listener 2');

  setTimeout(() => console.log('Timeout 2'))

  Promise.resolve().then(() => console.log('Promise 2'))
});
复制代码

公布答案:

  • 第一题: Start, End, Promise 1, Promise 2, Timeout 1, Timeout 2
  • 第二题: Listener 1, Promise 1, Listener 2, Promise 2, Timeout 1, Timeout 2

这两个题目来自一篇相关文章(连接在最后),其中还有详细的分析,我这里就不重复了。

相关文章

JavaScript: How is callback execution strategy for promises different than DOM events callback?

相关文章
相关标签/搜索