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

执行分析:

  • 第一轮宏任务 (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 startasync1() 调用 → async2() 调用 → async2 endpromisescript end
  • 微任务await 后面的 async1 end,以及两个 .then 回调。按顺序执行:async1 endthen1then2
  • 宏任务:最后执行 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");

输出顺序:

script start
script end
promise1
promise2
setTimeout

解析:

  • 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

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


示例二: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");

输出顺序:

script start
foo start
bar
promise constructor
script end
foo end
promise then
setTimeout

解析:

  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
  6. 当前宏任务(同步代码)执行完毕。
  7. 检查微任务队列,执行 foo 函数中 await 后面的微任务 console.log("foo end"),输出 foo end
  8. 微任务队列中还有任务,执行 promise then,输出 promise then
  9. 微任务队列清空。
  10. 检查宏任务队列,执行 setTimeout,输出 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 等宏任务执行。

转载自:https://juejin.cn/post/7520429827620519988

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐