本文是作者自己在学习JS在浏览器事件循环时做的总结,希望可以给一些同样在事件循环这一块学习有疑问的朋友们一些帮助!!!!
在学习事件循环之前,我们需要先了解浏览器的进程模型,假使你是一名前端开发者,你可以这样理解进程和线程
-
进程:简单的理解为浏览器程序运行的一片内存空间(拥有独立的内存空间、文件句柄、CPU时间片等资源)
-
类比进程为一家公司(每个公司(进程)有独立的资金、办公室(内存资源),公司间合作需签订合同(IPC))
-
线程:在进程里面可以运行程序,而线程就是执行该进程任务的“人”
-
类比线程为公司内的部门(各部门(线程)共享公司资源,协作完成项目(任务),但需开会协调(同步机制))
一个进程开启后就会创建一个主线程,当更多的程序要执行时就会创建更多的其他线程,浏览器是一个多进程多线程的应用程序
在浏览器设置的任务管理器里面可以查看有哪些进程。
浏览器的主要进程包括浏览器进程、GPU进程、渲染进程,默认境况下浏览器会为每一个标签页开启一个新的渲染进程,以保证不同标签页之间的互不影响,而渲染进程启动后,会开启一个渲染主线程,主线程负责执行HTML、CSS、JS代码
如果看到这里觉得上面这些内容有点难以理解,那么你只需要简单的知道,在浏览器增加一个标签页时,就会增加一个渲染进程,同时在该进程里面启动一个渲染主线程,而JS的事件循环就是在这个线程里面实现的!
知道进程和线程的概念之后,我们就可以开始学习事件循环了!
1、如何理解JS的异步?(面试题)
(以下提到的消息队列实际上就是任务队列的早期说法,包括宏任务队列和微任务队列)
首先,JS是一门单线程语言,这是因为我们前面说到的,JS代码运行在浏览器的渲染进程的主线程中,而渲染主线程只有一个。
其次,渲染主线程承担着诸多的工作,包括渲染页面、执行JS等等,如果我们现在使用同步的方式执行JS,就有可能导致主线程产生阻塞(比如执行JS代码时出现一个计时器,那么我们就需要等...),从而导致消息队列的其他任务无法得到执行,这样一来,一方面会导致繁忙的渲染主线程在等待的过程中白白的浪费时间,另一方面也会导致页面无法及时的更新,给用户造成卡死的假象。
所以浏览器采用异步的方式来避免这种情况。具体做法是当某些异步任务发生时,比如计时器、网络、事件监听等,渲染主线程会将任务交给其他线程去处理,比如计时器开始之后,渲染主线程就会将计时器任务交给计时线程去处理,自身立即结束任务的执行,转而执行后续的代码。当其他的线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。
在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行。
渲染进程只有一个主线程,但是存在其他线程,如计时器线程。
但是,JS的异步也有局限性-->长任务会阻塞主线程
即使采用异步,若单个任务(如复杂计算)执行时间过长(超过 16.7ms -->由屏幕刷新率决定),仍会导致帧丢失("掉帧"Jank
)。此时需通过 任务拆分(如 requestIdleCallback
)或 Web Workers 解决(了解就行)。
2、关于浏览器渲染主线程工作机制的补充细节
Ⅰ、首先,浏览器的事件循环并不是仅有一个任务(消息)队列,而是存在多个任务队列,按以下优先级排序:
微任务队列:Promise.then
、MutationObserver
、async/await
等任务的优先级最高,会在当前的宏任务执行完毕之后立即清空。
宏任务队列:setTimeout
、DOM事件、I/O等任务按事件循坏轮次执行。
动画序列:requestAnimationFrame
回调在渲染前执行,与浏览器的帧率同步
脚本执行时整个脚本会作为初始宏任务加入渲染主线程。
由于微队列优先级大于宏队列,在一次事件循环的宏任务结束后,会优先清空微队列,然后再从宏队列取出下一个宏任务放入渲染主线程。
以下是一个简单的例子:
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
Ⅱ、其次是并非所有的异步任务都交给其他线程去处理(异步任务的处理边界):
比如,事件的监听回调(如click事件、setTimeout
)虽然触发是异步的,但回调的执行仍在渲染主线程。浏览器的其他线程(如输入线程)仅负责监听事件,最终回调仍需主线程处理。
这里一个setTimeout
回调就可以理解了!
setTimeout(() => console.log("2"), 1000);
// 渲染主线成执行到这行代码时,会将其打包到定时器线程
// 定时器线程在倒计时结束后将console.log("2")回调打包成宏任务放入宏任务队列
// 等到该宏任务出队后就会在主线程执行,打印2
// 所以说触发是异步的,但回调的执行仍在渲染主线程
Ⅲ、最后,我们还需要理解一下渲染和事件循环的关系:
浏览器会在每一轮事件循环中检查是否需要渲染(通常按屏幕刷新率,如 60Hz),但渲染本身可能被跳过(若无视觉变化)。渲染流程(布局、绘制、合成)也发生在主线程,可能被长任务阻塞。
浏览器的事件循环(Event Loop)并非简单地“执行任务 → 渲染”,而是按特定时间阶段顺序处理任务,渲染只是其中的一个可选环节。
对此:我们先了解一下一次事件循环的流程:
①、执行一个宏任务(如setTimeout
回调、<script>脚本)。
②、清空当前的微任务队列(所有的Promise.then
、MutationObserver
等)
③、判断是否需要渲染(通常按照屏幕刷新率,如16.7ms一次)
-
若需要渲染
a、执行
requestAnimationFrame
回调(更新动画状态)。b、执行Layout(布局)和Paint(绘制)。(前提是DOM或样式有变化)
c、Composite(合成)图层并交给GPU显示。
-
若不需要渲染:跳过此步骤,直接进入下一轮循环
④、下一轮循环,继续处理其他宏任务
需要理解的关键点:
-
渲染不是异步任务的直接结果,而是浏览器根据屏幕刷新率和页面是否有视觉变化决定的。即使有异步任务完成,若页面无变化或未到渲染时机,浏览器会跳过渲染阶段。
怎么理解?通俗的讲,渲染需同时满足两个条件后才会发生,一是屏幕刷新率(比如16.7ms)时刻到达、二是页面产生了变化。如果一个异步任务结束之后使页面发生变化,但是屏幕距离上一次刷新的间隔小于刷新率间隔(也就是无刷新信号),那么页面并不会重新渲染;反之,下一次屏幕刷新时刻到达时如果页面并没有发生变化时也不会重新渲染。
一次渲染的页面就是一帧,我们常说的掉帧就是因为主线程被长任务阻塞,导致某一帧的渲染未能在 16.7ms 内完成,浏览器会直接丢弃该帧,导致“掉帧”。
明白上面这些内容,我们就明白关于JS的事件循环机制了!总结如下👇
3、阐述一下JS的事件循环(面试题)
事件循环又叫做消息循坏,是浏览器渲染主线程的工作方式。
在Chrome的源码中,他开启一个永远不会结束的for循环,每次循环都从消息队列取出第一个任务执行,而其他线程只需要在合适的时候将任务加到消息队列末尾即可。
过去我们把消息队列简单的分为宏队列和微队列,这种说法已经无法满足目前复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式(微队列、交互队列、延时队列)。
根据W3C官方的解释,每个任务有不同的类型,同类型的任务必须在同一队列,不同的任务可以分属不同队列。不同的队有优先级(微队列 > 交互队列 > 延时队列),在一次事件循环中由浏览器决定取那一队列的任务,但浏览器必须由一个微队列,微队列的任务一定有最高的优先级,必须优先调度执行!
4、JS的异步与事件循环代码(面试题)
下面是一些关于 JavaScript
异步的经典面试题及解析,假如你需要检测一下自己是否已经了解js的事件循环和异步机制,你可以尝试一下下面这些题目,覆盖事件循环(Event Loop)
、Promise
、async/await
等核心概念,帮助你深入理解异步机制!!!
(1)async/await
与Promise
的结合
async function foo() {
console.log("a");
await Promise.resolve();
console.log("b");
}
console.log("c");
foo();
console.log("d");
// 答案:c → a → d → b
// await 后的代码相当于被包裹在 Promise.then 中,成为微任务。
// 执行顺序:同步代码 c → a → d,然后微任务 b。
(2)setTimeout
与Promise
的优先级陷阱
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => {
console.log("promise");
setTimeout(() => console.log("timeout-in-promise"), 0);
});
console.log("global");
// 答案:global → promise → timeout → timeout-in-promise
// 微任务 promise 先执行,内部的 setTimeout 作为新的宏任务加入队列。
// 第一轮宏任务队列中的 timeout 先执行,第二轮执行 timeout-in-promise。
(3)async
函数的返回值(本题涉及到对Promise对象的了解,如还未了解可以跳过)
async function func1() {
return "ok";
}
async function func2() {
return Promise.resolve("ok");
}
console.log(func1());
console.log(func2());
// 答案:Promise {<fulfilled>: "ok"}
// Promise {<pending>}
// func1 直接返回非 Promise 值,会被包装为已解决的 Promise。
// func2 返回一个 Promise,此时需要等待内部 Promise 解决,因此初始状态为 pending。
(4)事件循环综合题
setTimeout(() => console.log("setTimeout1"), 0);
setTimeout(() => {
console.log("setTimeout2");
Promise.resolve().then(() => console.log("promise1"));
}, 0);
Promise.resolve().then(() => console.log("promise2"));
console.log("global");
// 答案:global → promise2 → setTimeout1 → setTimeout2 → promise1
// 第一轮:同步代码 global → 微任务 promise2
// 第一轮宏任务队列:setTimeout1 和 setTimeout2(按代码顺序)。
// 执行 setTimeout2 时,内部的 promise1 作为微任务立即执行。
(5)递归微任务与事件循环
function loop() {
Promise.resolve().then(loop);
}
loop();
setTimeout(() => console.log("timeout"), 0);
// 答案:timeout 永远不会输出
// loop 函数不断向微任务队列添加任务,导致主线程永远在处理微任务,宏任务 setTimeout 无法执行。
// 注意:此代码会导致浏览器卡死!
(6)async/await
与错误处理
async function fetchData() {
throw new Error("Fail");
}
fetchData()
.then(() => console.log("Success"))
.catch((err) => console.log(err.message));
// 答案:"Fail"
// async 函数中抛出错误等价于返回一个被拒绝的 Promise,触发 catch 分支。
(7)Promise.all
的并发控制
const p1 = new Promise((resolve) => setTimeout(() => resolve(1), 2000));
const p2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000));
Promise.all([p1, p2]).then((res) => console.log(res));
// 答案:[1, 2](等待约 2 秒后输出)
// Promise.all 等待所有 Promise 完成,结果按输入顺序排列(与完成顺序无关)。
(8)复杂事件循环
console.log("start");
setTimeout(() => {
console.log("timeout1");
Promise.resolve().then(() => console.log("promise1"));
}, 0);
Promise.resolve().then(() => {
console.log("promise2");
setTimeout(() => {
console.log("timeout2");
Promise.resolve().then(() => console.log("promise3"));
}, 0);
});
console.log("end");
// 答案:start->end->promise2->timeout1->promise1->timeout2->promise3
// 同步代码:start → end。
// 微任务队列:执行 promise2,添加宏任务 timeout2。
// 第一轮宏任务:执行 timeout1,触发微任务 promise1。
// 第二轮宏任务:执行 timeout2,触发微任务 promise3。
最后是以上的核心知识点总结:
-
事件循环阶段:同步代码 → 微任务 → 渲染 → 宏任务。
-
微任务优先级:
Promise.then
、MutationObserver
、queueMicrotask
。 -
宏任务类型:
setTimeout
、setInterval
、I/O、UI 渲染等。 -
async/await 本质:语法糖包装 Promise,用微任务实现异步。
-
递归微任务陷阱:微任务队列不空,宏任务永远无法执行。