JS事件循环

之前在公众号发的一篇文章,在这里再发一次


先来看一道常见的面试题,请给出下面程序的打印顺序

console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')

单纯记住答案没有什么意义,懂得背后的道理才是关键,理解JS的事件循环(Event Loop)便能彻底搞懂其原因。当然,除了这题,还有许多其他更重要的问题能从事件循环的原理中得到解决。

- Event Loop 概览 -

事件循环(Event Loop)是JS中相当重要的概念,它描述了JS整体上的运行机制,理解它对于深层次的开发工作有很大帮助。下面这张图大致说明了事件循环的流程

图1 事件循环

图中的几个概念解释:

  • 任务 - 需要执行的代码,比如js文件、某个函数等。

  • 任务队列 - 存放任务的队列(Queue),用于后续按先进先出(FIFO)原则逐个执行任务。图中有“宏任务”与“微任务”两个列队,后面会细说。

  • 执行栈 - Stack,具体执行任务的地方。

JS引擎会一直按照该图所示流程循环下去,作为切入点我们可以从Process 1开始观察。页面中通过script标签引入的js文件或者是直接写在script标签中的代码,最初都会被浏览器放入宏任务队列。整个流程大致分为3个阶段

  1. JS引擎会查看宏任务队列中是否有等待执行的任务,如果有则把第一个任务拿出执行;

  2. 在每次执行完成任务后查看微任务队列中是否有等待执行的任务,如果有则拿出第一个去执行,如此直至微任务队列为空。我们可以把Process 3、4、5理解为是整个循环中的一个微循环;

  3. 之后会判断是否需要更新页面,更新页面后(或者是不需要更新)完成一次循环,回到阶段1。

这里简单说明一下更新页面。浏览器会努力以60帧每秒的频率更新页面,每更新一次称作一帧。由于一次事件循环的用时可能会小于两帧之间的时间差,所以并不是每一次循环都会有更新页面的操作。更新页面的具体操作包括:输入事件(如keypress, click)和其它某些事件(如window resize)的发送(dispatch),requestAnimationFrame回调的执行,当然还有重排、重绘等等。

- 异步 -

什么是异步?就是同时进行着两件或两件以上的事,比如你在看这篇文章的同时还在听歌。众所周知,JS是单线程程序,所以它自己是无法做到异步的。JS不能像你一样,它要么先看完文章再听歌,要么先听完歌再看文章。JS的异步通常都是与它所处的环境相配合才能做得到。在浏览器中而言,自然就是浏览器提供的帮助。setTimeout、setInterval、ajax请求等方法实际上都属于Web APIs,即它们都是由浏览器提供的,你只是通过JS来调用而已。

举个例子

// some codes
// ...
// 500毫秒后执行方法funcA
setTimeout(funcA, 500) 
// some other codes
// ...

假设这段代码当前正在JS的执行栈中运行,则其在时间上大概是下图的样子

图2 setTimeout时序

当执行到setTimeout(funcA, 500)时,就是在告诉浏览器等500毫秒后把funcA放到宏任务队列。可以看到,浏览器在计时500毫秒的过程与JS执行栈中执行"some other codes"的过程是并行进行的。

同时我们应该注意到,funcA并不是在500毫秒后立即被执行,而只是被放入到了宏任务队列,至于什么时候会被执行就要看事件循环的具体情况了。如果此刻它是宏任务队列中的第一个,则事件循环再次走到Process 1时funcA便会被放入栈中执行,否则它还需要等待前面的任务都执行完。至此我们也能得出setTimeout并不精确,它只是保证了一个最少等待时间,setInterval也是同样的道理。

总结下JS的异步:它是由JS的事件循环机制加上浏览器的帮助--把相应的任务(通常是一个回调函数)放入到任务队列--来实现的。

上面有提到,任务队列分为宏任务微任务,它们在事件循环中的区别在图1中可以清楚的分辨:宏任务队列中的每个任务执行后都走一遍完整的循环,但微任务队列中的所有任务都执行完才会进入当前循环的下一步。除了在事件循环机制中的区别,它们的任务来源也不同。

微任务来源:Promise的then、catch、finally方法传入的回调函数,异步方法(async/await)里await之后的内容,调用queueMicrotask方法传入的回调函数

宏任务来源:script标签引入的JS文件,script标签中的代码,setTimeout与setInterval方法的回调函数,DOM事件的处理函数(handler或者叫listener),以及其他需要异步执行的内容或回调函数

我们回头看看那道面试题,按照事件循环走一下就很清晰了

// 当前代码正在执行,说明现在处于 Process 3
​
// 直接打印,所以是第一个输出
console.log('A')
// 回调函数被“立刻”放入宏任务队列等待执行
setTimeout(() => console.log('B'), 0)
// 一个立即完成的Promise,then的回调函数被立刻放入微任务队列等待执行
Promise.resolve().then(() => console.log('C'))
// 直接打印,所以是第二个输出
console.log('D')
// 代码执行完成,目前输出结果是 AD

当这段代码执行完成即Process 3结束,然后走到Process 4,发现微任务队列中有一个回调函数需要执行,则拿出到Process 3执行,此时输出‘C’,执行完后再次回到Process 4,微任务队列已空所以继续向下,经过(或未经过)更新页面后,最终来到Process 1,发现宏任务队列有一个回调函数需要执行,将其拿出到Process 3执行,此时输出‘B’。所以最终的输出顺序为ADCB

- UI阻塞 -

图1中可以看到,更新页面的Process 6会等待前置的所有步骤,所以如果Process 3 或者说是Process 3、4、5整个微循环耗时过长,则更新页面的频率可能就达不到60帧每秒的频率,页面会出现卡顿无反应(输入事件的发送也是在更新页面的步骤里)。如果你的代码里有死循环,则Process 3将永远不会结束,导致页面永远卡死,直到弹出一个页面无响应的对话框问你是否要结束该页面:

图3

实际上不用死循环,只要你的代码在Process 3执行的时间够长,浏览器就会弹出这个对话框。

如果你的代码里有DOM操作,比如插入新元素或者改变元素状态,但是代码耗时比较久--比如要处理大量数据,则页面上显现出结果也就相应的延迟,给人的感觉就是卡顿。

通过事件循环机制我们已经明白为什么当代码耗时过长会阻塞UI,解决的方法自然也很明显:避免Process 3耗时过长。一个方法是另起一个worker,让耗时的代码在worker的线程中运行,这样自然不会占用主线程中Process 3的时间。但worker也有一个缺点--不能操作DOM。实际上除了worker还有一个办法,在worker出现之前经常会使用,即拆分代码。把耗时过长的代码拆分到多次事件循环中执行,即一次事件循环只执行一部分,这样就有时间更新页面了。例如

const num = 1e6 // 假设有一百万个数据需要处理
let cur = 0 // 当前处理到第几条
const deal = () => {
  do {
    // 处理一条数据
    // ...
    cur += 1
  } while(cur < num && cur % 1000 !== 0) // 每次执行1000条
​
  if (cur < num) { // 如果还有数据未处理
    setTimeout(deal) // 放入宏任务队列,等待执行
  } else {
    console.log('处理完成')
  }
// 开始执行
deal()

虽然setTimeout没有指定等待时间,也就是使用默认的0毫秒,但实际上浏览器会有一个最小等待时间,大约是4毫秒。为了节省一点点时间,我们完全可以把setTimeout放到最前面

const num = 1e6 // 假设有一百万个数据需要处理
let cur = 0 // 当前处理到第几条
const deal = () => {
  // setTimeout放在最前面
  if (cur + 1000 < num) { // 如果这次处理不完数据
    setTimeout(deal) // 放入宏任务队列,等待下次执行
  }
  
  do {
    // 处理一条数据
    // ...
    cur += 1
  } while(cur < num && cur % 1000 !== 0) // 每次执行1000条
​
  if (cur >= num) {
    console.log('拆分方式处理完成')
  }
// 开始执行
deal()

将setTimeout放到前面,则浏览器的计时等待与代码里的数据处理同时进行,从而可以节省时间。

- 初始动画 -

来看一段代码

const div = document.createElement('div')
div.style.padding = '0.5em'
div.style.margin = '10px'
div.style.boxSizing = 'border-box'
div.style.backgroundColor = 'white'
div.style.transition = 'all linear 2s'
​
document.body.appendChild(div)
// 原本设想当元素加添到body后通过背景渐变的方式显示出来
div.style.backgroundColor = 'red'

这段代码本意是想利用transition,在新div插入到body后修改div的背景颜色,以达到动态渐变显现的效果,但真实情况是它直接就是红色。问题在于transition是在更新页面的步骤中触发的。这段代码执行完来到更新页面步骤时,div样式的背景颜色已经是红色,而且它是新添加的元素,与上一帧相比这个div的样式没有变化,所以根本不会有过渡动画。我们本可以尝试使用setTimout来修改div的背景颜色,但多试几次后会发现过渡效果有时出现有时不出现,其原因前面也有提到,因为浏览器会尝试以60帧每秒的频率更新页面,所以不是每次事件循环都会有更新。所以要想确保有过渡动画,就要确保div被插入body后经历过一次更新页面,然后再修改div的背景颜色,再次更新页面时浏览器才会知道其背景颜色有变化

const div = document.createElement('div')
div.style.padding = '0.5em'
div.style.margin = '10px'
div.style.boxSizing = 'border-box'
div.style.backgroundColor = 'white'
div.style.transition = 'all linear 2s'
​
document.body.appendChild(div)
​
// 使用rAF两次,确定元素状态会在页面更新之后才被修改
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    div.style.backgroundColor = 'red'
  })
})

requestAnimationFrame(简称rAF)的回调函数是在更新页面步骤里执行的,若在回调函数里再次使用rAF,新的回调函数会在下一次更新时执行。上面的代码中,外层的rAF确保了新插入的div会更新到页面上,内层的rAF确保了在div更新到页面上之后的一帧修改div背景。


这次就说这些,希望我都讲明白了。相关的示例代码可在浏览器中打开下面的地址查看:

https://letjs.fun/workbench/xFP7h

你可能感兴趣的:(JS事件循环)