在浏览器中,JavaScript 的执行是单线程的。如何在单线程中实现异步操作呢?答案就是事件循环。
事件循环(Event Loop)
浏览器通过事件循环来处理事件、用户交互、JS 代码执行、渲染、网络请求等。通常又两种事件循环,一种是 Window 事件循环,一种是 Worker 事件循环。由于它们核心的工作原理相同,本文我们仅仅讨论 Window 事件循环。
事件循环,首先是一个循环,每个循环周期会执行一些代码,一个循环周期被称为 tick
。
while (eventLoop.waitForTask()) {
eventLoop.processNextTask()
}
2
3
一个事件循环有一到多个任务队列。每个任务队列就是一个有序的任务列表。可以理解为一段要执行的代码,或者浏览器要执行的一个动作,比如发送事件、解析 HTML 等。
网页和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。 该线程就是主线程open in new window,它除了运行网页本身的代码之外,还负责收集和派发用户和其它事件,以及渲染和绘制网页内容等。然后,事件循环会驱动发生在浏览器中与用户交互有关的一切。
执行过程
简略的说,事件循环在每一个循环周期都会顺序执行下面的步骤:
- 选择一个任务队列,从队列中取出最靠前(最老的)的任务。如果已经没有任务了,则跳到第 3 步。
- 执行取出的任务。
- 从微任务(Micro Task)队列中取出微任务执行,知道清空微任务队列。
- 更新渲染(resize、scroll、动画等)
- 返回第 1 步。
每一个时间循环都有一个微任务队列。微任务队列与任务队列很像,不同的地方在于,每次循环周期只会执行任务队列中的一个任务,在这期间产生的任何任务都只能在下一个循环周期中才能得以执行。在执行前任务后,事件循环会一次执行微任务队列中的每一个微任务,直到微任务队列为空。也就是说,在微任务执行过程中新产生的微任务,也会在当前循环周期内得到执行。
不同的任务队列可能有不同的优先级。比如浏览器可能会将用户鼠标和键盘输入(用户交互)的任务都放在一个任务队列中,其他任务放到另外一个队列中。在每个循环周期中优先从用户交互队列中取出任务执行,来保证及时响应用户操作。
下图展示了一个事件循环周期的执行过程。
任务与微任务
一个任务可以简单的理解为一段要执行的 JavaScript 代码。比如当执行 <script>
标签中的代码时,一个任务会被添加到任务队列中。事件的回调函数、setTimeout
、setInterval
的回调函数都会作为任务放到任务队列中。
每个事件循环周期,事件循环会从任务队列中取出一个最老的任务执行,其他任务要等到下一个事件循环周期才会执行。
微任务与任务没有本质的区别,只是因为被放入的微任务任务队列。事件循环在每个循环周期都会清空微任务队列中的任务。
在浏览器的实现中,
setTimeout
和setInterval
被放在了任务队列中,Promise
和 Mutation Observer APIopen in new window 被放在的微任务队列中。我们也可以借助于 queueMicrotask()open in new window 函数向微任务队列中添加任务。
执行异步代码
我们有很多种方式来执行异步代码。
回调函数
我们可以给一些事件添加监听,设置回调函数,从而在触发事件的时候执行函数。
buttonEl.addEventListener('click', () => { /* 点击响应 */ });
回调函数会被放到任务队列中执行。
setTimeout 和 setInterval
这两个函数大家都非常熟悉,setTimeout
会在指定的时间之后执行回调函数,setInterval
会每个一定的时间执行一次回调函数。这些回调函数都是以任务的形式被添加到任务队列中。
setTimeout(() => {
/* 1s 后执行 */
}, 1000);
setInterval(() => {
/* 每 500 ms 执行一次 */
}, 500);
2
3
4
5
6
7
注意,这两个函数都接收一个时间参数。但是实际运行的时候,事件循环并不会保证一定按照这个时间执行。
requestAnimationFrame
requestAnimationFrame
是一个特殊的工具函数,浏览器会在重绘页面之前调用这个函数设置的回调,允许我们在页面重绘之前更新页面。
通过这个函数,我们可以很好的在代码执行和运行设备的显示帧率(display frame rate)之间取得一个平衡。
假如,我们通过 setInterval
来控制动画,由于每个事件循环周期执行的时间不可控,而显示器的刷新频率是固定的(通常是 60Hz),因此如果我们的动画执行过快,会出现掉帧,执行过慢又会有卡顿现象。
requestAnimationFrame
会综合考虑显示器的刷新频率和代码的执行,以达到动画能够顺滑执行。
requestAnimationFrame(() => {
/* 执行一次 */
})
// 每个事件循环都会考虑执行
function alwaysRun() {
/* 函数逻辑 */
requestAnimationFrame(alwaysRun);
}
alwaysRun();
2
3
4
5
6
7
8
9
10
Promise 和 Async/await
Promise 和 Async/await 最终都会以 promise
的形式在代码中执行。
Promise 可以帮助我们管理有依赖关系的任务,同时可以一定程度的避免回调函数的回调地狱问题。
new Promise((resolve, reject) => {
/* 函数逻辑 */
})
.then()
.then() // 链式调用
2
3
4
5
Async/await 是 Promise 的语法糖,可以帮助我们实现以同步的形式编写异步代码,解决了回调地狱问题。
async function task() {
await someFunc();
await someOtherFunc();
}
2
3
4
Promise 和 Async/await 的任务都是微任务,会被放到微任务队列中。
参考资料
- HTML5 规范中的事件循环open in new window
- 深入:微任务与Javascript运行时环境open in new window
- Using microtasks in JavaScript with queueMicrotask()open in new window
- Choosing the right approachopen in new window
关注微信公众号,获取最新推送~
加微信,深入交流~