为什么要学习事件循环?

事件循环机制(Event Loop)是全面了解javascript代码执行顺序绕不开的一个重要知识点。虽然许多人知道这个知识点非常重要,但是其实很少有人能够真正理解它。特别是在ES6正式支持Promise之后,对于新标准中事件循环的理解就变得更加重要了。

我们先从一段代码开始
代码一:

  1. let el = document.createElement('div');
  2. el.innerText = '隐藏';
  3. document.body.appendChild(el);
  4. el.style.display = 'none';

代码二:

  1. let el = document.createElement('div');
  2. el.style.display = 'none';
  3. el.innerText = '隐藏';
  4. document.body.appendChild(el);

我们先看看两端代码的不同:

  • 代码一:先把一个元素添加到 body,然后隐藏它。
  • 代码二:先将元素隐藏,在添加到 body 上。

思考:上面两种操作会导致页面闪动吗?

但实际上两种写法都不会造成闪动,因为他们都是同步代码。浏览器会把同步代码捆绑在一起执行,然后以执行结果为当前状态进行渲染。因此无论两句是什么顺序,浏览器都会执行完成后再一起渲染,因此结果是相同的。(除非同步代码中有获取当前计算样式的代码,后面会提到)

为什么有事件循环机制?

JavaScript的一大特点就是单线程,也就是说,同一时间只能执行一个任务(或者说只能做一件事)。与之相对人可以理解为多线程,我们可以一边动手一边动脚;一边跑步一边说话,因此我们很难体会“阻塞”的概念。

由于Js 是单线程执行,如果某个任务(方法)执行事件太久,导致其他任务难以被执行的情况,这样既就形成阻塞,相当于卡死,导致用户体验很差。

那为什么要设计成单线程呢,多线程效率不是更高吗?

有这样一个场景:假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以为了避免复杂性,JavaScript从诞生就是单线程。但是单线程就导致有很多任务需要排队,只有一个任务执行完才能执行后一个任务。如果某个执行时间太长,就容易造成阻塞;为了解决阻塞这一问题,JavaScript引入了事件循环机制;

image.png
事件循环示意图

异步队列

不是所有任务都是同步处理的,有些任务需要延迟一会儿再做处理,如果这个时候卡住等待会严重影响效率和体验,为了实现JS异步的实现, JS 增加了异步队列 (task queue) 和事件循环机制来解决这个问题。

每次碰到异步操作,就把操作添加到异步队列中。等待主进程为空(即没有同步代码需要执行了),就去执行异步队列。执行完成后再回到主进程。
以 setTimeout(callback, ms) 为例:
image.png
初始状态:异步开关关闭(因为异步队列为空)。然后 ms 毫秒后添加一个任务 T 到队列中。
image.png
执行过程:主程序执行完之后,会通过 ProcessDelayTask 函数检查异步队列有没有到期可以执行的任务,如果异步队列不为空了,异步开关就会打开,然后主进程(白色方块)进入到异步队列,准备去执行黄色的 timeout 任务。等到期的任务执行完成之后,再继续下一个循环过程。

渲染过程

浏览器页面并不是时时刻刻被渲染的,它有固定的节奏(以 60 次/秒每次的频率)去渲染页面。渲染主要分为3个小步骤,称为 render steps。分别是:

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

image.png

我们看看如下的代码:

  1. button.addEventListener('click', () => {
  2. while(true){
  3. // 执行 console.log,整个浏览器都死掉了,是为什么?
  4. // console.log(9);
  5. }
  6. })

演示:while
当我们点击后会导致异步队列永远执行,因此不单单主进程,渲染过程也同样被阻塞而无法执行,因此页面无法再选中(因为选中时页面表现有所变化,文字有背景色,鼠标也变成 text,都会生成对应的任务添加到消息队列中,等待主线程的执行),这就是我们看到页面卡死状态。(但鼠标却可以动!)
image.png

如果我们把代码改成这样

  1. function loop() {
  2. setTimeout(loop, 0)
  3. }
  4. loop()

演示:setTimeout
每个异步任务的执行效果都是加入一个新的异步任务,新的异步任务将在下一次循环被执行,(如果在执行异步任务时,有其他到期的异步任务,这个时候也不会去执行,因为 ProcessDelayTask 函数下一个循环才会被执行 )因此就不会存在阻塞。主进程和渲染过程都能正常进行。

image.png

requestAnimationFrame

是一个特别的异步任务,只是注册的方法不加入异步队列,而是加入渲染这一边的队列中,它在渲染的三个步骤之前被执行。通常用来处理渲染相关的工作,简称为:raf

image.png

我们来看一下 setTimeout 和 requestAnimationFrame 的差别。假设我们有一个元素 box,并且有一个 moveBoxForwardOnePixel 方法,作用是让这个元素向右移动 1 像素。

  1. // 方法 1
  2. function callback() {
  3. moveBoxForwardOnePixel();
  4. requestAnimationFrame(callback)
  5. }
  6. callback()
  7. // 方法 2
  8. function callback() {
  9. moveBoxForwardOnePixel();
  10. setTimeout(callback, 0)
  11. }
  12. callback()

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

这是因为 setTimeout 在每次运行结束时都把自己添加到异步队列。等渲染过程的时候(不是每次执行异步队列都会进到渲染循环)异步队列已经运行过很多次了,所以渲染部分会一下会更新很多像素,而不是 1 像素requestAnimationFrame 只在渲染过程之前运行,因此严格遵守“执行一次渲染一次”,所以一次只移动 1 像素,是我们预期的方式。
image.png
setTimeout(callback, 1000 / 60) 任务示例

如果在低端环境兼容,常规也会写作 setTimeout(callback, 1000 / 60) 来大致模拟 60 fps 的情况,但本质上 setTimeout 并不适合用来处理渲染相关的工作。因此和渲染动画相关的,多用 requestAnimationFrame,不会有掉帧的问题(即某一帧没有渲染,下一帧把两次的结果一起渲染了)
image.png
requestAnimationFrame 任务示例

同步代码的合并

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

  1. box.style.display = 'none'
  2. box.style.display = 'block'
  3. box.style.display = 'none'

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

原因:其实js中每次设置dom的显示隐藏是生效的,但是由于主线程占用,没有时间去更新渲染,多有等主线程中任务执行完后,box 已经设置为隐藏了,并且这个时候浏览器进程渲染了,我们看到的是box隐藏了。

代码合并带来的困扰

有时候这种渲染模式也会给我们造成困扰,例如我要实现一个box盒子从1000移动到500的动画效果,如下代码:

  1. button.addEventListener('click', event=>{
  2. box.style.transform = 'translateX(1000px)'
  3. box.style.tranition = 'transform 1s ease'
  4. box.style.transform = 'translateX(500px)'
  5. })

演示:from1000to500
我们的本意是从让 box 元素的位置从 0 一下子 移动到 1000,然后动画移动 到 500。
但实际情况是从 0 动画移动 到 500。这也是由于浏览器的合并优化造成的。它并没有在乎中间的变化,第一句设置位置到 1000 的代码被忽略了。
image.png

解决方法1:requestAnimationFrame

我们刚才提过的 requestAnimationFrame。思路是让设置 box 的初始位置(第一句代码)在同步代码执行;让设置 box 的动画效果(第二句代码)和设置 box 的重点位置(第三句代码)放到下一帧执行。
但要注意,requestAnimationFrame 是在渲染过程 之前 执行的,因此直接写成

  1. box.style.transform = 'translateX(1000px)'
  2. requestAnimationFrame(() => {
  3. box.style.tranition = 'transform 1s ease'
  4. box.style.transform = 'translateX(500px)'
  5. })

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

  1. box.style.transform = 'translateX(1000px)'
  2. requestAnimationFrame(() => {
  3. requestAnimationFrame(() => {
  4. box.style.tranition = 'transform 1s ease'
  5. box.style.transform = 'translateX(500px)'
  6. })
  7. })

在渲染过程之前,再一次注册 requestAnimationFrame,这就能够让后两句代码放到下一帧去执行了,问题解决。(当然代码丑了点)

解决方法2:getComputedStyle

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

  1. box.style.transform = 'translateX(1000px)'
  2. getComputedStyle(box) // 伪代码,只要获取一下当前的计算样式即可
  3. box.style.tranition = 'transform 1s ease'
  4. box.style.transform = 'translateX(500px)'

getComputedStyle 可以迫使浏览器更早的执行样式计算,会让浏览器记下你在此之前设置的所有内容,但是这种做法要小心,因为你最终可能会让浏览器在一帧的时间内做不少多余的工作。

Microtasks

现在我们要引入“第三个”异步队列,叫做 microtasks。

简单来说, Microtasks 就是在 当次 事件循环的 结尾 立刻执行 的任务。Promise.then() 内部的代码就属于 microtasks。相对而言,之前的异步队列 (Task queue) 就叫做 macrotasks,不过一般还是简称为 tasks。

  1. function callback() {
  2. Promise.resolve().then(callback)
  3. }
  4. callback()

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

3 个异步队列特性

名称 执行特点


Tasks
(setTimeout)
Tasks 只执行一个。执行完了就进入主进程,主进程可能决定进入其他两个异步队列,也可能自己执行到空了再回来。
补充:对于“只执行一个”的理解,可以考虑设置 2 个相同时间的 timeout,两个并不会一起执行,而依然是分批的。案列演示:executeOnce



Animation callbacks
(requestAnimationFrame)
Animation callbacks 执行队列里的全部任务,但如果任务本身又新增 Animation callback 就不会当场执行了,因为那是下一个循环(下一帧)。
补充:同 Tasks,可以考虑连续调用两句 requestAnimationFrame,它们会在同一次事件循环内执行,有别于 Tasks。
Microtasks
(Promise.then)
Microtasks 队列有任务会一直执行完所有的微任务中才会下一个宏任务,如果任务本生有新增了 Microtasks(也可以说添加微任务的速度比执行快), 也会一直执行下去,直到微任务队列完全清空。所以上面的例子才会产生阻塞。

一段神奇的代码

这是本次讲座的高光部分
考虑如下的代码:
在同一个按钮上面有两个一样的事件监听器,方式一和方式二打赢结果会不一样吗?

  1. <script>
  2. // 方式一
  3. <button>按钮</button>
  4. <script>
  5. let button = document.querySelector('button');
  6. button.addEventListener('click', () => {
  7. Promise.resolve().then(()=>console.log('Microtask 1'));
  8. console.log('Listener 1');
  9. });
  10. button.addEventListener('click', () => {
  11. Promise.resolve().then(()=>console.log('Microtask 2'));
  12. console.log('Listener 2');
  13. });
  14. // 方式二
  15. button.click();
  16. </script>

案列演示:button.click

方式一:在浏览器上运行后点击按钮,会按顺序打印

listener 1 microtask 1 listener 2 microtask 2

方式二:在上面代码的最后加上 button.click() 打印

listener 1 listener 2 microtask 1 microtask 2

主要是 listener 2 和 microtask 1 次序的问题,原因如下:

  • 用户直接点击的时候,浏览器先后触发 2 个 listener。第一个 listener 触发完成 (listener 1) 之后,队列空了,就先打印了 microtask 1。然后再执行下一个 listener。重点在于浏览器并不实现知道有几个 listener,因此它发现一个执行一个,执行完了再看后面还有没有。(相当于将两个回调分别做成两个宏任务)
  • 而使用 button.click() 时,浏览器的内部实现是把 2 个 listener 都同步执行。因此 listener 1 之后,执行队列还没空,还要继续执行 “listener 2” 之后才行。所以 listener 2 会早于 microtask 1。重点在于浏览器的内部实现,click 方法会先采集有哪些 listener,再依次触发。(相当于将两个回调做成一个宏任务)

这个差别最大的应用在于自动化测试脚本。在这里可以看出,使用自动化脚本测试和真正的用户操作还是有细微的差别。如果代码中有类似的情况,要格外注意了。

再来两个测试题

第一题:

  1. console.log('Start')
  2. setTimeout(() => console.log('Timeout 1'), 0)
  3. setTimeout(() => console.log('Timeout 2'), 0)
  4. Promise.resolve().then(() => {
  5. for(let i=0; i<100000; i++) {}
  6. console.log('Promise 1')
  7. })
  8. Promise.resolve().then(() => console.log('Promise 2'))
  9. console.log('End');

第二题:

  1. console.log('Start')
  2. setTimeout(() => {
  3. console.log('Timeout 1')
  4. Promise.resolve().then(() => console.log('Promise 3'))
  5. }, 0)
  6. setTimeout(() => console.log('Timeout 2'), 0)
  7. Promise.resolve().then(() => {
  8. for(let i=0; i<100000; i++) {}
  9. console.log('Promise 1')
  10. })
  11. Promise.resolve().then(() => console.log('Promise 2'))
  12. console.log('End');

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

  1. let button = document.querySelector('#button');
  2. button.addEventListener('click', function CB1() {
  3. console.log('Listener 1');
  4. setTimeout(() => console.log('Timeout 1'))
  5. Promise.resolve().then(() => console.log('Promise 1'))
  6. });
  7. button.addEventListener('click', function CB1() {
  8. console.log('Listener 2');
  9. setTimeout(() => console.log('Timeout 2'))
  10. Promise.resolve().then(() => console.log('Promise 2'))
  11. });

案列演示:button.click2
第四题:(在浏览器上分别点击a标签和button标签)

  1. <body>
  2. <a href="https://www.baidu.com/" target="_blank">baobu</a>
  3. <button onclick="handle()">button.click()</button>
  4. <script>
  5. let link = document.querySelector('a');
  6. const nextClick = new Promise(resolve => {
  7. link.addEventListener('click', resolve, {onece: true})
  8. })
  9. nextClick.then(event=>{
  10. console.log('阻止了');
  11. event.preventDefault()
  12. })
  13. function handle (){
  14. link.click();
  15. }
  16. </script>
  17. </body>

案列演示:link.click
公布答案:

  • 第一题: Start, End, Promise 1, Promise 2, Timeout 1, Timeout 2
  • 第二题: Start, End, Promise 1, Promise 2, Timeout 1, Promise 3, Timeout 2
  • 第三题: Listener 1, Promise 1, Listener 2, Promise 2, Timeout 1, Timeout 2
  • 第三题: 点击a标签阻止了,点击button没有阻止

总结:

我们讲了js被设计为单线程的原因:如果有两条线程同时操作一个dom,一个新增一个删除容易发生混乱,这也是js被设计为单线程原因之一。 有了js为单线程,任务只能一个一个的执行,对于事件任务比较多的情况,如果前面任务执行事件比较久,就会引发阻塞情况,然后 js 引入了异步请求,在执行异步请求的时候不需要等到结果返回,主程序直接向下执行,等到异步注册函数结果返回, 将对应的回调任务推到任务队列中。然后事件循环采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。 js单线程的任务分为同步任务和异步任务,事件循环中任务又分为宏任务和微任务; 我们讲了3个异步队列,延时队列(setTimeout)、渲染前回调队列(requestAnimationFrame)、Microtasks 微任务队列(Promise.then)的队列特性;

js 异步是通过事件循环(Event Loop)来实现的,它是连接任务队列和调用栈的。 不同的异步操作添加到任务队列的时机也不同,比如onclick,setTimeout,ajax 处理方式都不同,这些异步操作都是浏览器内核来执行的,浏览器内核上包含3中 webAPI,分别是: DOM Binding(DOM绑定)、network(网络请求)、timer(定时器)模块。

参考: