一. 引言
嘿,你是否曾对JavaScript中那些“奇怪”的执行顺序感到困惑?为什么setTimeout(..., 0)
不会立即执行?为什么Promise
的then
方法总是在setTimeout
之前触发?这些问题的答案都藏在一个核心概念里——JavaScript事件循环(Event Loop)。
二. 预备知识——进程与线程
在深入Event Loop之前,我们先来补习一下计算机基础知识:进程和线程。理解它们,能帮助我们更好地理解JavaScript的运行环境。
2.1 什么是进程?
想象一下,你的手机上安装了微信、支付宝、抖音等很多应用。当你点击微信图标,微信应用开始启动,系统会为它分配独立的内存空间、CPU时间等资源。这个独立的、正在运行的程序实例,就是进程。简单来说,一个进程就是CPU在运行指令和保存上下文所需要的时间。
举个例子: 当你打开微信,从点击图标到微信界面完全显示出来,再到你使用微信聊天、刷朋友圈,直到你彻底关闭微信,这个过程中,微信就作为一个独立的进程在你的手机上运行。它拥有自己独立的资源,与其他应用互不干扰。
2.2 什么是线程?
线程是进程中的一个更小的单位,它是进程内的一个执行流,指的是执行一段指令所需的时间。一个进程可以包含一个或多个线程。线程共享进程的资源,但每个线程有自己独立的执行栈。
继续以微信为例: 在微信这个进程内部,可能有很多不同的线程在同时工作:
- 渲染线程: 负责绘制微信的界面,让你看到漂亮的UI。
- 网络线程: 负责从服务器获取最新的消息、朋友圈动态等数据。
- 聊天线程: 处理你发送和接收的聊天消息。
- 视频线程: 处理视频通话或观看视频的功能。
这些线程在同一个微信进程中协同工作,共同完成了微信的各项功能。它们共享微信进程的内存空间,但各自执行不同的任务。
2.3 浏览器中的进程与线程
现在,我们把目光转向浏览器。你可能不知道,浏览器也是一个多进程的应用程序。当你新开一个浏览器Tab页面时,通常就意味着开启了一个新的浏览器进程。这个进程是独立的,即使其中一个Tab崩溃了,也不会影响到其他Tab的正常运行。
在浏览器进程内部,也有许多线程在协同工作,最终将网页内容展示给你。其中,与我们理解Event Loop最密切相关的有以下几种线程:
- HTTP请求线程(网络线程): 负责处理网络请求,比如下载HTML、CSS、JavaScript文件,或者发送Ajax请求获取数据。
- JS引擎线程: 负责解析和执行JavaScript代码。划重点:JavaScript是单线程的,这意味着在同一时间,JS引擎线程只能做一件事。
- 渲染线程(GUI渲染线程): 负责解析HTML和CSS,构建DOM树和渲染树,并最终将页面绘制到屏幕上。当页面需要重绘或回流时,也是由它来完成。
一个非常重要的概念是:JS引擎线程和渲染线程是互斥的。 这意味着当JS引擎线程在执行JavaScript代码时,渲染线程会被挂起,无法进行页面的渲染。反之亦然。这就是为什么长时间运行的JavaScript代码会阻塞页面渲染,导致页面“卡死”的原因。而其他线程之间,比如HTTP请求线程和JS引擎线程,则可以并行工作,互不影响。
三. JavaScript的单线程特性与异步
3.1 JavaScript为什么是单线程的?
JavaScript被设计成单线程的,主要是为了避免DOM操作的复杂性。如果JavaScript是多线程的,那么当多个线程同时操作同一个DOM元素时,就会出现竞态条件(Race Condition),导致不可预测的结果。例如,一个线程要删除某个DOM元素,另一个线程要修改它,那么到底应该以哪个线程的操作为准呢?为了避免这种复杂性,JavaScript从诞生之初就被设计为单线程。
这意味着,JavaScript引擎在执行代码时,默认只开启一个线程工作。这个线程就是我们前面提到的JS引擎线程。它负责从头到尾地执行你的JavaScript代码,一次只处理一个任务。
3.2 单线程如何处理耗时操作?——异步机制
既然JavaScript是单线程的,那么它如何处理那些耗时很长的操作呢?比如网络请求(Ajax)、定时器(setTimeout、setInterval)或者文件读写?如果这些操作都同步执行,那么在它们完成之前,JS引擎线程就会一直被阻塞,导致页面长时间无响应,用户体验极差。
为了解决这个问题,JavaScript引入了异步机制。当JS引擎线程遇到异步代码时,它不会等待异步操作完成,而是会将其“挂起”,交给其他线程(比如浏览器提供的Web APIs)去处理,然后JS引擎线程继续执行后续的同步代码。当异步操作完成后,它会将一个“任务”放入**任务队列(Task Queue)**中,等待JS引擎线程空闲时再来处理。
3.3 同步代码与异步代码的区别
-
同步代码: 按照代码书写的顺序,一行一行地执行。只有当前一行代码执行完毕,才能执行下一行。如果某一行代码耗时很长,那么后续代码都会被阻塞。
console.log('Start'); alert('这是一个同步操作,会阻塞页面'); // 阻塞 console.log('End');
上面的代码会先打印 'Start',然后弹出警告框,只有你点击确定后,才会打印 'End'。在警告框弹出期间,页面是无法进行任何操作的。
-
异步代码: 不会立即执行,而是会被“挂起”,等待某个条件满足(比如定时器时间到了,网络请求回来了)后,再将对应的回调函数放入任务队列,等待JS引擎线程空闲时执行。异步代码不会阻塞主线程的执行。
console.log('Start'); setTimeout(() => { console.log('Async operation finished'); }, 0); console.log('End');
这段代码的执行顺序是:先打印 'Start',然后
setTimeout
被挂起,JS引擎线程继续执行,打印 'End'。最后,当JS引擎线程空闲时(即使setTimeout
设置的时间是0毫秒),setTimeout
的回调函数才会被执行,打印 'Async operation finished'。这就是异步的魅力,它让JavaScript在单线程的环境下也能处理复杂的、耗时的任务,而不会阻塞用户界面。
3.4 任务队列的概念
任务队列是一个先进先出(FIFO)的队列,用于存放异步操作完成后需要执行的回调函数。当异步操作(比如setTimeout
的计时结束,或者Ajax请求成功返回数据)满足了执行条件时,它们对应的回调函数并不会立即执行,而是会被放入这个任务队列中排队。JS引擎线程会在执行完所有同步代码后,才从任务队列中取出任务来执行。这个不断从任务队列中取出任务并执行的过程,就是Event Loop的核心所在。
四. 深入理解Event Loop
4.1 Event Loop是什么?
Event Loop,直译过来就是“事件循环”。它是JavaScript运行时环境(比如浏览器或Node.js)中的一个机制,用于协调同步代码、异步代码以及各种任务的执行。正是Event Loop的存在,才使得JavaScript这个单线程语言能够实现非阻塞的异步操作。
你可以把Event Loop想象成一个永不停歇的循环,它不断地检查两个地方:一个是主线程的执行栈(Call Stack),另一个是任务队列。当执行栈清空时,Event Loop就会从任务队列中取出任务,放到执行栈中执行。
4.2 宏任务(Macro-tasks)与微任务(Micro-tasks)
在JavaScript的异步世界里,任务被分成了两种类型:宏任务(Macro-tasks)和微任务(Micro-tasks)。这两种任务在Event Loop中有着不同的优先级和执行时机。
宏任务(Macro-tasks)包括:
setTimeout
setInterval
- I/O操作(例如网络请求
ajax
、文件读写) - UI渲染
setImmediate
(Node.js环境特有)
微任务(Micro-tasks)包括:
Promise.then()
、Promise.catch()
、Promise.finally()
process.nextTick
(Node.js环境特有)MutationObserver
(用于监听DOM变化)
4.3 Event Loop的执行顺序详解
理解了宏任务和微任务,我们就可以详细地梳理Event Loop的执行顺序了。这个顺序是理解JavaScript异步编程的关键:
-
执行同步代码: 当JavaScript代码开始执行时,会首先执行所有的同步代码。这些同步代码可以被看作是当前宏任务的一部分。在执行过程中,如果遇到异步任务(无论是宏任务还是微任务),就会将其对应的回调函数放入相应的任务队列中。
-
清空微任务队列: 当所有同步代码执行完毕后,Event Loop并不会立即去执行宏任务队列中的任务。它会优先检查并清空微任务队列。这意味着,所有在当前宏任务执行期间产生的微任务,都会在下一个宏任务开始之前被执行完毕。
-
页面渲染(可选): 在微任务队列清空之后,如果浏览器判断有必要进行页面渲染(比如DOM结构发生了变化,或者需要更新UI),它就会进行一次页面渲染。这一步是可选的,浏览器会根据实际情况决定是否进行渲染。
-
执行下一个宏任务: 页面渲染完成后,Event Loop会从宏任务队列中取出一个任务来执行。这个任务执行完毕后,又会重复步骤2,检查并清空微任务队列,然后再次进行页面渲染(如果需要),接着再从宏任务队列中取出下一个任务……如此循环往复,直到所有任务执行完毕。
这个过程可以概括为:一个宏任务执行完毕 -> 清空所有微任务 -> 页面渲染(如果需要) -> 执行下一个宏任务。这个循环会一直持续下去,直到所有任务都执行完毕。
五. await
关键字在Event Loop中的表现
async/await
是ES2017引入的异步编程语法糖,它让异步代码看起来像同步代码一样,极大地提高了代码的可读性和可维护性。但await
的底层实现依然是基于Promise
和Event Loop的。
5.1 await
的本质:将后续代码推入微任务队列
当你使用await
关键字时,它会暂停async
函数的执行,直到await
后面的Promise
对象状态变为resolved
或rejected
。而await
的精妙之处在于,它会将await
后面的代码(即async
函数中await
表达式之后的代码)推入微任务队列。
这意味着,当await
等待的Promise
解决后,async
函数中被暂停的代码会作为微任务被添加到微任务队列中,等待当前宏任务执行完毕,并且所有已有的微任务执行完毕后,才会轮到它执行。
5.2 浏览器对await
执行时间的优化
在早期的async/await
实现中,await
的行为可能与你想象的有所不同。但随着规范的演进和浏览器引擎的优化,现在await
的执行顺序更加符合直觉。
以前的await
执行原理(已过时,仅作了解):
在旧的实现中,await
会将await
后面的代码(即async
函数中await
表达式之后的代码)作为一个微任务放入微任务队列。而await
表达式本身,如果它等待的是一个Promise
,那么这个Promise
的then
方法也会产生一个微任务。这可能导致一些复杂的执行顺序问题。
现在的await
执行顺序:
现在,
await
的执行机制可以这样理解:当await
一个Promise
时,它会“阻塞”async
函数的执行,直到Promise
解决。一旦Promise
解决,await
会立即将async
函数中await
表达式后面的代码作为微任务添加到微任务队列中。而await
表达式本身,可以被看作是立即执行的,它只是等待一个值。
结合代码示例深入分析await
的执行流程:
让我们通过一个具体的例子来理解await
的执行流程。假设我们有以下代码:
// async.js
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
请你预测一下这段代码的输出顺序。
我们来一步步分析:
-
console.log("script start"): 首先执行同步代码,输出
script start
。 -
setTimeout
: 遇到setTimeout
,将其回调函数放入宏任务队列。 -
async1()
: 调用async1
函数。
- console.log("async1 start"):
async1
函数内部,首先输出async1 start
。 await async2()
: 遇到await
。async2
函数被调用,输出async2
。await
会暂停async1
的执行,并将async1
中await
后面的代码(即console.log("async1 end")
)放入微任务队列。
new Promise(...)
: 继续执行同步代码,遇到Promise
。
console.log("promise1")
:Promise
构造函数是同步执行的,所以立即输出promise1
。resolve()
:Promise
状态变为resolved
。.then(...)
:then
方法的回调函数被放入微任务队列。
console.log("script end")
: 继续执行同步代码,输出script end
。
至此,所有的同步代码执行完毕。此时,微任务队列中有两个任务:
async1
中await
后面的代码 (console.log(async1 end")
)Promise.then
的回调 (console.log(promise2")
)
根据Event Loop的执行顺序,接下来会清空微任务队列:
- 执行微任务队列: 先执行
async1
中await
后面的代码,输出async1 end
。 - 执行微任务队列: 再执行
Promise.then
的回调,输出promise2
。
微任务队列清空后,检查宏任务队列:
- 执行宏任务队列: 执行
setTimeout
的回调,输出setTimeout
。
最终的输出顺序是:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
这个例子完美地展示了async/await
在Event Loop中的执行机制,以及微任务的优先级高于宏任务的特性。
六. 实战演练与常见误区
理解了Event Loop的原理,我们再来看一些实际场景和常见的误区,帮助你更好地巩固知识。
6.1 经典面试题分析
Event Loop是前端面试的常客,尤其是关于输出顺序的题目。除了上面async/await
的例子,我们再来看一个经典的题目:
// 1.js
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
请你再次预测一下这段代码的输出顺序。
分析:
-
console.log("script start")
: 首先执行同步代码,输出script start
。 -
setTimeout
: 遇到setTimeout
,将其回调函数放入宏任务队列。 -
Promise.resolve().then(...)
:Promise.resolve()
会立即返回一个已解决的Promise。它的第一个.then
回调函数被放入微任务队列。 -
console.log("script end")
: 继续执行同步代码,输出script end
。
至此,所有同步代码执行完毕。此时微任务队列中有:promise1
的回调。
- 执行微任务队列: 执行
promise1
的回调,输出promise1
。注意,promise1
的回调执行完毕后,它返回的Promise会立即resolve
,因此其后面的.then
回调(promise2
)会立即被放入微任务队列。 - 继续执行微任务队列: 执行
promise2
的回调,输出promise2
。
微任务队列清空。检查宏任务队列:
- 执行宏任务队列: 执行
setTimeout
的回调,输出setTimeout
。
最终的输出顺序是:
script start
script end
promise1
promise2
setTimeout
这个例子再次强调了微任务的优先级。即使setTimeout
的延迟时间设置为0,它也必须等待当前宏任务中的所有微任务执行完毕后才能执行。
6.2 常见错误理解与纠正
-
误区:
setTimeout(fn, 0)
会立即执行。 纠正:setTimeout(fn, 0)
表示将fn
放入宏任务队列,等待主线程空闲且所有微任务执行完毕后,在下一个宏任务阶段执行。它并不能保证立即执行,只是表示“尽快”执行。 -
误区:
async/await
是同步的。 纠正:async/await
只是语法糖,它让异步代码看起来像同步代码,但其本质仍然是基于Promise
的异步操作,并且await
后面的代码会被推入微任务队列,遵循Event Loop的规则。 -
误区:只要是异步代码,就一定会比同步代码后执行。 纠正: 这句话不完全正确。异步代码的回调函数确实会在同步代码执行完毕后才执行,但微任务的优先级高于宏任务。所以,在同步代码执行完毕后,会先执行所有微任务,再执行宏任务。
6.3 优化异步代码的建议
理解Event Loop不仅是为了面试,更是为了写出更好的代码:
- 避免长时间运行的同步代码: 长时间运行的同步代码会阻塞主线程,导致页面卡顿。如果遇到耗时操作,考虑将其拆分为多个小任务,或者使用Web Workers在后台线程中执行。
- 合理使用
Promise
和async/await
: 它们是处理异步操作的强大工具,能够让你的代码更清晰、更易维护。但也要注意,滥用await
可能会导致性能问题,例如在循环中频繁使用await
。 - 理解任务优先级: 清楚宏任务和微任务的执行顺序,可以帮助你更好地预测代码行为,避免不必要的bug。
七. 总结
恭喜你!通过本文的学习,相信你已经对JavaScript事件循环有了全面而深入的理解。我们从最基础的进程与线程概念入手,逐步剖析了JavaScript的单线程特性、异步机制,以及Event Loop的核心——宏任务与微任务的执行顺序。最后,我们还详细探讨了async/await
在Event Loop中的表现,并通过实战案例加深了理解。
希望本文能为你打开Event Loop的大门,让你在前端开发的道路上走得更远、更稳!