JavaScript 事件循环(Event-Loop)通俗讲解与实用案例

1,967 阅读10分钟

JavaScript 事件循环(Event-Loop)通俗讲解与实用案例

引言

你是否曾对 JavaScript 中 setTimeout 的“不准时”感到困惑?或者对 Promiseasync/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) // 立刻执行

执行分析

  1. 代码从上到下执行,let a = 1
  2. 遇到 setTimeout,JS 引擎说:“哦,这是个异步任务,我先不管你,把你丢到任务队列里等着。”
  3. 继续向下执行,console.log(a),此时 a 还是 1,所以控制台打印出 1
  4. 所有同步代码执行完毕。
  5. 大约 1 秒后,setTimeout 的回调函数被从任务队列中取出并执行,a 被修改为 2,控制台打印出 2

Part 3:事件循环的核心 —— 宏任务与微任务

现在,我们来深入事件循环的核心。任务队列里的任务其实还分为两种:

  • 宏任务(MacroTask) :可以理解为比较“大”的任务。包括:setTimeoutsetIntervalsetImmediate (Node.js)、I/O 操作、UI rendering 等。
  • 微任务(MicroTask) :可以理解为比较“小”且需要尽快执行的任务。包括:Promise.then()catch()finally()MutationObserverprocess.nextTick (Node.js) 等。

事件循环的执行顺序非常严格,请一定记住这个规则

  1. 执行一个宏任务(通常是 script 脚本本身)。
  2. 执行过程中,遇到宏任务就把它放到宏任务队列,遇到微任务就把它放到微任务队列
  3. 当前宏任务执行完毕后,立即检查微任务队列,并执行里面所有的微任务。
  4. 所有微任务执行完毕后,如有需要,进行页面渲染。
  5. 然后,从宏任务队列中取出一个任务,开始新一轮的循环。

让我们用一个经典的面试题来巩固一下:

 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

执行分析

  1. 第一轮宏任务 (script) 开始执行。
  2. console.log(1),输出 1
  3. new Promise,构造函数立即执行,console.log(2),输出 2.then 的回调被放入微任务队列
  4. 遇到第一个 setTimeout,其回调被放入宏任务队列
  5. console.log(7),输出 7
  6. 同步代码执行完毕。检查微任务队列,发现有一个 .then 的回调。
  7. 执行微任务:console.log(3),输出 3。在其中又遇到一个 setTimeout,其回调被放入宏任务队列
  8. 微任务队列清空
  9. 第一轮事件循环结束
  10. 第二轮宏任务 开始,从宏任务队列中取出第一个任务(打印 5 的那个)。
  11. console.log(5),输出 5。又遇到一个 setTimeout,其回调被放入宏任务队列
  12. 第三轮宏任务 开始,取出打印 4 的任务,console.log(4),输出 4
  13. 第四轮宏任务 开始,取出打印 6 的任务,console.log(6),输出 6

Part 4:现代异步方案 —— async/await

async/awaitPromise 的语法糖,让异步代码写起来更像同步代码。但它的本质没有变,仍然是基于事件循环。

  • 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

执行分析

  1. 同步代码script start -> async1() 调用 -> async2() 调用 -> async2 end -> promise -> script end
  2. 微任务await 后面的 async1 end,以及两个 .then 回调。按顺序执行:async1 end -> then1 -> then2
  3. 宏任务:最后执行 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");

思考一下,这段代码的输出顺序是什么?

解析:

  1. console.log("script start"):同步代码,立即执行,输出 script start
  2. setTimeout:宏任务,进入宏任务队列。
  3. Promise.resolve().then().then():第一个 .then() 是微任务,进入微任务队列。当第一个 .then() 执行完毕后,其返回的Promise会立即将第二个 .then() 作为微任务放入微任务队列。
  4. console.log("script end"):同步代码,立即执行,输出 script end

当前宏任务(同步代码)执行完毕。

  1. 检查微任务队列,执行第一个微任务 promise1,输出 promise1。此时,第二个 .then() 立即作为微任务进入微任务队列。
  2. 微任务队列中还有任务,继续执行第二个微任务 promise2,输出 promise2

微任务队列清空。

  1. 检查宏任务队列,执行 setTimeout,输出 setTimeout

最终输出:

 script start
 script end
 promise1
 promise2
 setTimeout

这个例子再次强调了微任务在当前宏任务执行完毕后,会优先于下一个宏任务执行,并且微任务队列会在每个宏任务执行完毕后被完全清空。

示例二:async/awaitsetTimeout 的结合

 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");

思考一下,这段代码的输出顺序是什么?

解析:

  1. console.log("script start"):同步代码,输出 script start

  2. setTimeout:宏任务,进入宏任务队列。

  3. foo() 调用:

    • console.log("foo start"):同步代码,输出 foo start
    • await bar()bar() 函数执行,输出 barawait 暂停 foo 函数,并将 console.log("foo end") 作为微任务放入微任务队列。
  4. new Promise(...):Promise 构造函数中的代码是同步执行的,输出 promise constructorresolve() 被调用,将 .then() 回调作为微任务放入微任务队列。

  5. console.log("script end"):同步代码,输出 script end

当前宏任务(同步代码)执行完毕。

  1. 检查微任务队列,执行 foo 函数中 await 后面的微任务 console.log("foo end"),输出 foo end
  2. 微任务队列中还有任务,执行 promise then,输出 promise then

微任务队列清空。

  1. 检查宏任务队列,执行 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阻塞的关键在于合理利用异步编程。将耗时操作(如网络请求、大量计算)放入异步任务中,例如使用 Promiseasync/awaitsetTimeout。这样可以确保主线程始终保持响应,提升用户体验。

总结

恭喜你!坚持看到了这里。现在,让我们来回顾一下今天学到的核心知识:

  • JS 是单线程的,但通过事件循环机制实现了异步。
  • 任务分为宏任务微任务
  • 执行顺序是:一个宏任务 -> 所有微任务 -> (渲染) -> 下一个宏任务
  • Promise.thenawait 后面的代码属于微任务,会优先于 setTimeout宏任务执行。

理解事件循环是每一位前端开发者的必备内功。希望这篇博客能为你打下坚实的基础。如果你觉得有帮助,不妨分享给更多正在学习的小伙伴吧!