引言:金三银四开始了,主播也打算进厂找个事做。于是这几天陆陆续续面了好几家公司,发现不管是大公司和小公司都喜欢问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 ):存放
setTimeout
、setInterval
、DOM事件
、I/O
操作等回调。 - 微任务队列 (Microtask Queue ):
存放Promise.then()
、MutationObserver
、queueMicrotask()
等回调。
3. 事件循环调度器
- 持续检查调用栈和任务队列,按优先级调度任务
执行流程
- 执行同步代码:所有同步任务直接进入调用栈执行,直到栈空。
- 清空微任务队列:检查微任务队列,依次执行所有微任务(包括执行过程中新产生的微任务)。
- 执行一个宏任务:从宏任务队列中取出首个任务执行。
- 重复循环:每执行完一个宏任务后,再次清空微任务队列,依次循环。
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 start
,async2 end
,promise
,script end
,async1 end
,then1
,then2
,setTimeout
。下面是分析过程:
首先我们执行同步代码打印script start
,然后遇到了用async
声明的一个函数,这相当于这个函数里面写了return new Promise
,注意这不是异步,仍然是同步代码。这只是个函数体的声明并不执行,后面又遇到一个async
,依旧是个函数体的声明,不执行。随后我们碰见了async1
这是个函数调用,我们开始执行async1
函数,因为我们前面提到await
后面的代码要当成同步代码来执行,所以我们立即执行async2
,所以输出了async2 end
,又因为await的下几行代码要进入微任务队列,所以async1 end
先不打印,随后我们遇到了一个定时器这是个宏任务,我们先不执行,并把它加入到宏任务队列。后面我们遇到了一个new Promise
这是个同步代码,我们输出promise
,随后我们遇到了两个.then
我们把它们加入到微任务队列。最后输出script end
,所有同步代码都执行完毕之后,我们去执行异步代码,我们先去清空微任务队列,依次输出async1 end
,then1
,then2
,然后清空宏任务队列输出setTimeout
。
五、setTimeout定时器执行的时间准吗?
setTimeout被执行时,浏览器会启动一个新的线程来计时,等到时间结束才将定时器的回调取出来执行(js 主线程将其取出),如果此时JS主线程还在执行同步代码,那么该回调就会一直挂起,直到同步代码执行完毕,微任务也执行完毕才执行该回调(就算原本宏任务队列里面有代码也是先执行该定时器的回调)
六、总结
- 核心规则:同步代码 → 微任务 → 宏任务 → 渲染 → 下一轮循环。
- 应用场景 :
- 使用
Promise
处理即时异步操作(如数据请求)。 - 使用
setTimeout
调度延迟任务(如动画分帧)。
- 使用
- 优化建议:避免在微任务中执行耗时操作,合理拆分任务以平衡性能与响应速度。