带你梳理JS的事件循环机制
1. 为什么需要事件循环?
事件循环是为了解决JavaScript单线程的语言特性与异步任务需求之间的矛盾而出现的。
首先,我们要理解JavaScript是一门单线程的语言。
1.1 JS为什么选择单线程?
所谓单线程,简单来说一个时间只能做一件事,只有做完这件事,才能进行下一件。那为什么选择单线程,不选择多线程呢?这是由JS的用途决定的,JS的用途是与用户交互,以及操作DOM,假设JS有两个线程,一个要在某个DOM节点上添加内容,一个要删除这个节点,那浏览器该以哪个为准呢,事情就变得复杂了。因此,JS在诞生时就是单线程,以后也不会改变,这是这门语言的核心特征。
1.2 异步任务需求
但是,单线程有个缺点,如果一件任务耗时太长,例如I/O请求,就会阻塞后面的任务。这样肯定是不行的,所以,JS设计者将任务分成同步任务和异步任务。
简单来说,同步任务就是在主线程(调用栈 Call Stack)中按照书写顺序依次执行的任务。异步任务就是不阻塞主线程,而是交由其他线程或系统处理(如浏览器 Web API 或 Node.js 的 C++ 模块),处理完毕后,将其回调函数推入任务队列(Task Queues)。等到主线程空了,任务队列中的任务再根据FIFO算法被调度到主线程中执行。
1.3 事件循环原理
那么,事件循环在其中起到什么作用呢?它又是怎么工作的呢?
事件循环的工作流程如下:
1.执行调用栈中的所有同步代码,直到栈空。
2.检查任务队列,依次将任务调度到调用栈中执行,直到队列空。
3.重复循环。
只要调用栈空了,就去任务队列中读取任务,这个过程不断重复循环,这就是JavaScript的运行机制, 这种机制就叫做事件循环机制。
注意,事件循环是以事件为驱动,它并不是按照固定的时间间隔运行的,而是根据任务队列中的任务来触发。当调用栈为空时,事件循环会检查任务队列,如果有任务,就取出执行。所以,事件循环的触发时机取决于是否有任务需要处理,而不是定时器那样的固定周期。
1.4 执行顺序题目1
理解了上面的概念之后,我们来看一道输出题:
console.log("1"); // 同步任务 setTimeout(() => console.log("2"), 1000); // 异步任务 console.log("3"); // 同步任务
答案:正确输出是1→3→2,而不是1→2→3,为什么呢?
分析:首先要知道setTimeout这个函数,它不会立刻返回结果,而是发起了一个异步,它用于在指定的毫秒数后调用函数或计算表达式。这段代码中它接受了两个参数,第一个是回调函数,第二个数是1000。也就是说,它将在1秒后执行() => console.log("2")这个箭头函数,结果是输出2。
前面说到事件循环机制,它先执行调用栈中的所有同步代码,直到栈空,然后再到任务队列中读取任务执行,这个过程不断重复。
因此,console.log("1")这个同步任务首先被加入调用栈中执行,输出1,执行完毕后弹出调用栈。
接着,setTimeout(() => console.log("2"), 0)被加入调用栈,但它是一个异步任务,因此被转交到其他线程处理,等待处理完毕后(对于setTimeout函数,就是计时完毕),将回调函数调入任务队列。
同时,主线程则继续下一个同步任务console.log("3"),输出3,执行完毕后弹出调用栈。
此时,代码中没有同步任务了,调用栈已空,于是任务队列中的() => console.log("2")任务进入调用栈执行,输出2。
这个过程如果不明白的,可以借助Loupe(事件循环模拟器)工具理解,工具作者是Philip Roberts ,latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3Dhttp://latentflip.com/loupe/?code=JC5vbignYnV0dG9uJywgJ2NsaWNrJywgZnVuY3Rpb24gb25DbGljaygpIHsKICAgIHNldFRpbWVvdXQoZnVuY3Rpb24gdGltZXIoKSB7CiAgICAgICAgY29uc29sZS5sb2coJ1lvdSBjbGlja2VkIHRoZSBidXR0b24hJyk7ICAgIAogICAgfSwgMjAwMCk7Cn0pOwoKY29uc29sZS5sb2coIkhpISIpOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIkNsaWNrIHRoZSBidXR0b24hIik7Cn0sIDUwMDApOwoKY29uc29sZS5sb2coIldlbGNvbWUgdG8gbG91cGUuIik7!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D
强烈推荐这个作者的一个演讲视频,看完可以很好的理解事件循环机制。
【中文字幕】Philip Roberts:到底什么是Event Loop呢?(JSConf EU 2014) https://www.bilibili.com/video/BV1oV411k7XY/?share_source=copy_web&vd_source=2ea0872ee94da416e76fe584e8238538
这个作者的也很棒,讲得更深入一点。
【事件循环】【前端】事件原理讲解,超级硬核,忍不住转载】 https://www.bilibili.com/video/BV1K4411D7Jb/?share_source=copy_web&vd_source=2ea0872ee94da416e76fe584e8238538
1.5 思考题:setTimeout(callback,0)是什么意思?
如果仔细看了前面两个视频,setTimeout(callback, 0) 表示将 callback 函数延迟到当前同步代码执行完毕,主线程空闲后立即执行。它并非“立即执行”,而是将回调推入任务队列,等待事件循环调度。所以setTimeout(callback,s)的真正含义并不是在指定的毫秒数后调用函数,而是最快s毫秒后调用函数,因为它需要等待主线程空后再被调用。
2. 异步任务的细化
2.1 宏任务与微任务
事实上,异步任务还分为宏任务和微任务。
在事件循环的早期设计中,所有异步任务都进入同一个任务队列。但随着前端复杂度提升,任务优先级问题显露出来:
-
紧急任务需要优先处理(如 Promise 状态更新)
-
非紧急任务可以延后(如 UI 渲染前的计算)
因此,现代事件循环将异步任务细分为两类:
任务类型 典型 API 执行优先级 宏任务 主线程代码、setTimeout、setInterval、setImmediate、requestAnimation、I/O、script
低 微任务 Process.NextTick(Node.js环境)、Promise.then、MutationObserver、Object.observe 高 2.2 事件循环的完整流程
首先,明确几个概念:
宏任务队列:存放待执行的宏任务回调(如 setTimeout、DOM 事件 的回调)。
微任务队列:存放待执行的微任务回调(如 Promise.then、MutationObserver 的回调)。
调用栈:实际执行代码的地方,无论是同步代码还是异步回调(宏任务/微任务),都需推入调用栈运行。
同步任务与宏任务:整个脚本(主线程代码),即标签里的代码,本身就是一个宏任务,主线程执行脚本中的同步代码属于初始宏任务。整个脚本的执行是第一个宏任务,同步代码是它的组成部分。
于是,事件循环的工作流程就变成:
1.按照代码书写顺序执行初始宏任务:
-
如果遇到同步代码,直接推入调用栈执行。
-
如果遇到宏任务,将回调函数推入宏任务队列。
-
如果遇到微任务,将回调函数推入微任务队列。
2.清空微任务队列:
-
当前宏任务执行完毕后,依次将微任务队列中的所有微任务推入调用栈执行,直到微任务队列清空。
-
注意:若微任务中又生成新的微任务,新微任务也会在此阶段被立即执行。
3.渲染更新(如有必要):
- 浏览器判断是否需要渲染(通常根据屏幕刷新率,如 60Hz 对应约 16.6ms/次)。
4.开启下一轮事件循环:
- 从宏任务队列中取出下一个宏任务执行,重复上述流程。
2.3 执行顺序题目2
console.log("1"); // 同步任务(属于初始宏任务) setTimeout(() => console.log("2"), 0); // 宏任务 // 微任务 Promise.resolve().then(() => { console.log("3"); setTimeout(() => console.log("4"), 0); // 嵌套宏任务 Promise.resolve().then(() => console.log("5")); // 嵌套微任务 }); console.log("6"); // 同步任务(属于初始宏任务)
正确输出:1 → 6 → 3 → 5 → 2 → 4。
分析:
1.按照书写顺序执行初始宏任务:
(1)遇到同步代码console.log("1"),直接推入调用栈执行,输出1。
(2)遇到宏任务setTimeout(),将回调 () => console.log("2") 推入宏任务队列。
(3)遇到微任务Promise.resolve().then(),将回调() => {
console.log("3");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => console.log("5"));
}推入微任务队列。
(4)遇到同步代码console.log("6"),直接推入调用栈执行,输出6。
(5)检查调用栈为空,当前宏任务执行完毕,进行下一步。
2.清空微任务队列:
(1)取出第一个微任务console.log("3"),推入调用栈执行,输出3。
(2)遇到宏任务setTimeout(),将回调 () => console.log("2") 推入宏任务队列。
(3)遇到微任务Promise.resolve().then(),将回调 () => console.log("5") 推入 微任务队列。
(4)检查微任务队列未空,继续清空。取出微任务 () => console.log("5"),推入调用栈执行,输出 5。
(5)检查微任务队列为空,进行下一步。
3.下一轮事件循环
(1)从宏任务队列中取出第一个宏任务console.log("2"),推入调用栈执行,输出2。
(2)调用栈为空,进行下一步。
(3)微队列为空,进行下一步。
4.再下一轮事件循环
(1)从宏任务队列中取出第一个宏任务console.log("4"),推入调用栈执行,输出6。
(2)调用栈为空,进行下一步。
(3)微队列为空,进行下一步。
(4)宏队列为空,结束。
2.4 执行顺序题目3
console.log("1") //同步任务 setTimeout(() => { //第一个宏任务 console.log('2'); setTimeout(() => { //嵌套宏任务 console.log('3') }, 0); new Promise(resolve => { //同步任务 resolve() console.log('4') }).then(() => { //微任务 console.log('5') }) }, 0) setTimeout(() => { //第二个宏任务 console.log('6') }, 0)
正确输出:1 → 2 → 4 → 5 → 6 → 3。
分析:
1.按照书写顺序执行初始宏任务:
(1)遇到同步任务console.log("1"),直接推入调用栈执行,输出1。
(2)遇到第一个宏任务setTimeout(),将回调推入宏任务队列。
(3)遇到第二个宏任务setTimeout(),将回调推入宏任务队列。
(4)调用栈为空,进行下一步。
(5)微队列为空,进行下一步。
2.第二轮事件循环
(1)从宏任务队列中取出第一个宏任务的回调推入调用栈执行。
(2)遇到同步任务console.log("2"),输出2。
(3)遇到嵌套宏任务setTimeout(),将回调推入宏任务队列。
(4)遇到同步任务new Promise(),输出4。
(5)遇到微任务promise.then(),将回调推入微任务队列。
(6)调用栈为空,进行下一步清空微任务。
(7)取出微任务console.log("5"),推入调用栈执行,输出5。
(8)微任务队列为空,进行下一轮循环。
3.第三轮事件循环
(1)从宏任务队列中取出第二个宏任务console.log("6"),推入调用栈执行,输出6。
(2)调用栈为空,进行下一步。
(3)微队列为空,进行下一轮循环。
4.第四轮事件循环
(1)从宏任务队列中取出嵌套宏任务console.log("3"),推入调用栈执行,输出3。
(2)调用栈为空,进行下一步。
(3)微队列为空,进行下一步。
(4)宏队列为空,结束。
2.5 执行顺序题目4
console.log('1'); setTimeout(function() { console.log('2'); Promise.resolve().then(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5') }) }) Promise.resolve().then(function() { console.log('6'); }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8') }) setTimeout(function() { console.log('9'); Promise.resolve().then(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12') }) })
请思考这题。
参考:
JavaScript 运行机制详解:再谈Event Loop - 阮一峰的网络日志
一篇搞定(Js异步、事件循环与消息队列、微任务与宏任务) - 知乎
【事件循环】【前端】事件原理讲解,超级硬核,忍不住转载_哔哩哔哩_bilibili
【中文字幕】Philip Roberts:到底什么是Event Loop呢?(JSConf EU 2014)_哔哩哔哩_bilibili
- 从宏任务队列中取出下一个宏任务执行,重复上述流程。
- 浏览器判断是否需要渲染(通常根据屏幕刷新率,如 60Hz 对应约 16.6ms/次)。
-
-