源本科技 | 码上会

JavaScript 事件循环

2026/01/12
18
0

学习目标

  • 理解 JavaScript 单线程模型与事件循环的关系

  • 掌握调用栈、任务队列(宏任务)与微任务队列的执行顺序

  • 能够解释常见异步代码的输出顺序

  • 识别并避免阻塞主线程的常见陷阱

  • 应用最佳实践编写高效、可维护的异步代码


什么是事件循环?

JavaScript 是单线程语言,意味着它一次只能执行一个任务。然而,现代 Web 应用需要处理网络请求、用户交互、定时器等异步操作,而不能让这些操作阻塞主线程。

事件循环(Event Loop) 正是解决这一矛盾的核心机制。它协调同步代码与异步回调的执行,确保程序在不阻塞的情况下高效运行。

组件

说明

Call Stack

调用栈存储当前正在执行的函数,遵循 LIFO(后进先出)原则

Web APIs

浏览器或 Node.js 提供的异步能力(如 setTimeoutfetch、DOM 事件等)

宏任务队列

Callback Queue 存放宏任务的回调,如 setTimeoutsetInterval

微任务队列

存放微任务,如 Promise.then()queueMicrotask()

注意:微任务优先级(Priority)高于宏任务

事件循环

持续检查调用栈是否为空,若空则从队列中取出任务执行


执行顺序示例分析

console.log("Start");

setTimeout(() => {
    console.log("定时器回调");
}, 0);

Promise.resolve().then(() => {
    console.log("Promise 已解析");
});

console.log("End");

输出结果:

Start
End
Promise 已解析
定时器回调

执行流程:

  1. console.log("Start") 同步执行 → 输出 "Start"

  2. setTimeout 被交给 Web API,其回调进入任务队列(宏任务)

  3. Promise.resolve().then() 的回调被放入微任务队列

  4. console.log("End") 同步执行 → 输出 "End"

  5. 同步代码执行完毕,调用栈清空

  6. 事件循环先处理微任务队列 → 输出 "Promise 已解析"

  7. 再处理任务队列 → 输出 "定时器回调"

关键规则微任务优先级高于宏任务,每次宏任务执行前后都会清空微任务队列。


常见问题

阻塞主线程

while (true) {
    console.log('阻塞中...');
}
  • 无限循环会持续占用调用栈,导致事件循环无法处理任何异步任务。

  • 结果:页面卡死、无响应。

解决方案:将耗时计算拆分为多个小任务,使用 setTimeoutqueueMicrotask 分片执行,或使用 Web Workers


setTimeout 不准时

console.log("开始");
setTimeout(() => console.log("定时器触发"), 1000);
for (let i = 0; i < 1e9; i++) {} // 耗时循环
console.log("结束");
  • 尽管设置了 1000ms 延迟,但只有当调用栈空闲时setTimeout 回调才能执行。

  • 如果主线程被长时间占用,回调会被延迟执行

提示setTimeout(fn, 0) 并非“立即执行”,而是“尽快在下一个宏任务中执行”。


微任务优先级更高

setTimeout(() => console.log("宏任务"), 0);
Promise.resolve().then(() => console.log("微任务"));
console.log("同步代码");

输出

同步代码
微任务
宏任务
  • 微任务总是在当前宏任务结束后、下一个宏任务开始前执行。

  • 即使 setTimeout 设为 0ms,也无法超越微任务。


回调地狱

setTimeout(() => {
    console.log("步骤 1");
    setTimeout(() => {
        console.log("步骤 2");
        setTimeout(() => {
            console.log("步骤 3");
        }, 1000);
    }, 1000);
}, 1000);
  • 多层嵌套导致代码难以阅读、调试和维护。

解决方案

  • 使用 Promise 链式调用

  • 使用 async/await 语法糖

async function runSteps() {
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log("步骤 1");
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log("步骤 2");
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log("步骤 3");
}

最佳实践

实践

说明

避免同步阻塞操作

fs.readFileSync、大数组遍历等,应改用异步版本

合理使用微任务

过度使用 Promise.then 可能延迟 UI 更新或其他宏任务

CPU 密集型任务交由 Worker

使用 Web Workers(浏览器)或子进程(Node.js)释放主线程

优先使用 async/await

比回调更清晰,比 Promise 链更简洁

监控性能

使用 Chrome DevTools 的 Performance 面板或 Node.js 的 perf_hooks 分析事件循环延迟

小技巧:在 Node.js 中,若需在 I/O 阶段后立即执行高优先级任务,可使用 setImmediate(),它比 setTimeout(fn, 0) 更快进入队列。


重点总结

  • JavaScript 是单线程,靠事件循环实现异步非阻塞。

  • 执行顺序:同步代码 → 微任务队列 → 宏任务队列

  • 微任务(如 Promise.then优先级高于宏任务(如 setTimeout)。

  • 阻塞调用栈会导致整个应用卡死,务必避免。

  • 使用 async/await 和 Promise 替代回调嵌套,提升代码可读性。

  • 利用开发者工具监控事件循环性能,及时发现瓶颈。


思考题

  1. 为什么 Promise.resolve().then() 总是在 setTimeout(..., 0) 之前执行?这背后的队列机制是什么?

  2. 如果在一个 Promise.then() 中又添加了新的 Promise.then(),这些微任务会如何执行?它们会阻塞下一个宏任务吗?

  3. 在浏览器中,requestAnimationFrame 属于宏任务还是微任务?它与事件循环的关系是怎样的?