《面试必问:为什么Promise比setTimeout先执行?事件循环的魔鬼细节》

引言:金三银四开始了,主播也打算进厂找个事做。于是这几天陆陆续续面了好几家公司,发现不管是大公司和小公司都喜欢问JS中的事件循环机制,这玩意说难也不难,但是如果面试官问细点可能答不上来了,于是主播就想写篇文章和大家一起把事件循环机制聊明白了,下次遇到面试官问你事件循环机制,各位就能够自信得回答了。

一、 进程与线程

在聊事件循环机制之前,我们先来聊一下进程与线程,这样有助于我们更好地理解事件循环机制。

1.进程与线程的基本概念

1.进程

  • 定义:进程是操作系统分配资源(如内存、CPU)的基本单位,每个进程独立运行且拥有独立的内存空间
  • 前端场景 :浏览器中每个标签页通常对应一个独立的渲染进程,负责HTML、CSS、JavaScript的解析和页面渲染。
  • 特点:进程间严格隔离,一个崩溃不会影响其他进程(如Chrome崩溃的标签页不会导致整个浏览器关闭)

2.线程

  • 定义:线程是进程内的执行单元,共享进程的资源(如内存、文件句柄),是CPU调度的最小单位。
  • 前端场景 :渲染进程内包含多个协作线程,如JS引擎线程GUI渲染线程等,共同完成页面交互和渲染

通俗地说,进程就像是一个工厂,而线程就是工厂中不同的流水线。

2.前端中的单线程与异步机制

JavaScript采用单线程模型 ,但通过事件循环(Event Loop)​实现异步操作。V8引擎在允许js代码时,这个进程中只有一个线程会被开启。

二、什么是事件循环机制

在了解事件循环机制之前我们先来看一段代码,大家可以先思考下最后会输出啥

js 复制代码
let a = 1
console.log(a);
setTimeout(function() {
  let b = 2
  console.log(b);
  a++
  setTimeout(function() {
    b++
  }, 2000)
  console.log(b);
}, 1000)
console.log(a);

这段代码最后会输出1、1、2、2,因为我们先定义了一个变量a,然后执行到第2行开始打印a输出第一个1,然后我们在第3行碰到了一个定时器,这个定时器是异步的,根据V8的规则我们先去执行同步代码再去执行异步代码。所以我们先跳过它去执行最后一行又输出第二个1,最后一行执行完毕后我们开始执行定时器里的代码,里面定义了一个变量b值为2,随后打印第一个2,随后a++,a的值变为2,然后又遇到一个定时器,同样的,我们先去执行同步代码打印b,输出第二个2。最后2秒后执行定时器里的代码b的值变为3

其实,我们刚刚思考这段代码的过程就是JS的事件循环机制,浏览器的V8引擎在执行一份JS代码时,会循环往复地执行,先执行不耗时的代码,再执行耗时的代码,在耗时代码中也是先执行同步代码,再执行异步代码。直到所有代码执行完毕。

所以事件循环机制是V8按照先执行同步代码,再执行异步代码,如此反复执行的一种策略

我们再来看一段代码

js 复制代码
let a = 1
for (var i = 0; i < 10000; i++) {
  a++
}
console.log(a);

大家觉得这份代码耗时吗?如果是执行1亿次循环耗时吗?其实,这份代码是不耗时的,因为只要你的电脑性能足够好,这份代码的执行就不需要耗费时间,而类似上面定时器这种代码,不管你的电脑性能好不好,都需要耗费时间。

三、事件循环机制的执行步骤

1. 宏任务与微任务的定义

宏任务

  • 定义:由宿主环境(如浏览器)发起的异步任务,需要较长时间执行,每次事件循环处理一个宏任务。
  • 常见类型
    • setTimeout / setInterval
    • DOM 事件回调(点击、滚动等)
    • I/O 操作(文件读写、网络请求)
    • requestAnimationFrame(浏览器动画帧回调)
    • script标签
    • 页面渲染

微任务

  • 定义:由JavaScript引擎自身发起的轻量级异步任务,需在当前宏任务结束后立即执行,优先级高于宏任务。
  • 常见类型
    • Promise.then() / .catch() / .finally()
    • MutationObserver(监听 DOM 变化)
    • queueMicrotask() 显式添加的微任务

2. 执行顺序与事件循环流程

事件循环机制由以下三部分构成:

1. 调用栈(Call Stack)

  • 用于执行同步代码(如 console.log()、函数调用),遵循"后进先出"(LIFO)原则。
  • 同步代码会立即执行,直到栈空。

2. 任务队列(Task Queues)

  • 宏任务队列Macrotask Queue ):存放 setTimeoutsetIntervalDOM事件I/O操作等回调。
  • 微任务队列Microtask Queue ):存放Promise.then()MutationObserverqueueMicrotask()等回调。

3. 事件循环调度器

  • 持续检查调用栈和任务队列,按优先级调度任务

执行流程

  1. 执行同步代码:所有同步任务直接进入调用栈执行,直到栈空。
  2. 清空微任务队列:检查微任务队列,依次执行所有微任务(包括执行过程中新产生的微任务)。
  3. 执行一个宏任务:从宏任务队列中取出首个任务执行。
  4. 重复循环:每执行完一个宏任务后,再次清空微任务队列,依次循环。

3. 特殊的await

我们不能把await单纯的看成.then来对待,await封装了.then和其他东西,它不完全等同于.then,我们在遇到await时要特别注意以下几点。

  • 浏览器对await的执行提前了 (await后面的代码当成同步代码来执行)
  • await会将后续代码(下一行/下N行代码)挤入微任务队列

四、 代码示例与分步解析

我们来看一个例子,大家可以思考下这段代码的输出结果。

js 复制代码
console.log(1);
new Promise(function(resolve, reject) {
  console.log(2);
  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,然后我们碰到了一个new Promise的函数调用,注意,除了我们刚刚提到的常见的宏任务的和微任务,其他代码绝大部分都是同步代码,这里的new Promise也是。所以里面执行new Promise里面的代码,输出2,然后Promise的状态变为成功的状态,然后就碰到了.then我们先不执行它因为它是微任务,并且V8引擎会开始创建一个微任务队列,.then入队。要注意这个.then里面的setTimeout是先不管的,因为.then都不调用。然后我们又遇到了一个setTimeout,它属于一个宏任务,v8引擎开始创建一个宏任务队列,并且这个setTimeout入队。随后我们执行同步代码输出7。此时所有的同步代码都执行完毕,就开始去检查有没有异步代码要执行 ,检查的顺序是先检查微任务队列,再检查宏任务队列 。于是就把之前进入微任务队列里的.then拿出来执行了,.then的执行上下文此时才进入调用栈中,.then开始执行便先输出了3,输出3之后又碰到一个宏任务setTimeout,继续把这个定时器放到宏任务队列,此时宏任务队列有两个setTimeout。 此时微任务队列已经清空,开始执行宏任务队列,首先执行第一个入队的setTimeout,执行它的过程中先输出5,又遇到一个setTimeout,将它加入宏任务队列,接着执行第二个setTimeout,输出4,然后执行最后一个setTimeout,输出6

大家如果还是不明白可以看我画的图再理解一下(图中的数字代表输出顺序,而不是输出结果)。

我简单的总结下流程:就是先执行同步代码,中途如果遇到异步代码先不执行把它们挂到队列中(宏任务挂宏任务队列,微任务挂微任务队列),等到所有同步代码都执行完毕后去执行异步代码,先执行微任务队列中的代码,再执行宏任务队列中的代码。

相比各位对事件循环机制比较清晰了,我们再来看一段代码,大家思考下这段代码的输出结果是什么。

js 复制代码
console.log('script start');
async function async1() {
  await async2()
  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 startasync2 endpromisescript endasync1 endthen1then2setTimeout。下面是分析过程:

首先我们执行同步代码打印script start,然后遇到了用async声明的一个函数,这相当于这个函数里面写了return new Promise,注意这不是异步,仍然是同步代码。这只是个函数体的声明并不执行,后面又遇到一个async,依旧是个函数体的声明,不执行。随后我们碰见了async1这是个函数调用,我们开始执行async1函数,因为我们前面提到await后面的代码要当成同步代码来执行,所以我们立即执行async2,所以输出了async2 end,又因为await的下几行代码要进入微任务队列,所以async1 end先不打印,随后我们遇到了一个定时器这是个宏任务,我们先不执行,并把它加入到宏任务队列。后面我们遇到了一个new Promise这是个同步代码,我们输出promise,随后我们遇到了两个.then我们把它们加入到微任务队列。最后输出script end,所有同步代码都执行完毕之后,我们去执行异步代码,我们先去清空微任务队列,依次输出async1 endthen1then2,然后清空宏任务队列输出setTimeout

五、setTimeout定时器执行的时间准吗?

setTimeout被执行时,浏览器会启动一个新的线程来计时,等到时间结束才将定时器的回调取出来执行(js 主线程将其取出),如果此时JS主线程还在执行同步代码,那么该回调就会一直挂起,直到同步代码执行完毕,微任务也执行完毕才执行该回调(就算原本宏任务队列里面有代码也是先执行该定时器的回调)

六、总结

  • 核心规则:同步代码 → 微任务 → 宏任务 → 渲染 → 下一轮循环。
  • 应用场景
    • 使用 Promise 处理即时异步操作(如数据请求)。
    • 使用 setTimeout 调度延迟任务(如动画分帧)。
  • 优化建议:避免在微任务中执行耗时操作,合理拆分任务以平衡性能与响应速度。
相关推荐
喝拿铁写前端5 小时前
前端与 AI 结合的 10 个可能路径图谱
前端·人工智能
codingandsleeping5 小时前
浏览器的缓存机制
前端·后端
self-discipline6346 小时前
【Java】Java核心知识点与相应面试技巧(七)——类与对象(二)
java·开发语言·面试
灵感__idea7 小时前
JavaScript高级程序设计(第5版):扎实的基本功是唯一捷径
前端·javascript·程序员
摇滚侠7 小时前
Vue3 其它API toRow和markRow
前端·javascript
難釋懷7 小时前
JavaScript基础-history 对象
开发语言·前端·javascript
beibeibeiooo7 小时前
【CSS3】04-标准流 + 浮动 + flex布局
前端·html·css3
拉不动的猪7 小时前
刷刷题47(react常规面试题2)
前端·javascript·面试
浪遏7 小时前
场景题:大文件上传 ?| 过总字节一面😱
前端·javascript·面试
Bigger7 小时前
Tauri(十八)——如何开发 Tauri 插件
前端·rust·app