揭秘 JavaScript 事件循环:面向开发人员的深度探索

39 阅读5分钟

JavaScript 通常被描述为单线程语言,这意味着它一次只能执行一个任务。这引出了一个根本问题:它如何处理网络请求、计时器或文件 I/O 等长时间运行的操作,而不会“冻结”整个应用程序?答案在于事件循环——一种巧妙的机制,它允许 JavaScript 执行非阻塞异步操作,从而产生并发的错觉。

无论您是构建前端 UI 还是 Node.js 后端,了解事件循环都是编写高效、响应迅速且可预测的 JavaScript 代码的关键。

核心组件:涉及什么? 在深入研究循环本身之前,让我们先了解一下主要参与者:

调用栈(执行栈): JavaScript 中用它来跟踪正在执行的函数。当一个函数被调用时,它会被压入栈中。当它返回时,它会被弹出。它遵循 LIFO(后进先出)原则。调用堆栈执行 Web API(浏览器)/ C++ API(Node.js):这些是由浏览器(例如setTimeout、DOM events、fetch)或 Node.js 运行时(例如fs.readFile、http.request)提供的功能。它们本身不属于 JavaScript 引擎,但允许 JS卸载耗时的任务。 Web API(浏览器)/ C++ API(Node.js)

回调队列(任务队列/宏任务队列):当异步操作(例如setTimeout回调、click事件处理程序或fetch响应处理程序)完成时,其回调函数将被移至此队列。它是一个 FIFO(先进先出)队列。 回调队列(任务队列/宏任务队列)

作业队列(微任务队列):随 Promises 引入,此队列优先级高于回调队列。来自 Promises(.then()、.catch()、.finally())、queueMicrotask()和 的回调MutationObserver都放置在此处。 作业队列(微任务队列)

事件循环本身:它是不知疲倦的仲裁者。它唯一的工作就是持续监控调用栈是否为空。如果调用栈为空,它会首先检查作业队列。如果有作业,它会将作业逐个移动到调用栈执行,直到作业队列为空。只有此时,它才会检查回调队列。如果有回调,它会将第一个回调移动到调用栈执行。此过程无限重复。 事件循环本身

事件循环实战:分步流程 setTimeout让我们用一个涉及承诺的常见场景来说明整个过程。

console.log('Start'); // 1

setTimeout(() => { console.log('setTimeout callback'); // 4 }, 0); // Scheduled with Web API

Promise.resolve().then(() => { console.log('Promise microtask'); // 3 });

console.log('End'); // 2 第一阶段:初始执行(同步代码) console.log('Start');被推送到调用堆栈,执行并弹出。输出:Start。 setTimeout遇到。它的回调被传递给 Web API(Timer)。setTimeout函数本身被弹出。 Promise.resolve().then(...)遇到。该承诺立即解决。其回调被放入任务队列。 console.log('End');被推送到调用堆栈,执行并弹出。输出:End。 此时,调用堆栈现在为空。 初始执​​行(同步代码)

第二阶段:事件循环启动 - 优先处理微任务 事件循环发现调用堆栈是空的。 它检查作业队列并找到Promise.resolve().then(...)回调。 此回调从作业队列移至调用堆栈。 console.log('Promise microtask');被执行并弹出。输出:Promise microtask。 调用栈再次为空。事件循环重新检查作业队列,发现它为空。 事件循环启动——优先处理微任务

阶段 3:处理宏任务 事件循环的工作只有在所有待处理的任务都被清除后才算完成。当作业队列清空后,事件循环才可以将注意力转向宏任务队列。

上述操作发生时,setTimeoutWeb API 中的 0ms 延迟已过期。其回调函数 ( () => { console.log('setTimeout callback'); }) 现已移至回调队列(宏任务队列)。 事件循环发现作业队列是空的。 它检查回调队列并找到setTimeout回调。 此回调从回调队列移至调用堆栈。 console.log('setTimeout callback');被执行并弹出。输出:setTimeout callback。 最终输出顺序: Start End Promise microtask(微任务完全在宏任务之前执行) setTimeout callback(宏任务每个循环运行一次) 为什么微任务具有优先级:饥饿 核心要点是,在事件循环移至回调队列(宏任务)之前,作业队列(微任务)已完全处理完毕。这对于可预测性和数据一致性至关重要。

如果您使用Promise ( .then()) 来处理从网络请求加载的数据,您希望该处理程序尽快运行,理想情况下是在浏览器渲染、绘制或处理用户交互(通常是宏任务)之前运行。这可确保应用程序状态以高优先级更新。

然而,这种优先级可能会导致饥饿。如果开发人员将无限循环的微任务(例如,递归的 Promise 链)放入作业队列,事件循环将卡在为作业队列提供服务,并且永远无法到达回调队列,从而有效地阻止所有 I/O、渲染和用户输入。

特征 队列类型 优先事项 示例 微任务 作业队列 高(完全清除) 承诺(.then,.catch)queueMicrotask,,MutationObserver 宏任务 回调队列 低(每个循环一次) setTimeout、、回调、DOM 事件setInterval(、)fetchclickload 结论:掌握异步 JS 事件循环将单线程 JavaScript 转变为强大的非阻塞语言。通过将耗时的操作卸载到 Web API,并通过调用堆栈、作业队列和回调队列协调执行顺序,浏览器可以保持流畅、响应迅速的用户体验。

作为开发人员,保持调用堆栈为空并尊重宏任务/微任务优先级系统是编写干净、高性能异步 JavaScript 的秘诀。作者www.mjsyxx.com