事件循环机制

296 阅读8分钟

核心概念和组件

JavaScript是单线程的

  • 这意味着它一次只能执行一段代码。
  • 如果所有操作(网络请求、文件读取、定时器、用户点击)都同步执行,那么耗时的操作会阻塞整个页面,导致页面无响应。事件循环就是为了解决这个问题而存在的。

关键组件

  • 调用栈Call Stack
  1. 一个后进先出LIFO的数据结构.
  2. 负责跟踪当前正在执行的函数。
  3. 每调用一个函数,就将其压入栈顶;函数执行完毕return,就将其弹出栈。
  4. 同步代码在这里按顺序执行。
  • Heap

一个非结构化的内存区域,用于存储对象(变量、函数等分配的内存);

  • 任务队列Callback Queue/Task Queue/MacroTask Queue
  1. 一个先进先出FIFO的数据结构。
  2. 存放宏任务MacroTask 的回调函数。
  3. 常见的宏任务来源:setTimeout/setInterval/setImmediate(Node.js)/I/O操作(如网络请求完成、文件读取完成)/UI渲染(浏览器)/DOM事件(click/load)的回调。
  • 微任务队列Microtask Queue/Job Queue
  1. 一个先进先出FIFO的数据结构,但优先级高于任务队列
  2. 存放微任务的回调函数。
  3. 常见的微任务来源:Promise.then/Promise.catch/Promise.finally/MutationObserver(浏览器)/queueMicrotask
  • 事件循环Event Loop
  1. 一个持续运行的进程,负责协调调用栈、任务队列和微任务队列。
  2. 它的核心职责是:当调用栈为空时,检查队列并安排下一个任务执行

工作流程

事件循环遵循一个非常具体的循环过程:

执行同步代码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浏览器:核心的事件循环概念(宏任务/微任务)是相同的。区别在于:
  1. 宏任务来源:Node.js有setImmediate(通常比setTimeout(fn, 0)优先级更高)、I/O回调、特定于Node的事件。
  2. 微任务来源Promise回调、process.nextTick()(netTick队列是一个特殊的队列-Node.js特有,其优先级甚至高于微任务队列,会在当前操作结束后、事件循环继续之前立即执行)。
  3. 阶段划分: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');  // 同步

解析

  1. 执行同步代码

Start
Promise executor
Async function start
End

  1. 处理nextTick队列

nextTick 1 nextTick 2

执行时:

  • nextTick 1输出,并添加setTimeout到timers队列
  • nextTick 2输出
  1. 处理微任务队列(Promise回调)

Promise then 1 After await

执行时:

  • Promise then 1输出
  • 添加() => console.log('nextTick inside Promise then')到nextTick队列
  • 处理await隐式PromiseasyncFunc中的await产生一个微任务
  • 添加() => console.log('nextTick after await')到nextTick队列
  1. 再次处理nextTick队列

nextTick inside Promise then nextTick after await

  1. 进入事件循环(timers阶段)

setTimeout 1
setTimeout inside nextTick

  1. 进入check阶段(setImmediate

setImmediate

关键点

  1. 执行顺序优先级:同步代码 -> process.nextTick() -> 微任务 -> 宏任务
  2. process.nextTick()特性
    • 在当前操作结束后立即执行
    • 优先级高于微任务队列
    • 递归调用会导致事件循环饿死
  3. async/await本质
    • await之后的代码相当于放在Promise.then()
    • asyncFunc的调用同步执行直到第一个await
  4. 事件循环阶段:
    • timers阶段执行setTimeout/setInterval
    • check阶段执行setImmediate
  5. 微任务执行时机
    • 在每个阶段切换之间执行
    • 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变化,其核心设计为异步批量处理

  1. 变化记录队列:DOM变化时,不会立即触发回调,而是将变更记录到队列。
  2. 微任务触发:在当前宏任务和微任务执行完毕后,统一处理队列中的变更,并执行回调。

优势

  • 批量处理:多次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'));
// 此时不会立即触发回调,而是将两次变更记录到队列

// 微任务结束后,执行回调(一次)

requestAnimationFrameMutationObserver对比与关联

特性requestAnimationFrameMutationObserver
触发时机渲染阶段前(与帧刷新同步)微任务阶段(宏任务执行完毕后)
主要用途动画优化、批量DOM读操作监听DOM变化并批量处理
任务类型渲染阶段逻辑,非传统宏/微任务微任务