JavaScript 事件循环(Event-Loop)通俗讲解与实用案例
引言
你是否曾对 JavaScript 中 setTimeout 的“不准时”感到困惑?或者对 Promise 和 async/await 的执行顺序感到好奇?别担心,这些都是每个 JS 学习者都会遇到的“拦路虎”。今天,我将带你一起揭开它们背后的神秘面纱——事件循环(Event Loop) 。
这篇博客将像一位耐心的向导,带你从最基础的“进程”和“线程”概念出发,一步步走进事件循环的核心,让你彻底搞懂 JS 的异步运行机制。准备好了吗?让我们开始这场奇妙的探索之旅吧!
Part 1:故事的起点 —— 进程与线程
在我们深入 JS 世界之前,先来聊聊两个计算机世界的基本角色:
- 进程(Process) :你可以把它想象成一个正在运行的“应用程序实例”。比如,你电脑上打开的微信、浏览器,每一个都是一个独立的进程。它有自己专属的内存空间,像一座独立的房子。
- 线程(Thread): 如果进程是房子,那线程就是房子里的“工人”。一个进程可以有一个或多个工人(线程)同时干活。比如,在浏览器这个大房子里,有负责请求网络资源的工人(HTTP 请求线程)、有负责执行 JS 代码的工人(JS 引擎线程),还有负责把页面画出来的工人(渲染线程)。
重点来了:在浏览器中,JS 引擎线程 和 渲染线程 这两个工人是“死对头”,它们不能同时工作。为什么呢?想象一下,如果 JS 工人正在修改页面元素(操作 DOM),而渲染工人同时在绘制页面,那页面不就乱套了吗?所以,当 JS 工人在干活时,渲染工人就必须停下来等待,反之亦然。这就是所谓的“JS 引擎和渲染线程互斥”。
Part 2:为什么需要异步?—— JS 的单线程宿命
我们知道 JS 的主要工作是操作 DOM、响应用户交互,这决定了它必须是单线程的。也就是说,JS 在同一时间只能做一件事。
这会带来一个问题:如果一个任务非常耗时(比如一个需要 5 秒的网络请求),那整个页面岂不是要卡住 5 秒钟,什么都干不了?这用户体验也太糟糕了!
为了解决这个问题,异步(Asynchronous) 概念应运而生。JS 引擎(比如 Chrome 的 V8)想出了一个聪明的办法:
遇到耗时的异步任务,先不执行,而是把它挂起来,放到一个叫做“任务队列(Task Queue) ”的地方。然后继续执行后面的同步代码。等到所有同步代码都执行完了,再回过头去看看任务队列里有没有需要处理的任务。
让我们来看个最简单的例子:
let a = 1
setTimeout(() => {
a = 2
console.log(a) // 1秒后才会执行
}, 1000)
console.log(a) // 立刻执行
执行分析:
- 代码从上到下执行,
let a = 1。 - 遇到
setTimeout,JS 引擎说:“哦,这是个异步任务,我先不管你,把你丢到任务队列里等着。” - 继续向下执行,
console.log(a),此时a还是1,所以控制台打印出1。 - 所有同步代码执行完毕。
- 大约 1 秒后,
setTimeout的回调函数被从任务队列中取出并执行,a被修改为2,控制台打印出2。
Part 3:事件循环的核心 —— 宏任务与微任务
现在,我们来深入事件循环的核心。任务队列里的任务其实还分为两种:
- 宏任务(MacroTask) :可以理解为比较“大”的任务。包括:
setTimeout、setInterval、setImmediate(Node.js)、I/O 操作、UI rendering 等。 - 微任务(MicroTask) :可以理解为比较“小”且需要尽快执行的任务。包括:
Promise.then()、catch()、finally()、MutationObserver、process.nextTick(Node.js) 等。
事件循环的执行顺序非常严格,请一定记住这个规则:
- 执行一个宏任务(通常是 script 脚本本身)。
- 执行过程中,遇到宏任务就把它放到宏任务队列,遇到微任务就把它放到微任务队列。
- 当前宏任务执行完毕后,立即检查微任务队列,并执行里面所有的微任务。
- 所有微任务执行完毕后,如有需要,进行页面渲染。
- 然后,从宏任务队列中取出一个任务,开始新一轮的循环。
让我们用一个经典的面试题来巩固一下:
console.log(1); // 同步
new Promise((resolve) => {
console.log(2); // 同步 (Promise构造函数是同步的)
resolve();
})
.then(() => { // 微任务
console.log(3);
setTimeout(() => { // 宏任务
console.log(4);
}, 0);
});
setTimeout(() => { // 宏任务
console.log(5);
setTimeout(() => { // 宏任务
console.log(6);
}, 0);
}, 0);
console.log(7); // 同步
你能推断出正确的输出顺序吗?
答案是:1, 2, 7, 3, 5, 4, 6
执行分析:
- 第一轮宏任务 (script) 开始执行。
console.log(1),输出1。new Promise,构造函数立即执行,console.log(2),输出2。.then的回调被放入微任务队列。- 遇到第一个
setTimeout,其回调被放入宏任务队列。 console.log(7),输出7。- 同步代码执行完毕。检查微任务队列,发现有一个
.then的回调。 - 执行微任务:
console.log(3),输出3。在其中又遇到一个setTimeout,其回调被放入宏任务队列。 - 微任务队列清空。
- 第一轮事件循环结束。
- 第二轮宏任务 开始,从宏任务队列中取出第一个任务(打印 5 的那个)。
console.log(5),输出5。又遇到一个setTimeout,其回调被放入宏任务队列。- 第三轮宏任务 开始,取出打印 4 的任务,
console.log(4),输出4。 - 第四轮宏任务 开始,取出打印 6 的任务,
console.log(6),输出6。
Part 4:现代异步方案 —— async/await
async/await 是 Promise 的语法糖,让异步代码写起来更像同步代码。但它的本质没有变,仍然是基于事件循环。
async函数会返回一个Promise。await后面通常跟着一个返回Promise的表达式。它会“暂停”async函数的执行,等待Promise状态变为 resolved,然后把resolve的值返回,并继续执行函数后面的代码。
重点来了:
await会将它后面的代码推入微任务队列。
来看这个例子:
console.log("script start");
async function async1() {
await async2(); // await 会阻塞后面的代码
console.log("async1 end"); // 这行代码进入微任务队列
}
async function async2() {
console.log("async2 end"); // 这行是同步代码
}
async1();
setTimeout(() => { // 宏任务
console.log("setTimeout");
}, 0)
new Promise((resolve, reject) => {
console.log("promise"); // 同步
resolve();
})
.then(() => { // 微任务
console.log("then1");
})
.then(() => { // 微任务
console.log("then2");
});
console.log("script end");
正确输出顺序:script start, async2 end, promise, script end, async1 end, then1, then2, setTimeout
执行分析:
- 同步代码:
script start->async1()调用 ->async2()调用 ->async2 end->promise->script end。 - 微任务:
await后面的async1 end,以及两个.then回调。按顺序执行:async1 end->then1->then2。 - 宏任务:最后执行
setTimeout。
Part 5:实用示例与常见问题解答
为了更好地理解事件循环,我们来看几个更复杂的例子,并解答一些初学者常遇到的问题。
示例一:宏任务与微任务的交替执行
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:宏任务,进入宏任务队列。Promise.resolve().then().then():第一个.then()是微任务,进入微任务队列。当第一个.then()执行完毕后,其返回的Promise会立即将第二个.then()作为微任务放入微任务队列。console.log("script end"):同步代码,立即执行,输出script end。
当前宏任务(同步代码)执行完毕。
- 检查微任务队列,执行第一个微任务
promise1,输出promise1。此时,第二个.then()立即作为微任务进入微任务队列。 - 微任务队列中还有任务,继续执行第二个微任务
promise2,输出promise2。
微任务队列清空。
- 检查宏任务队列,执行
setTimeout,输出setTimeout。
最终输出:
script start
script end
promise1
promise2
setTimeout
这个例子再次强调了微任务在当前宏任务执行完毕后,会优先于下一个宏任务执行,并且微任务队列会在每个宏任务执行完毕后被完全清空。
示例二:async/await 与 setTimeout 的结合
async function foo() {
console.log("foo start");
await bar();
console.log("foo end");
}
async function bar() {
console.log("bar");
}
console.log("script start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
foo();
new Promise(function(resolve) {
console.log("promise constructor");
resolve();
}).then(function() {
console.log("promise then");
});
console.log("script end");
思考一下,这段代码的输出顺序是什么?
解析:
-
console.log("script start"):同步代码,输出script start。 -
setTimeout:宏任务,进入宏任务队列。 -
foo()调用:console.log("foo start"):同步代码,输出foo start。await bar():bar()函数执行,输出bar。await暂停foo函数,并将console.log("foo end")作为微任务放入微任务队列。
-
new Promise(...):Promise 构造函数中的代码是同步执行的,输出promise constructor。resolve()被调用,将.then()回调作为微任务放入微任务队列。 -
console.log("script end"):同步代码,输出script end。
当前宏任务(同步代码)执行完毕。
- 检查微任务队列,执行
foo函数中await后面的微任务console.log("foo end"),输出foo end。 - 微任务队列中还有任务,执行
promise then,输出promise then。
微任务队列清空。
- 检查宏任务队列,执行
setTimeout,输出setTimeout。
最终输出:
script start
foo start
bar
promise constructor
script end
foo end
promise then
setTimeout
这个例子展示了 async/await 如何与 Promise 和 setTimeout 协同工作,以及微任务的优先级。
常见问题解答
Q1: 为什么 setTimeout(fn, 0) 不是立即执行?
A1: 尽管 setTimeout 的延迟时间设置为0,但它仍然是一个宏任务。根据事件循环的规则,宏任务必须等待当前所有同步代码和微任务执行完毕后才能执行。因此,setTimeout(fn, 0) 只是表示将 fn 放入宏任务队列,等待下一个事件循环周期执行。
Q2: Promise.resolve().then() 和 process.nextTick() 有什么区别?
A2: 两者都是微任务,但 process.nextTick() 在Node.js环境中具有更高的优先级,它会在当前宏任务执行完毕后,微任务队列中的其他任务之前立即执行。在浏览器环境中,process.nextTick() 不可用,Promise.then() 是最常用的微任务。
Q3: 事件循环和浏览器渲染有什么关系?
A3: 浏览器渲染通常发生在微任务队列清空之后,下一个宏任务开始之前。这意味着,如果你在微任务中修改了DOM,这些修改会在本次事件循环的渲染阶段被统一绘制到屏幕上。这有助于避免不必要的重复渲染,提高页面性能。
Q4: 如何避免JavaScript的阻塞?
A4: 避免JavaScript阻塞的关键在于合理利用异步编程。将耗时操作(如网络请求、大量计算)放入异步任务中,例如使用 Promise、async/await 或 setTimeout。这样可以确保主线程始终保持响应,提升用户体验。
总结
恭喜你!坚持看到了这里。现在,让我们来回顾一下今天学到的核心知识:
- JS 是单线程的,但通过事件循环机制实现了异步。
- 任务分为宏任务和微任务。
- 执行顺序是:一个宏任务 -> 所有微任务 -> (渲染) -> 下一个宏任务。
Promise.then和await后面的代码属于微任务,会优先于setTimeout等宏任务执行。
理解事件循环是每一位前端开发者的必备内功。希望这篇博客能为你打下坚实的基础。如果你觉得有帮助,不妨分享给更多正在学习的小伙伴吧!