在浏览器中,JavaScript 的执行是单线程的。如何在单线程中实现异步操作呢?答案就是事件循环。

事件循环(Event Loop)

浏览器通过事件循环来处理事件、用户交互、JS 代码执行、渲染、网络请求等。通常又两种事件循环,一种是 Window 事件循环,一种是 Worker 事件循环。由于它们核心的工作原理相同,本文我们仅仅讨论 Window 事件循环。

事件循环,首先是一个循环,每个循环周期会执行一些代码,一个循环周期被称为 tick

while (eventLoop.waitForTask()) {
  eventLoop.processNextTask()
}
1
2
3

一个事件循环有一到多个任务队列。每个任务队列就是一个有序的任务列表。可以理解为一段要执行的代码,或者浏览器要执行的一个动作,比如发送事件、解析 HTML 等。

网页和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。 该线程就是主线程open in new window,它除了运行网页本身的代码之外,还负责收集和派发用户和其它事件,以及渲染和绘制网页内容等。然后,事件循环会驱动发生在浏览器中与用户交互有关的一切。

执行过程

简略的说,事件循环在每一个循环周期都会顺序执行下面的步骤:

  1. 选择一个任务队列,从队列中取出最靠前(最老的)的任务。如果已经没有任务了,则跳到第 3 步。
  2. 执行取出的任务。
  3. 从微任务(Micro Task)队列中取出微任务执行,知道清空微任务队列。
  4. 更新渲染(resize、scroll、动画等)
  5. 返回第 1 步。

每一个时间循环都有一个微任务队列。微任务队列与任务队列很像,不同的地方在于,每次循环周期只会执行任务队列中的一个任务,在这期间产生的任何任务都只能在下一个循环周期中才能得以执行。在执行前任务后,事件循环会一次执行微任务队列中的每一个微任务,直到微任务队列为空。也就是说,在微任务执行过程中新产生的微任务,也会在当前循环周期内得到执行。

不同的任务队列可能有不同的优先级。比如浏览器可能会将用户鼠标和键盘输入(用户交互)的任务都放在一个任务队列中,其他任务放到另外一个队列中。在每个循环周期中优先从用户交互队列中取出任务执行,来保证及时响应用户操作。

下图展示了一个事件循环周期的执行过程。

事件循环

任务与微任务

一个任务可以简单的理解为一段要执行的 JavaScript 代码。比如当执行 <script> 标签中的代码时,一个任务会被添加到任务队列中。事件的回调函数、setTimeoutsetInterval 的回调函数都会作为任务放到任务队列中。

每个事件循环周期,事件循环会从任务队列中取出一个最老的任务执行,其他任务要等到下一个事件循环周期才会执行。

微任务与任务没有本质的区别,只是因为被放入的微任务任务队列。事件循环在每个循环周期都会清空微任务队列中的任务。

在浏览器的实现中,setTimeoutsetInterval 被放在了任务队列中,PromiseMutation Observer APIopen in new window 被放在的微任务队列中。我们也可以借助于 queueMicrotask()open in new window 函数向微任务队列中添加任务。

执行异步代码

我们有很多种方式来执行异步代码。

回调函数

我们可以给一些事件添加监听,设置回调函数,从而在触发事件的时候执行函数。

buttonEl.addEventListener('click', () => { /* 点击响应 */ });
1

回调函数会被放到任务队列中执行。

setTimeout 和 setInterval

这两个函数大家都非常熟悉,setTimeout 会在指定的时间之后执行回调函数,setInterval 会每个一定的时间执行一次回调函数。这些回调函数都是以任务的形式被添加到任务队列中。

setTimeout(() => {
  /* 1s 后执行 */
}, 1000);

setInterval(() => {
  /* 每 500 ms 执行一次 */
}, 500);
1
2
3
4
5
6
7

注意,这两个函数都接收一个时间参数。但是实际运行的时候,事件循环并不会保证一定按照这个时间执行。

requestAnimationFrame

requestAnimationFrame 是一个特殊的工具函数,浏览器会在重绘页面之前调用这个函数设置的回调,允许我们在页面重绘之前更新页面。

通过这个函数,我们可以很好的在代码执行和运行设备的显示帧率(display frame rate)之间取得一个平衡。

假如,我们通过 setInterval 来控制动画,由于每个事件循环周期执行的时间不可控,而显示器的刷新频率是固定的(通常是 60Hz),因此如果我们的动画执行过快,会出现掉帧,执行过慢又会有卡顿现象。

requestAnimationFrame 会综合考虑显示器的刷新频率和代码的执行,以达到动画能够顺滑执行。

requestAnimationFrame(() => {
  /* 执行一次 */
})

// 每个事件循环都会考虑执行
function alwaysRun() {
  /* 函数逻辑 */
  requestAnimationFrame(alwaysRun);
}
alwaysRun();
1
2
3
4
5
6
7
8
9
10

Promise 和 Async/await

Promise 和 Async/await 最终都会以 promise 的形式在代码中执行。

Promise 可以帮助我们管理有依赖关系的任务,同时可以一定程度的避免回调函数的回调地狱问题。

new Promise((resolve, reject) => {
  /* 函数逻辑 */
})
.then()
.then() // 链式调用
1
2
3
4
5

Async/await 是 Promise 的语法糖,可以帮助我们实现以同步的形式编写异步代码,解决了回调地狱问题。

async function task() {
  await someFunc();
  await someOtherFunc();
}
1
2
3
4

Promise 和 Async/await 的任务都是微任务,会被放到微任务队列中。

参考资料

关注微信公众号,获取最新推送~