核心概念和组件
JavaScript是单线程的
- 这意味着它一次只能执行一段代码。
- 如果所有操作(网络请求、文件读取、定时器、用户点击)都同步执行,那么耗时的操作会阻塞整个页面,导致页面无响应。事件循环就是为了解决这个问题而存在的。
关键组件
- 调用栈
Call Stack
- 一个后进先出
LIFO的数据结构.- 负责跟踪当前正在执行的函数。
- 每调用一个函数,就将其压入栈顶;函数执行完毕
return,就将其弹出栈。- 同步代码在这里按顺序执行。
- 堆
Heap
一个非结构化的内存区域,用于存储对象(变量、函数等分配的内存);
- 任务队列
Callback Queue/Task Queue/MacroTask Queue
- 一个先进先出
FIFO的数据结构。- 存放宏任务
MacroTask的回调函数。- 常见的宏任务来源:
setTimeout/setInterval/setImmediate(Node.js)/I/O操作(如网络请求完成、文件读取完成)/UI渲染(浏览器)/DOM事件(click/load)的回调。
- 微任务队列
Microtask Queue/Job Queue
- 一个先进先出
FIFO的数据结构,但优先级高于任务队列。- 存放微任务的回调函数。
- 常见的微任务来源:
Promise.then/Promise.catch/Promise.finally/MutationObserver(浏览器)/queueMicrotask
- 事件循环
Event Loop
- 一个持续运行的进程,负责协调调用栈、任务队列和微任务队列。
- 它的核心职责是:当调用栈为空时,检查队列并安排下一个任务执行。
工作流程
事件循环遵循一个非常具体的循环过程:
执行同步代码Initial Execution
- 脚本开始执行时,所有的同步代码被依次压入调用栈执行。
- 这是事件循环的第一个轮回的开始。
执行当前调用栈
- 事件循环首先处理调用栈中的任务,直到调用栈完全清空。
执行所有微任务Process Microtasks
- 一旦调用栈为空,事件循环会立即检查微任务队列。
- 它会连续不断地、一次性执行完微任务队列中所有已存在的微任务回调函数(直到微任务队列为空)。
- 在执行一个微任务的过程中,该微任务可能又会产生新的微任务(例如:在
Promise.then()中又返回一个新的Priomise并调用其.then())。这些新产生的微任务会被添加到微任务队列的末尾,并在当前这轮微任务处理循环中被立即执行,直到队列真正清空。这是微任务处理的关键特点:一个宏任务之后,会清空整个微任务队列(包括期间新产生的微任务)。
是否需要渲染(Update Rendering-浏览器特有)
- 「此步骤主要针对浏览器环境」 在微任务队列清空后,浏览器可能会执行渲染更新(布局
Layout/绘制Paint)。但这不是事件循环规范的一部分,而是浏览器实现时的优化点。渲染发生的时机由浏览器决定,通常尝试与屏幕刷新率同步(如每秒60次)。
执行一个宏任务Run a MacroTask
- 微任务队列清空(/渲染后),事件循环检查 (宏)任务队列。
- 如果任务队列中有等待的任务,事件循环取出队列中最前面的一个宏任务(最早入队的那个),将其回调函数压入调用栈执行。
- 注意:每次循环只执行一个宏任务(如果在执行这个宏任务的过程中产生了新的宏任务,新的宏任务要等到下一轮循环才执行)。
重复循环Loop
- 执行完步骤5中的一个宏任务后,调用栈再次变空。
- 事件循环 立即回到步骤「执行所有微任务」 。
- 然后按顺序执行随后步骤,这个过程无限循环下去。
关键点与注意事项
- 微任务优先:微任务队列的优先级远高于宏任务队列。每当调用栈清空(无论是初始同步代码执行完,还是一个宏任务执行完),事件循环都会先去清空整个微任务队列,然后才考虑执行下一个宏任务。
- 宏任务一次一个
- UI渲染时机:在浏览器中,渲染通常发生在微任务队列清空之后,下一个宏任务执行之前。这意味着在微任务中进行大量的同步操作会阻塞渲染,导致页面卡顿。
- setTimeout(fn, 0)并不精确:它表示“尽快”将
fn的回调放入宏任务队列,但实际执行至少要等到当前调用栈和微任务队列清空之后,并且前面可能还有其它宏任务在排队。浏览器通常还有最小延迟(如4ms)。 Node.jsvs浏览器:核心的事件循环概念(宏任务/微任务)是相同的。区别在于:
- 宏任务来源:Node.js有
setImmediate(通常比setTimeout(fn, 0)优先级更高)、I/O回调、特定于Node的事件。- 微任务来源:
Promise回调、process.nextTick()(netTick队列是一个特殊的队列-Node.js特有,其优先级甚至高于微任务队列,会在当前操作结束后、事件循环继续之前立即执行)。- 阶段划分:Node.js的事件循环被更精细地划分为多个阶段(
timers,pending callbacks,idle/prepare,poll,check,close callbacks),每个阶段处理特地类型的宏任务。微任务(和netTick)在阶段切换之间执行。
- 避免阻塞:长时间运行的同步代码或微任务(如大型循环、复杂计算)会阻塞调用栈,导致事件循环无法处理队列中的任务(宏任务/微任务)和渲染,造成页面卡死。务必使用异步操作或将耗时任务分块(
setTimeout/requestIdleCallback/Web Workers)
经典案例分析
通用场景
console.log('script start'); // 1. 同步
setTimeout(() => {
console.log('setTimeout'); // 5. 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('promise 1'); // 3. 微任务
}).then(() => {
console.log('promise 2'); // 4. 微任务(在上一个微任务执行过程中产生,立即执行)
});
console.log('script end'); // 2. 同步
Node.js环境
console.log('Start'); // 同步
setTimeout(() => console.log('setTimeout 1'), 0); // 宏任务
process.nextTick(() => {
console.log('nextTick 1');
setTimeout(() => console.log('setTimeout inside nextTick'), 0); // 微任务 timers?
});
new Promise(resolve => {
console.log('Promise executor'); // 同步
resolve();
}).then(() => {
console.log('Promise then 1'); // 微任务
process.nextTick(() => console.log('nextTick inside Promise then'));
});
async function asyncFunc() {
console.log('Async function start'); // 同步
await new Promise(resolve => resolve());
console.log('After await'); // 微任务
process.nextTick(() => console.log('nextTick after await'));
}
setImmediate(() => console.log('setImmediate')); // 宏任务
asyncFunc();
process.nextTick(() => console.log('nextTick 2'));
console.log('End'); // 同步
解析
- 执行同步代码
Start
Promise executor
Async function start
End
- 处理nextTick队列
nextTick 1 nextTick 2
执行时:
nextTick 1输出,并添加setTimeout到timers队列nextTick 2输出
- 处理微任务队列(
Promise回调)
Promise then 1 After await
执行时:
Promise then 1输出- 添加
() => console.log('nextTick inside Promise then')到nextTick队列 - 处理
await隐式Promise:asyncFunc中的await产生一个微任务 - 添加
() => console.log('nextTick after await')到nextTick队列
- 再次处理
nextTick队列
nextTick inside Promise then nextTick after await
- 进入事件循环(
timers阶段)
setTimeout 1
setTimeout inside nextTick
- 进入
check阶段(setImmediate)
setImmediate
关键点
- 执行顺序优先级:同步代码 ->
process.nextTick()-> 微任务 -> 宏任务 process.nextTick()特性:- 在当前操作结束后立即执行
- 优先级高于微任务队列
- 递归调用会导致事件循环
饿死
async/await本质:await之后的代码相当于放在Promise.then()中asyncFunc的调用同步执行直到第一个await
- 事件循环阶段:
timers阶段执行setTimeout/setIntervalcheck阶段执行setImmediate
- 微任务执行时机:
- 在每个阶段切换之间执行
- 在
nextTick队列清空后执行
requestAnimationFrame
在浏览器渲染阶段,会执行以下步骤:
- 执行
requestAnimationFrame回调 - 计算样式
style、布局layout、绘制paint - 合成图层
composite
requestAnimationFrame的执行时机
- 与屏幕刷新频率同步:
requestAnimationFrame回调在每次渲染前执行(通常每秒60次,即16.67ms/帧),确保动画流畅。 - 避免布局抖动:所有
requestAnimationFrame回调在同一事件循环的渲染阶段批量执行,保证DOM变更在布局前完成。
示例流程
// 宏任务阶段
setTimeout(() => { /** detail */}, 0);
// 微任务阶段
Promise.resolve().then(() => { /** detail */ });
// 渲染阶段
requestAnimationFrame(() => { /** detail */});
执行顺序:宏任务 -> 微任务 -> rAF回调 -> 渲染
MutationObserver的异步监听机制
MutationObserver用于监听DOM变化,其核心设计为异步批量处理:
- 变化记录队列:DOM变化时,不会立即触发回调,而是将变更记录到队列。
- 微任务触发:在当前宏任务和微任务执行完毕后,统一处理队列中的变更,并执行回调。
优势
- 批量处理:多次DOM改动合并为一次回调,减少重复操作。
- 性能优化:相比同步的
Mutaion Events(已废弃),避免频繁触发导致的性能问题。
示例
const observer = new MutationObserver((mutations) => {
// detail
});
observer.observe(document.body, { childList: true });
// 连续修改DOM
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('div'));
// 此时不会立即触发回调,而是将两次变更记录到队列
// 微任务结束后,执行回调(一次)
requestAnimationFrame与MutationObserver对比与关联
| 特性 | requestAnimationFrame | MutationObserver |
|---|---|---|
| 触发时机 | 渲染阶段前(与帧刷新同步) | 微任务阶段(宏任务执行完毕后) |
| 主要用途 | 动画优化、批量DOM读操作 | 监听DOM变化并批量处理 |
| 任务类型 | 渲染阶段逻辑,非传统宏/微任务 | 微任务 |