JavaScript 事件循环机制详解

小飞兽 JavaScript 188 次阅读 2026-06-15

JavaScript 事件循环机制详解

一、Introduction

JavaScript 从诞生起就是一门单线程语言——同一时刻只能执行一段代码。这并非语言的设计缺陷,而是有意为之:浏览器环境中,如果多个脚本同时操作 DOM,后果将不堪设想。然而,单线程并不意味着浏览器只能傻等——事件循环(Event Loop)机制让 JavaScript 能够在等待(I/O、定时器、网络请求)期间处理其他任务,从而实现看似并发的用户体验。本文将彻底拆解事件循环的每一个组成部分:调用栈(Call Stack)、任务队列(Task Queue / Macrotask Queue)、微任务队列(Microtask Queue)、Node.js 的 libuv 事件循环,以及 requestAnimationFrame 的特殊调度逻辑。

二、基础概念

1. 调用栈(Call Stack)

调用栈是 JavaScript 引擎维护的执行上下文栈。每次调用一个函数,引擎就会创建一个帧(Frame)推入栈顶;函数返回时,帧从栈顶弹出。栈的特点是后进先出(LIFO),这意味着嵌套调用的函数会依次叠加在栈上,形成完整的执行链路。

function a() { b(); console.log('a'); }
function b() { c(); console.log('b'); }
function c() { console.log('c'); }

a();
// 调用顺序: a → b → c → c打印 → b打印 → a打印
// 栈的变化: [global] → [a] → [a,b] → [a,b,c] → [a,b] → [a] → []
// 输出: c → b → a

2. 任务队列(Macrotask Queue)

任务队列也称为宏任务队列,用于存放需要异步执行的"任务"。每个任务本质上是一个函数回调。每轮事件循环,引擎从队列中取出一个任务执行完毕后,才会进入下一轮循环。

常见的宏任务(Macrotask)来源:

    • setTimeout / setInterval 的回调
    • I/O 操作完成后的回调(readFile、fetch、XMLHttpRequest)
    • setImmediate(Node.js 独有)
    • UI 渲染(浏览器环境,非每次循环都会渲染)
    • requestAnimationFrame(浏览器环境,归属稍特殊)

    3. 微任务队列(Microtask Queue)

    微任务队列是 ES6 引入的概念(Promise 规范),专门存放微任务(Microtask)。与宏任务不同,每次宏任务执行完毕后,引擎会倾空(drain)整个微任务队列——所有微任务会在下一个宏任务开始前全部执行完毕。

    常见的微任务来源:

    • Promise.then / catch / finally 的回调
    • async/await(实质是 Promise + 生成器语法糖)
    • queueMicrotask() 手动入队
    • MutationObserver 回调(浏览器)
    • process.nextTick(Node.js 独有,优先级高于普通微任务)

    4. Node.js libuv 事件循环阶段(Phase)

    Node.js 的事件循环比浏览器更复杂,libuv 将循环划分为多个阶段(Phase),每个阶段处理不同的任务源:

       ┌───────────────────────────┐
       │       timers (setTimeout/setInterval callbacks)       │
       │  pending callbacks ── I/O callbacks (errors, etc.)     │
       │  idle, prepare ── 内部使用                             │
       │  poll ── 检索新的 I/O 事件;执行几乎所有 I/O 回调      │
       │  check ── setImmediate 回调                            │
       │  close callbacks ── socket.on('close') 等             │
       └───────────────────────────────────────────────────────┘
       │              每阶段之间:微任务队列 + process.nextTick  │
    

    三、代码示例

    1. 基础事件循环演示

    console.log('1 - script start');
    

    setTimeout(() => console.log('2 - setTimeout'), 0);

    Promise.resolve()
    .then(() => console.log('3 - Promise.then 1'))
    .then(() => console.log('4 - Promise.then 2'));

    console.log('5 - script end');

    // 经典浏览器输出顺序(Chrome/Firefox):
    // 1 - script start
    // 5 - script end
    // 3 - Promise.then 1 ← 微任务
    // 4 - Promise.then 2 ← 微任务
    // 2 - setTimeout ← 宏任务(尽管延迟为 0)

    2. async/await 与微任务

    async function main() {
      console.log('A - before await');
      await Promise.resolve();
      console.log('B - after await'); // 相当于在 Promise.then 里执行
    }
    

    console.log('C - before call');
    main();
    console.log('D - after call');

    // 输出顺序:
    // C - before call
    // A - before await
    // D - after call
    // B - after await ← 微任务执行时机

    // 微任务执行时机:在一个 Promise resolve 之后,.then() 的回调会进入微任务队列,
    // 但不是立即执行,而是等待当前执行栈清空后统一执行。

    3. 嵌套 setTimeout 与任务累积

    setTimeout(() => {
      console.log('macro 1');
      Promise.resolve().then(() => console.log('micro during macro 1'));
    }, 0);
    

    setTimeout(() => {
    console.log('macro 2');
    }, 0);

    Promise.resolve().then(() => console.log('micro before both macros'));

    // 执行流程:
    // 1. 两个 setTimeout 注册 → 两个宏任务进入队列
    // 2. Promise.then 注册 → 微任务进入队列
    // 3. 执行栈为空 → 清空微任务队列 → 输出 "micro before both macros"
    // 4. 事件循环取第一个 setTimeout → 输出 "macro 1" → 其中的 Promise.then 进入微队列
    // 5. 第一个 setTimeout 结束 → 清空微任务队列 → 输出 "micro during macro 1"
    // 6. 事件循环取第二个 setTimeout → 输出 "macro 2"

    4. 浏览器渲染时机

    // 浏览器中,渲染发生在每轮事件循环的「开始」,
    // 但不是每轮都会渲染——只有当达到合适的帧时间(~16.67ms)时才会实际渲染。
    

    document.body.style.background = 'red';
    // 注意:同步 JS 修改样式,渲染可能在下一个宏任务开始前不会发生
    // 浏览器为了优化,可能将多个 DOM 变更合并到一次渲染中

    // 如果需要强制同步渲染后执行某操作:
    requestAnimationFrame(() => {
    console.log('rendered!');
    // 这里的代码在浏览器真正渲染之后执行
    });

    5. Node.js process.nextTick vs setImmediate

    // Node.js 环境
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('microtask'));
    setImmediate(() => console.log('setImmediate'));
    

    // 输出:
    // nextTick ← nextTick 优先级最高,在当前操作完成后立即执行
    // microtask ← 普通微任务
    // setImmediate ← 下一个事件循环 check 阶段才执行
    // 注意:I/O 回调中两者顺序可能互换

    四、运行效果

    在 Node.js 环境(v18+)运行上述代码,典型输出:

    # 示例 1(基础事件循环)
    1 - script start
    5 - script end
    3 - Promise.then 1
    4 - Promise.then 2
    2 - setTimeout
    

    示例 2(async/await)

    C - before call A - before await D - after call B - after await

    示例 3(嵌套 setTimeout)

    micro before both macros macro 1 micro during macro 1 macro 2

    示例 5(Node.js nextTick)

    nextTick microtask setImmediate

    在浏览器中,可以使用 Performance API 观察每个阶段的精确时序,或在 DevTools 的 Performance 面板中录制并放大查看 Call Tree 和 Event Log。

    五、常见问题

    Q1: setTimeout(fn, 0) 是否保证 fn 立即执行?

    否。setTimeout(fn, 0) 只是告诉引擎"在最早可执行的下一个事件循环中执行 fn"。实际上,fn 会进入宏任务队列,需要等当前执行栈清空 + 微任务队列清空后,才会轮到它。如果此时有其他宏任务在队列前面,fn 仍需等待。

    Q2: Promise 和 setTimeout 谁先执行?

    Promise.then 的回调(微任务)总是在 setTimeout 的回调(宏任务)之前执行,即使 setTimeout 延迟为 0。因为微任务队列在每个宏任务结束后都会被倾空。参见上方代码示例 1。

    Q3: async/await 是否会阻塞主线程?

    async 函数本身的调用是同步的,不会阻塞。await 只在等待一个未决议的 Promise 时才会"暂停"当前 async 函数的执行,将后续代码注册为微任务,让出主线程。这使得 async/await 是非阻塞的异步语法。

    Q4: 为什么在 for 循环中使用 setTimeout 时所有回调都打印相同的值?

    // 经典闭包问题
    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 0);
    }
    // 输出: 3 3 3 —— 循环结束后 i=3,所有回调共享同一个 i
    

    // 解决方案 1:使用 let(块级作用域)
    for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0);
    }
    // 输出: 0 1 2 —— let 在每次迭代创建新绑定

    // 解决方案 2:IIFE 捕获
    for (var i = 0; i < 3; i++) {
    ((j) => setTimeout(() => console.log(j), 0))(i);
    }

    Q5: Node.js 和浏览器的事件循环有何区别?

    主要区别在于 Node.js 使用 libuv 库实现了多阶段(Phase)的事件循环,支持文件 I/O、DNS 查询、子进程等,且有 process.nextTicksetImmediate 这些浏览器没有的 API。浏览器环境的事件循环更简单,主要围绕渲染和 UI 事件展开。两者在微任务(Promise)的处理上基本一致,但 Node.js 中 process.nextTick 优先级高于普通微任务,而浏览器没有这个概念。

    Q6: requestAnimationFrame 在事件循环的哪个位置?

    在浏览器中,requestAnimationFrame 回调在每轮事件循环结束后、渲染之前执行(如果需要渲染的话)。它不属于微任务也不属于普通宏任务,而是浏览器的特殊调度信号。如果屏幕不需要刷新(没有 DOM 变更),回调也可能根本不执行。Node.js 从 v14 开始支持 performance.nodeTiming 等 Performance API,但 requestAnimationFrame 并非 Node.js 标准 API。

    六、延伸阅读

    • Loupe - 事件循环可视化工具(Philip Roberts 制作):http://latentflip.com/loupe —— 实时可视化调用栈、回调队列和事件循环的交互工具,是理解事件循环的最佳入门。
  • TC39 ECMAScript 规范 - Jobs and Host Jobs(高级):https://tc39.es/ecma262/ §8.4 — 微任务队列的规范定义。