第一层:幼儿园阶段 ------ 为什么要有 Event Loop?
首先要明白一个铁律 :JavaScript 在浏览器中是单线程的。
想象一下:你是一家餐厅唯一的厨师(主线程)。
-
客人点了一份炒饭(同步代码),你马上炒。
-
客人点了一份需要炖3小时的汤(耗时任务,如网络请求、定时器)。
如果你只有这一个线程,还要死等汤炖好才能炒下一个菜,那餐厅早就倒闭了(页面卡死)。
所以,浏览器给你配了几个服务员(Web APIs,如定时器模块、网络模块)。
-
厨师(主线程) :只负责炒菜(执行 JS 代码)。
-
服务员(Web APIs) :负责看火炖汤(计时、HTTP请求)。汤好了,服务员把"汤好了"这个纸条贴在厨房的**任务板(队列)**上。
-
Event Loop(事件循环) :就是厨师的一个习惯------炒完手里的菜,就去看看任务板上有没有新纸条。如果有,拿下来处理。
总结:Event Loop 是单线程 JS 实现异步非阻塞的核心机制。
第二层:小学阶段 ------ 宏任务与微任务的分类
任务板上的纸条分两种,优先级不同。面试官最爱问这个分类。
1. 宏任务(Macrotask / Task)
这就像是新的客人进店。每次处理完一个宏任务,厨师可能需要休息一下(浏览器渲染页面),然后再接下一个。
-
常见的:
-
script(整体代码 script 标签) -
setTimeout/setInterval -
setImmediate(Node.js/IE 环境) -
UI 渲染 / I/O
-
postMessage
2. 微任务(Microtask)
这就像是当前客人的临时加单 。客人说:"我要加个荷包蛋"。厨师必须 在服务下一个客人之前,先把这个客人的加单做完。不能让当前客人等着你去服务别人。
-
常见的:
-
Promise.then/.catch/.finally -
process.nextTick(Node.js,优先级最高) -
MutationObserver(监听 DOM 变化) -
queueMicrotask
第三层:中学阶段 ------ 完整的执行流程(必背)
这是大多数面试题的解题公式。请背诵以下流程:
-
执行同步代码(这其实是第一个宏任务)。
-
同步代码执行完毕,Call Stack(调用栈)清空。
-
检查微任务队列:
-
如果有,依次执行所有微任务,直到队列清空。
-
注意:如果在执行微任务时又产生了新的微任务,会插队到队尾,本轮必须全部执行完,绝不留到下一轮。
-
尝试渲染 UI(浏览器会根据屏幕刷新率决定是否需要渲染,通常 16ms 一次)。
-
取出下一个宏任务执行。
-
回到第 1 步,循环往复。
口诀:同步主线程 -> 清空微任务 -> (尝试渲染) -> 下一个宏任务
第四层:大学阶段 ------ 常见坑点实战(初级面试题)
这时候我们来看代码,这里有两个经典坑。
坑点 1:Promise 的构造函数是同步的
面试官常考:
javascript
new Promise((resolve) => {
console.log(1); // 同步执行!
resolve();
}).then(() => {
console.log(2); // 微任务
});
console.log(3);
JavaScriptCopy
解析 :Promise 构造函数里的代码会立即执行。只有 .then 里面的才是微任务。 输出 : 1 -> 3 -> 2
坑点 2:async/await 的阻塞
javascript
async function async1() {
console.log('A');
await async2(); // 关键点
console.log('B');
}
async function async2() {
console.log('C');
}
async1();
console.log('D');
JavaScriptCopy
解析:
-
async1开始,打印A。 -
执行
async2,打印C。 -
关键 :遇到
await,浏览器会把await后面 的代码(console.log('B'))放到微任务队列 里,然后跳出async1函数,继续执行外部的同步代码。 -
打印
D。 -
同步结束,清空微任务,打印
B。 输出 :A->C->D->B
第五层:博士阶段 ------ 深入进阶(吊打面试官专用)
1. 为什么要有微任务?(设计哲学)
你可能知道微任务比宏任务快,但为什么? 本质原因 :为了确保在下次渲染之前 ,更新应用的状态。 如果微任务是宏任务,那么 数据更新 -> 宏任务队列 -> 渲染 -> 宏任务执行 。这会导致页面先渲染一次旧数据,然后再执行逻辑更新,导致闪屏。 微任务保证了: 数据更新 -> 微任务(更新更多状态) -> 渲染 。所有的状态变更都在同一帧内完成。
2. 微任务的死循环(炸掉浏览器)
因为微任务必须清空才能进入下一个阶段。
scss
function loop() {
Promise.resolve().then(loop);
}
loop();
JavaScriptCopy
后果 :这会阻塞主线程!浏览器页面会卡死(点击无反应),且永远不会进行 UI 渲染。 对比 :如果是 setTimeout(loop, 0) 无限递归,虽然 CPU 占用高,但浏览器依然可以响应点击,依然可以渲染页面。因为宏任务之间会给浏览器"喘息"的机会。
3. 页面渲染的时机(DOM 更新是异步的吗?)
这是一个巨大的误区。JS 修改 DOM 是同步的(内存里的 DOM 树立刻变了),但视觉上的渲染是异步的。
ini
document.body.style.background = 'red';
document.body.style.background = 'blue';
document.body.style.background = 'black';
JavaScriptCopy
浏览器很聪明,它不会画红、画蓝、再画黑。它会等 JS 执行完,发现最后是黑色,直接画黑色。
必杀技问题:如何在宏任务执行前强制渲染? 如果你想让用户看到红色,然后再变黑,普通的 setTimeout(..., 0) 是不稳定的。 标准做法是使用 requestAnimationFrame 或者 强制回流(Reflow) (比如读取 offsetHeight )。
4. 真正的深坑:事件冒泡中的微任务顺序
这是极少数人知道的细节。
场景:父子元素都绑定点击事件。
javascript
// HTML: <div id="outer"><div id="inner">Click me</div></div>
const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');
function onClick() {
console.log('click');
Promise.resolve().then(() => console.log('promise'));
}
outer.addEventListener('click', onClick);
inner.addEventListener('click', onClick);
JavaScriptCopy
情况 A:用户点击屏幕
-
触发 inner 点击 -> 打印
click-> 微任务入队。 -
栈空了! (在冒泡到 outer 之前,当前回调结束了)。
-
检查微任务 -> 打印
promise。 -
冒泡到 outer -> 打印
click-> 微任务入队。 -
回调结束 -> 检查微任务 -> 打印
promise。 结果 :click->promise->click->promise
情况 B:JS 代码触发 inner.click()
-
inner.click()这是一个同步函数! -
触发 inner 回调 -> 打印
click-> 微任务入队。 -
栈没空! (因为
inner.click()还在栈底等着冒泡结束)。 -
不能执行微任务。
-
冒泡到 outer -> 打印
click-> 微任务入队。 -
inner.click()执行完毕,栈空。 -
清空微任务 (此时队列里有两个 promise)。 结果 :
click->click->promise->promise
面试杀招 :指出用户交互触发 和程序触发在 Event Loop 中的堆栈状态不同,导致微任务执行时机不同。
第六层:上帝视角 ------ 浏览器的一帧(The Frame)
要理解 React 为什么要搞 Concurrent Mode,首先要看懂**"一帧"**里到底发生了什么。
大多数屏幕是 60Hz,意味着浏览器只有 16.6ms 的时间来完成这一帧的所有工作。如果超过这个时间,页面就会掉帧(卡顿)。
完整的一帧流程(标准管线):
-
Input Events: 处理阻塞的输入事件(Touch, Wheel)。
-
JS (Macro/Micro) : 执行定时器、JS 逻辑。这里是性能瓶颈的高发区。
-
Begin Frame: 每一帧开始的信号。
-
requestAnimationFrame (rAF) : 关键点。这是 JS 在渲染前最后修改 DOM 的机会。
-
Layout (重排) : 计算元素位置(盒模型)。
-
Paint (重绘) : 填充像素。
-
Idle Period (空闲时间) : 如果上面所有事情做完还没到 16.6ms,剩下的时间就是 Idle。
关键冲突 : Event Loop 的微任务(Microtasks)是在 JS 执行完立刻执行的。如果微任务队列太长,或者 JS 宏任务太久,直接把 16.6ms 撑爆了,浏览器就没机会去执行 Layout 和 Paint。 结果就是:页面卡死。
第七层:React 18 Concurrent Mode ------ 时间切片(Time Slicing)
React 15(Stack Reconciler)是递归更新,一旦开始 diff 一棵大树,必须一口气做完。如果这棵树需要 100ms 计算,那这 100ms 内主线程被锁死,用户输入无响应。
React 18(Fiber 架构)引入了 可中断渲染。
1. 核心原理:把"一口气"变成"喘口气"
React 把巨大的更新任务切分成一个个小的 Fiber 节点(Unit of Work) 。
-
旧模式:JS 执行 100ms -> 渲染。 (卡顿)
-
新模式 (Concurrent) :
-
执行 5ms 任务。
-
问浏览器:"还有时间吗?有高优先级任务(如用户点击)插队吗?"
-
有插队 -> 暂停当前 React 更新,把主线程还给浏览器去处理点击/渲染。
-
没插队 -> 继续下一个 5ms。
2. 实现手段:如何"暂停"和"恢复"?(MessageChannel 的妙用)
React 必须要找一个宏任务来把控制权交还给浏览器。
-
为什么不用
setTimeout(fn, 0)? -
因为这货有 4ms 的最小延迟(由于 HTML 标准遗留问题,嵌套层级深了会强制 4ms)。对于追求极致的 React 来说,4ms 太浪费了。
-
为什么不用
Microtask? -
死穴 :微任务会在页面渲染前全部清空。如果你用微任务递归,主线程还是会被锁死,根本不会把控制权交给 UI 渲染。
-
最终选择:
MessageChannel -
React Scheduler 内部创建了一个
MessageChannel。 -
当需要"让出主线程"时,React 调用
port.postMessage(null)。 -
这会产生一个宏任务。
-
因为是宏任务,浏览器有机会在两个任务之间插入 UI 渲染 和 响应用户输入。
-
且
MessageChannel的延迟极低(接近 0ms),优于setTimeout。
简化的 React Scheduler 伪代码:
ini
let isMessageLoopRunning = false;
const channel = new MessageChannel();
const port = channel.port2;
// 这是一个宏任务回调
channel.port1.onmessage = function() {
const currentTime = performance.now();
let hasTimeRemaining = true;
// 执行任务,直到时间片用完(默认 5ms)
while (workQueue.length > 0 && hasTimeRemaining) {
performWork();
// 检查是否超时(比如超过了 5ms)
if (performance.now() - currentTime > 5) {
hasTimeRemaining = false;
}
}
if (workQueue.length > 0) {
// 如果还有活没干完,但时间片到了,
// 继续发消息,把剩下的活放到下一个宏任务里
port.postMessage(null);
} else {
isMessageLoopRunning = false;
}
};
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null); // 触发宏任务
}
}
JavaScriptCopy
第八层:Vue 3 的策略对比 ------ 为什么 Vue 不需要 Fiber?
这是一个极好的对比视角。
-
React:走的是"全量推导"路线。组件更新时,默认不知道哪里变了,需要遍历树。为了不卡顿,只能用 Event Loop 切片。
-
Vue:走的是"精确依赖"路线。响应式系统(Proxy)精确知道是哪个组件变了。更新粒度很细,通常不需要像 React 那样长时间的计算。
Vue 的 Event Loop 应用: nextTick Vue 依然大量使用了 Event Loop,主要是为了批量更新(Batching) 。
ini
count.value = 1;
count.value = 2;
count.value = 3;
JavaScriptCopy
Vue 检测到数据变化,不会渲染 3 次。它会开启一个队列,把 Watcher 推进去。然后通过 Promise.then (微任务) 或 MutationObserver 在本轮代码执行完后,一次性 flush 队列。
应用场景:当你修改了数据,想立刻获取更新后的 DOM 高度。
arduino
msg.value = 'Hello';
console.log(div.offsetHeight); // 还是旧高度!因为 DOM 更新在微任务里
await nextTick(); // 等待微任务执行完
console.log(div.offsetHeight); // 新高度
JavaScriptCopy
第九层:实战中的"精细化调度"
除了框架内部,我们在写复杂业务代码时,如何利用 Event Loop 管线进行优化?
1. requestAnimationFrame (rAF) 做动画
-
错误做法 :
setTimeout做动画。 -
原因:
setTimeout也是宏任务,但它的执行时机和屏幕刷新(VSync)不同步。可能会导致一帧里执行了两次 JS,或者掉帧。 -
正确做法 :
rAF。 -
它保证回调函数严格在下一次 Paint 之前执行。
-
浏览器会自动优化:如果页面切到后台,rAF 会暂停,省电。
2. requestIdleCallback 做低优先级分析
-
场景:发送埋点数据、预加载资源、大数据的后台计算。
-
原理:告诉浏览器,"等我不忙了(帧末尾有剩余时间)再执行这个"。
-
注意:React 没直接用这个 API,因为它的兼容性和触发频率不稳定,React 自己实现了一套类似的(也就是上面说的 MessageChannel 机制)。
3. 大数据列表渲染(时间切片实战)
假设后端给你返回了 10 万条数据,你要渲染到页面上。
-
直接渲染 :
ul.innerHTML = list-> 页面卡死 5 秒。 -
微任务渲染:用 Promise 包裹 -> 依然卡死!因为微任务也会阻塞渲染。
-
宏任务分批(时间切片) :
scss
function renderList(list) {
if (list.length === 0) return;
// 每次取 20 条
const chunk = list.slice(0, 20);
const remaining = list.slice(20);
// 渲染这 20 条
renderChunk(chunk);
// 关键:用 setTimeout 把剩下的放到下一帧(或之后的宏任务)去处理
// 这样浏览器就有机会在中间进行 UI 渲染,用户能看到列表慢慢变长,而不是卡死
setTimeout(() => {
renderList(remaining);
}, 0);
}
JavaScriptCopy
- 进阶 :使用
requestAnimationFrame替代setTimeout,虽然 rAF 主要是为动画服务的,但在处理 DOM 批量插入时,配合DocumentFragment往往比 setTimeout 更流畅,因为它紧贴渲染管线。
第十层:未来的标准 ------ scheduler.postTask
浏览器厂商发现大家都在自己搞调度(React 有 Scheduler,Vue 有 nextTick),于是 Chrome 推出了原生的 Scheduler API。
这允许你直接指定任务的优先级,而不需要玩 setTimeout 或 MessageChannel 的黑魔法。
php
// 只有 Chrome 目前支持较好
scheduler.postTask(doImportantWork, { priority: 'user-blocking' }); // 高优
scheduler.postTask(doAnalytics, { priority: 'background' }); // 低优
JavaScriptCopy
总结:如何回答"实际应用场景"
如果面试官问到这里,你可以这样收网:
-
管线视角:先说明 JS 执行、微任务、渲染、宏任务的流水线关系。
-
React 案例 :重点描述 React 18 如何利用 宏任务 (
MessageChannel) 实现时间切片,从而打断长任务,让出主线程给 UI 渲染。 -
对比 Vue :解释 Vue 利用 微任务 (
Promise) 实现异步批量更新,避免重复计算。 -
业务落地:
-
高性能动画 :必用
requestAnimationFrame保持与帧率同步。 -
海量数据渲染 :手动分片,利用
setTimeout或rAF分批插入 DOM,避免白屏卡顿。 -
后台计算/埋点 :利用
requestIdleCallback在浏览器空闲时处理。
终极回答策略:从机制到架构的四维阐述
1. 核心定性(不仅是单线程)
"Event Loop 是浏览器用来协调 JS 执行 、DOM 渲染 、用户交互 以及 网络请求 的核心调度机制。它解决了 JS 单线程无法处理高并发异步任务的问题,实现了非阻塞 I/O。"
2. 标准流程(精确到微毫秒的执行顺序)
"标准的流程是:执行栈为空 -> 清空微任务队列 (Microtasks) -> 尝试进行 UI 渲染 -> 取出一个宏任务 (Macrotask)执行。 这里的关键点是:微任务拥有最高优先级插队权 ,必须全部清空才能进入下一阶段;而UI 渲染穿插在微任务之后、宏任务之前,通常由浏览器的刷新率(60Hz)决定是否执行。"
3. 进阶:与渲染管线的结合(展示物理层面的理解)
"在性能优化中,我们要关注**'一帧'(16.6ms)**的生命周期。 如果微任务队列太长,或者宏任务执行太久,都会阻塞浏览器的 Layout 和 Paint,导致掉帧。
4. 降维打击:框架原理与调度实战(这是加分项!)
"深刻理解 Event Loop 是理解现代框架源码的基石:
速记核心关键词
如果面试紧张,脑子里只要记住这 4 个关键词,就能串联起整个知识网:
-
单线程 (起点)
-
微任务清空 (Promise, Vue 原理)
-
渲染管线 (16ms, 动画流畅度)
-
宏任务切片 (React Fiber, 大数据分片)