写在前面
对不起😩,标题党了😫,原谅我😭!本文其实是作者自个学习 JS的事件循环(Event Loop)机制 时做的一些总结,总体上是讲述一下作者自个的学习思路,如果我的朋友您不太了解或者没有系统的了解过事件循环机制的前因后果,那么相信通过这篇文章就能搞个一知半解,你一定可以的!!!
前置知识
学一个东西总要了解一些它的环境、背景吧!那么我们在正式看 事件循环机制 是个什么东西之前,我们需要了解一下 进程与线程、以及异步机制这两个东西的概念!
🐛进程与线程
进程与线程为什么会和事件循环机制相关呢?先说结论:当浏览器增加一个标签页时,就会增加一个渲染进程,同时在该进程里面启动一个渲染主线程,而JS的事件循环就是在这个线程里面实现的!
本人觉得带着疑惑 / 结论看下去效果会更加好!你一定可以的!
进程
操作系统资源分配的最小单位。可以简单的理解为浏览器程序运行的一片内存空间(拥有独立的内存空间、文件句柄、CPU时间片等资源)
线程
操作系统调度的最小单位。简单理解就是在进程里面可以运行程序,而线程就是执行该进程任务的“人”
类比进程为一家公司(每个公司(进程)有独立的资金、办公室(内存资源),公司间合作需签订合同(IPC))。 类比线程为公司内的部门(各部门(线程)共享公司资源,协作完成项目(任务),但需开会协调(同步机制))。
浏览器的主要进程包括浏览器进程、GPU进程、渲染进程、网络进程,默认情况下当你打开一个新的标签页时,浏览器就会为这个标签页开启一个新的渲染进程,以此保证不同标签页之间的渲染互不影响,而渲染进程启动后,会在这个进程内部开启一个渲染主线程,渲染主线程对于前端来说是一个极为重要的线程,因为这个线程负责HTML、CSS的解析以及JS脚本的执行。
我们知道JavaScript这门语言是单线程的,其实说的就是浏览器渲染进程的主线程!
Finally!我们回头去看这个结论:当浏览器增加一个标签页时打开了一个新的网站,就会增加一个渲染进程,同时在该进程里面启动一个渲染主线程就是执行JS代码的那个单线程,而JS的事件循环就是在这个线程里面实现的!
看到这里想必你已经看完了进程、线程与JS事件循环机制的关系,其实就是JS的事件(即代码)就是在这个线程执行的。那么我觉得看完有个印象就好,不用再去纠结和细挖了,接着看下去!!!
🐞异步机制的由来
JavaScript 最初设计为浏览器脚本语言,主要操作DOM、处理用户交互。如果允许多线程同时操作同一个DOM,会引发严重的资源竞争问题(一个线程删除节点,另一个线程修改节点)。所以设计者选择了单线程模型。
单线程带来的问题
在单线程中,所有任务按照顺序同步执行,那么遇到下面这些情况就会卡死:
- 网络请求:请求一个接口,等待服务器响应
- 定时器:setTimeout、setInterval
- 文件读取:即I/O操作
可以把JS的单线程工作理解为一个人单独去维护整个网页,遇到需要等待的任务就会停止工作进行等待,那么网页自然而然就会卡死 -- 不能响应用户点击、不能滚动、不能执行任何代码!
为了让单线程能同时(看起来)处理多件事,浏览器环境提供了异步机制:
- 耗时的操作(网络请求、定时器、I/O)交给浏览器底层去处理(这些底层模块是多线程的)
- JavaScript 主线程只管发起调用,然后继续执行后面的代码
- 等浏览器底层处理完了,把回调函数放回任务队列,主线程空闲时再来执行
异步机制让一些任务作为异步任务被处理,日常面试或者开发只需要区分同/异步分别有哪些即可!
网络请求由网络进程进行处理、定时器由浏览器的定时器模块进行处理、I/O操作根据不同的类型由浏览器底层的不同模块进行处理!
同步任务
立即执行、按代码顺序依次执行的任务,一个执行完才执行下一个。同步任务会阻塞后续代码。如果当前同步任务耗时(如写一个while(true)死循环),整个页面会卡住。
异步任务
不立即执行,先挂起,等待某个条件满足(如定时器到点、网络请求返回)后再执行的任务。不阻塞主线程,主线程空闲时才会从任务队列取出执行。
同时,任务队列又将任务分为宏任务和微任务,微任务的优先级会更加高!
- 宏任务:
setTimeout、setInterval、I/O、UI渲染、事件回调。每次事件循环取一个宏任务执行。 - 微任务:
Promise.then、MutationObserver、queueMicrotask。在当前宏任务执行完后、下一个宏任务之前,一次性清空所有微任务。
总结一下,JS是单线程的,为避免同步执行耗时任务时卡死页面,浏览器提供了异步机制:
- JS主线程只发起调用,将耗时操作交给底层模块处理,完成后回调进入任务队列,待主线程空闲时再执行。
- 同步任务立即执行且阻塞后续代码;异步任务不阻塞,且分为宏任务(如setTimeout)和微任务(如Promise.then),每个宏任务执行后会清空所有微任务。
事件循环的过程
事件循环(Event Loop)是浏览器渲染主线程的工作机制。它本质上是一个永远不会结束的 for 循环,主线程通过这个循环不停地从各种任务队列中取出任务来执行。
在Chrome的源码中,这个循环大致的逻辑就是:
while (true) {
从某个队列中取出一个任务
执行这个任务
}
其他线程(网络线程、定时器线程等)在合适的时候,只需要把任务追加到相应的队列末尾即可。
同时,虽然我们前面讲到浏览器的任务队列被分为宏任务队列和微任务队列,但其实在现在这周说法已经不准确了,现在的常见队列远不止宏任务队列和微任务队列。
根据 W3C 官方规范:
- 每个任务都有不同的类型
- 相同类型的任务必须放在同一个队列中
- 不同类型的任务可以放在不同的队列中
- 不同的队列有不同的优先级
- 浏览器必须维护一个微队列,它的优先级最高
常见的队列优先级(从高到低):
- 微队列:优先级最高,必须优先调度
- 交互队列:处理用户交互相关的任务
- 延时队列:处理定时器回调等任务
但是个人觉得只需要着重知道微任务队列和宏任务队列就行,其它的队列只是在浏览器层面的调度优化,对事件循环机制影响不大!
🐜任务从哪里来?
任务不是凭空产生的,而是从页面加载到事件循环运转的之后由其他线程在合适的时机放入队列的。
第一步:页面加载,执行全局脚本(初始任务)
当浏览器加载一个 <script> 标签或执行入口文件时:
- 主线程将整个脚本代码块作为一个初始任务,直接压入调用栈执行
- 这个初始任务是同步执行的,不在队列里,所以是立即执行
第二步:初始脚本执行过程中,往队列里放任务
在初始脚本执行时,遇到异步API调用,会向各个队列注册后续任务:
| 代码示例 | 执行结果 |
|---|---|
setTimeout(cb, 1000) | 定时器线程开始计时,1秒后将 cb 放入延时队列 |
button.addEventListener('click', cb) | 交互模块监听,用户点击时将 cb 放入交互队列 |
fetch(url).then(cb) | 网络请求完成后,将 cb 放入微队列 |
Promise.resolve().then(cb) | 立即将 cb 放入微队列 |
第三步:初始脚本执行完毕,事件循环启动
初始脚本的同步代码全部执行完后,主线程开始进入事件循环
while (true) {
1. 选择优先级最高的非空队列
2. 取出第一个任务执行
3. 执行过程中可能产生新任务
}
🕷️一次完整的事件循环流程
第一步:选择队列
浏览器根据队列优先级,从多个任务队列中选择一个队列。由于微队列优先级最高,只要微队列非空,就一定会先选它。
第二步:取出任务
从选中的队列中取出第一个任务(该任务出队)。
第三步:执行任务
主线程立即执行这个任务。任务执行期间,主线程被占用,不能做其他事情。
第四步:重复循环
任务执行完毕后,主线程立即回到第一步,重新选择队列、取任务、执行。这个过程永不停止。
那么从打开网页浏览器请求脚本到事件循环的完成流程就是:
🦋渲染与事件循环
浏览器会在每一轮事件循环中检查是否需要渲染(通常按屏幕刷新率,如 60Hz,通俗点讲就是页面一秒会画60次,平均16.7ms画一次,也就是渲染操作执行一次),但渲染操作本身可能被跳过(若无视觉变化,页面没有变化,肯定就不需要重复画)。渲染流程(布局、绘制、合成)也发生在主线程,可能被长任务阻塞。
浏览器的事件循环(Event Loop)并非简单地“执行任务 → 渲染”,而是按特定时间阶段顺序处理任务,渲染只是其中的一个可选环节。
那么,加上了渲染环节的事件循环如下:
-
执行一个(初始)宏任务(如
setTimeout回调、<script>脚本)。 -
清空当前的微任务队列(所有的
Promise.then、MutationObserver等) -
判断是否需要渲染(通常按照屏幕刷新率,如16.7ms一次)
-
若需要渲染
a、先执行
requestAnimationFrame回调(更新动画状态)。b、执行Layout(布局) 和Paint(绘制) 。(前提是DOM或样式有变化)
c、Composite(合成) 图层并交给GPU显示。
-
若不需要渲染:跳过此步骤,直接进入下一轮循环
- 下一轮循环,继续处理其他宏任务
渲染需同时满足两个条件后才会发生。一是屏幕刷新率(比如16.7ms)时刻到达、二是页面产生了变化。如果一个异步任务结束之后使页面发生变化,但是屏幕距离上一次刷新的间隔小于刷新率间隔(也就是无刷新信号),那么页面并不会重新渲染;反之,下一次屏幕刷新时刻到达时如果页面并没有发生变化时也不会重新渲染。 一次渲染的页面就是一帧,我们常说的掉帧就是因为主线程被长任务阻塞,导致某一帧的渲染未能在 16.7ms 内完成,浏览器会直接丢弃该帧,导致 “掉帧” 。
🐌检查成果
如果你觉得自己能够了解上面这个过程了,不妨看看下面两个代码题
console.log("1"); // 操作一 - 同步代码,立即执行
setTimeout(() => console.log("2"), 0); // 操作二 - 异步宏任务,交给定时器线程处理,0ms后将回调加入宏任务队列
Promise.resolve().then(() => console.log("3")); // 操作三 - 异步微任务,将回调加入微任务队列
console.log("4"); // 操作四 - 同步代码,立即执行
// 答案为:打印 1 -> 4 -> 3 -> 2
// 执行顺序分析:
// 1、程序执行之后,整个脚本作为一个初始宏任务加入主线程,事件循环开始
// 2、发现操作一为同步代码,立即执行,打印1
// 3、发现操作二为异步宏任务,交给浏览器的定时器线程处理,定时器线程在 0ms 后(实际至少 4ms,但逻辑上视为“尽快”)将回调函数放入宏任务队列(注意:此时回调尚未执行,只是加入队列)
// 4、发现操作三为异步微任务,将回调 () => console.log("3") 加入微任务队列
// 5、操作四为同步代码,直接执行,打印4
// 6、主线程当前宏任务结束,开始检查并清空微任务队列,从微任务队列中取出回调 () => console.log("3") 并执行,输出3,微队列已清空
// 7、进入下一次事件循环,将第一个宏任务加入主线程,执行打印2
// 执行流程可视化:
// [主线程]
// 1. 同步执行:打印"1"
// 2. 将setTimeout回调加入宏任务队列
// 3. 将3回调加入微任务队列
// 4. 同步执行:打印"4"
//
// [微任务队列]
// 1. 执行3回调:打印"3"
//
// [宏任务队列]
// 1. 执行setTimeout回调:打印"2"
稍微复杂一点的
setTimeout(() => console.log("A"), 0); // 操作一 - 异步宏任务,交给定时器线程处理,0ms后将回调加入宏任务队列
Promise.resolve()
.then(() => { // 操作二 - 异步微任务,第一个then回调加入微任务队列
console.log("B");
return Promise.resolve().then(() => console.log("C")); // 操作三 - 嵌套微任务
})
.then(() => console.log("D")); // 操作四 - 链式then回调,需等待前一个then完成
Promise.resolve().then(() => console.log("E")); // 操作五 - 异步微任务,加入微任务队列
// 执行顺序分析:
// 1. 整个脚本作为初始宏任务执行
// 2. 操作一:将setTimeout回调加入宏任务队列(此时未执行)
// 3. 操作二:将第一个then回调加入微任务队列 [B回调]
// 4. 操作五:将then回调加入微任务队列 [B回调, E回调]
// 5. 当前宏任务结束,开始清空微任务队列:
// a. 执行B回调:
// - 打印"B"
// - 操作三:将嵌套的C回调加入微任务队列 [E回调, C回调]
// - 由于返回了Promise,操作四的D回调需要等待
// b. 执行E回调:打印"E"
// c. 执行C回调:打印"C"
// - 此时前一个Promise链完成,将D回调加入微任务队列 [D回调]
// d. 执行D回调:打印"D"
// 6. 微任务队列清空,进入下一轮事件循环
// 7. 执行宏任务队列中的A回调:打印"A"
// 最终输出顺序:B → E → C → D → A
面试题
🐢如何理解JS的异步机制?
JS本身是单线程的,这个单线程指的是只有一个主线程在执行代码。如果所有事情都按顺序同步做,那遇到网络请求、定时器这种需要等待的操作,整个页面就会卡住,因为主线程一直在等。
为了解决这个问题,浏览器提供了异步能力。当我在代码里发起一个异步操作,比如setTimeout或者fetch,JS引擎不会自己处理,而是把这个任务交给浏览器底层模块去干,比如定时器模块或网络模块。主线程自己则继续往下执行后面的代码,不会被阻塞。
等浏览器底层把活干完了,它会把回调函数放进一个任务队列里。主线程通过一个叫事件循环的机制,不停地看着这个队列,一旦主线程空闲了,就从队列里取出任务来执行。这就是JS的异步机制——利用宿主环境的多线程能力,让单线程的JS也能非阻塞地处理耗时操作。
🐱阐述一下JS的事件循环
事件循环是浏览器主线程的工作方式,本质上就是一个永不结束的循环。你可以理解为主线程一直在做三件事:取任务、执行任务、再取下一个任务。
现代浏览器里,任务队列不只是一个,而是有多个,不同队列有不同优先级。比如微队列优先级最高,专门放Promise.then这类回调;交互队列放用户点击这类事件;延时队列放setTimeout回调。事件循环每次会优先从优先级最高的队列里取任务执行。
整个过程是这样启动的:页面加载时会先执行全局的同步代码,执行过程中可能会往各个队列里放任务。全局代码执行完后,主线程就进入了事件循环,不停地从队列里取任务执行。执行一个任务的过程中,可能又会往队列里添加新任务,比如在Promise.then回调里再调用setTimeout,这样事件循环就会继续处理下去。这个循环永远不会结束,直到页面关闭。
推荐资源:渡一大师课(可以在小破站上面找,小编也是从大师课才对事件循环机制有个一知半解的~)
最后,本文仅仅是小编自己学习总结的分享,如有错误,恳请各路大佬指正!!!