在快节奏的开发任务中,我们越来越依赖各种基于 JavaScript 的框架。框架确实能快速提高开发速度,但我们不能只停留在对框架和语言的基础应用上。写完越来越多的 bug 之后,会慢慢体会到:了解底层原理对一个程序员来说非常必要。掌握底层原理之后,我们才能站在更高的维度,更快速、更精准、更合理地解决工作学习中遇到的问题。本文将从 JavaScript 的基础运行机制出发,一起整理探讨 JavaScript 的事件循环机制(Event Loop)。
JavaScript 在设计之初就是单线程语言。遇到耗时操作时,我们会把需要耗时的异步操作交给宿主环境处理,等合适的时机再把回调放入任务队列,等待 JavaScript 空闲时执行。这种“任务队列 + 事件循环”的方式,就是 JavaScript 用来实现单线程异步编程的事件驱动机制(event-driven)。在理解 JavaScript 的事件循环机制之前,需要先了解一些核心概念:
理解事件循环,先建立一张运行时全景图:JS 引擎包含堆和调用栈;宿主环境提供 Web API / Node API、宏任务队列、微任务队列、rAF 回调列表、事件循环和渲染机会。在这张全景图里,有一条贯穿全文的主线:主线程负责执行,宿主环境负责提供能力和调度。主线程负责执行同步 JS、DOM 操作、事件回调、微任务、rAF,并参与渲染;宿主环境负责计时、网络、排队和调度。核心一句话:主线程执行,宿主环境提供能力和队列,事件循环负责调度。本章以浏览器为主,Node.js 差异只做标注。
在深入每个概念之前,我们先理清几个核心角色的职责和关系:
用一句话概括它们的关系:宿主环境提供平台、能力和队列;主线程负责执行;JS 引擎提供堆和调用栈;事件循环负责调度;渲染由浏览器在特定时机进行。
JavaScript 通常被称为单线程语言,主要指主线程同一时间只能执行一个任务。主线程负责:执行 JavaScript 代码、处理 DOM 操作、处理用户交互事件、参与浏览器渲染流程。注意:主线程不等于调用栈,调用栈只是主线程中管理函数调用的数据结构;主线程也不等于事件循环,事件循环是宿主环境提供的调度机制,不是线程。
堆用来存放对象、函数、数组等引用类型数据。变量本身保存在栈或作用域中,变量里保存的是对堆中对象的引用。
调用栈管理函数调用关系,后进先出(LIFO)。同步代码在调用栈中依次执行。调用栈清空,表示当前宏任务的同步代码执行完毕,但不代表所有任务结束,还要看微任务队列。
|
1 2 3 4 |
function a() { b(); } function b() { console.log('b'); } a(); // a 入栈 → b 入栈 → b 出栈 → a 出栈 |
JS 引擎只提供语言本身的能力,比如语法、变量、函数、对象、Promise。但浏览器里很多东西并不是 JavaScript 语言自带的,而是宿主环境额外提供的。打个比方:JS 引擎是“语言本身”,宿主环境是“运行这个语言的平台”。浏览器和 Node.js 都是宿主环境,它们提供了大量 JS 语言本身没有的能力。
浏览器提供的叫 Web API,常见的有:setTimeout、setInterval、DOM 事件、fetch、XMLHttpRequest、requestAnimationFrame。Node.js 提供的叫 Node API,常见的有:文件系统、网络、process.nextTick。
以 setTimeout(fn, 0) 为例,它并不是 JS 引擎自己执行的,而是这样的流程:
所以记住一句话:JS 引擎提供语言能力,宿主环境提供 API、计时、排队和调度。
任务队列存放异步回调,分为宏任务队列和微任务队列。每个队列内部通常是先进先出(FIFO),但浏览器可能维护多个任务队列,不同任务源之间的执行顺序由宿主环境调度。注意:宏任务和微任务不是同一个队列,微任务优先级高于下一个宏任务。并且,队列里存放的是回调函数的引用,不是函数调用本身。
事件循环是宿主环境提供的调度机制,协调调用栈、任务队列、微任务队列和渲染,它不是线程。核心顺序:执行当前宏任务 → 同步代码执行完、调用栈清空 → 清空微任务队列 → 如果浏览器决定渲染,执行requestAnimationFrame回调并进行样式计算、布局、绘制、合成 → 取出下一个宏任务 → 再清空微任务队列,循环往复。
口诀:宏任务 → 清空微任务 →(可能 rAF + 渲染)→ 下一个宏任务。关键:每执行完一个宏任务,必须清空微任务队列直到为空,然后才进入下一个宏任务;微任务中产生的新微任务也在当前清空阶段继续执行,直到队列为空。
常见宏任务:初始 script 整体代码、setTimeout、setInterval、I/O、网络回调、UI 事件、postMessage、MessageChannel;Node.js 额外有 setImmediate、close callbacks。注意:UI Rendering 不是宏任务;requestAnimationFrame也不是普通宏任务,它属于渲染前回调。
常见微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver;Node.js 的process.nextTick有独立队列,优先级高于 Promise 微任务。特别注意:new Promise(executor) 中的 executor 是同步执行的,真正进入微任务队列的是 .then / .catch / .finally 的回调;resolve() 不会立刻执行 .then,只是把状态改为 fulfilled,并把已注册的 .then 回调安排进微任务队列。注意:async/await 中,await 后面的代码相当于 .then 回调,会进入微任务队列。例如 await null 之后的代码不会同步执行,而是进入微任务。
|
1 2 3 4 5 6 7 8 9 |
console.log('start'); new Promise((resolve) => { console.log('executor 同步执行'); resolve(); }).then(() => { console.log('then 回调进入微任务'); }); console.log('end'); // start → executor 同步执行 → end → then 回调进入微任务 |
requestAnimationFrame 是浏览器提供的 API,用于在下一次重绘之前执行回调。它既不属于宏任务,也不属于微任务,而是渲染前回调。执行时机: 当前宏任务执行完、微任务队列清空后,并且浏览器决定渲染,且你注册了 rAF 回调时,才会在样式计算、布局、绘制之前执行。顺序:宏任务 → 微任务(清空)→ requestAnimationFrame 回调 → 样式计算 → 布局 → 绘制。注意:如果不调用 requestAnimationFrame,即使浏览器渲染,也不会有 rAF 回调。由此可以看出requestAnimationFrame非常适合做动画,因为它能保证回调在渲染前执行,从而让 DOM 修改和渲染在同一帧内完成,避免不必要的重排和重绘。
渲染是浏览器特有流程,不是宏任务也不是微任务,包括样式计算、布局、绘制、合成等。通常发生在当前宏任务执行完、微任务清空后、rAF回调执行后,但浏览器不保证每轮事件循环都渲染,是否渲染由屏幕刷新率、页面性能、页面可见性、是否有视觉变化、rAF 回调列表是否为空等决定。相对顺序是固定的:渲染发生在一个宏任务及其所有微任务执行完之后;如果有 rAF 回调,它们在渲染之前执行。主线程主要参与样式计算、布局和部分绘制,合成等可能由其他线程完成。 另外,如果微任务一直无法清空(比如微任务饥饿),浏览器同样无法进入渲染阶段。

这张图是“事件循环运行时全景图 / JS 引擎与宿主环境协作关系图”,运行时分为 JavaScript 引擎和宿主环境两大块。引擎提供堆和调用栈;宿主环境提供 Web API、任务队列、rAF 回调列表、渲染机会和事件循环,并创建主线程。主线程是宿主环境提供的执行场所,承载 JS 执行、事件循环调度和渲染,是连接引擎和宿主环境的桥梁。核心一句话:主线程执行,宿主环境提供能力和队列,事件循环负责调度。
一句话回顾主线:主线程负责执行,宿主环境负责提供能力和队列,事件循环负责调度;宏任务和微任务的区别只在调度时机,不在执行位置。

事件循环可以简化为:宏任务 → 清空微任务 →(可能 rAF + 渲染)→ 下一个宏任务。完整流程如下:
关键点:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
setTimeout(() => { console.log('A: timeout1'); new Promise((resolve) => { console.log('B: promise in timeout1'); setTimeout(() => { console.log('C: timeout2'); }, 0); resolve(); }).then(() => { console.log('D: then in timeout1'); }); }, 0); console.log('E: script start'); setTimeout(() => { console.log('F: timeout3'); }, 0); new Promise((resolve) => { console.log('G: promise in script'); resolve(); }).then(() => { console.log('H: then in script'); }); console.log('I: script end'); // E: script start // G: promise in script // I: script end // H: then in script // A: timeout1 // B: promise in timeout1 // D: then in timeout1 // F: timeout3 // C: timeout2 |
step1:执行初始 script 宏任务。1.遇到第一个 setTimeout,注册定时器,回调进入宏任务队列。2.打印 E: script start。3.遇到第二个 setTimeout,注册定时器,回调进入宏任务队列。4.遇到 new Promise,executor 同步执行,打印 G: promise in script;resolve() 后,.then 回调进入微任务队列。5.打印 I: script end。
step2:清空微任务队列。微任务队列中有 .then 回调,执行它,打印:H: then in script
step3 :执行第一个宏任务(第一个 setTimeout 回调)。1.打印 A: timeout1。2.遇到 new Promise,executor 同步执行,打印 B: promise in timeout1。3.内部又注册一个 setTimeout,回调进入宏任务队列。4.resolve() 后,.then 回调进入微任务队列。5.当前宏任务同步代码执行完,清空微任务队列,打印 D: then in timeout1。
step4:执行第二个宏任务(第二个 setTimeout 回调)。打印:F: timeout3
step5:执行第三个宏任务(第一个 setTimeout 内部新注册的定时器回调)。打印:C: timeout2
用一句话就是:先同步,再微任务,再宏任务;每个宏任务结束后,都要清空一次微任务队列。
前面我们讲了事件循环的宏观流程,这里再向下深挖一下 ,看看一个回调从任务队列被取出之后,到真正执行,中间到底发生了什么。
当事件循环发现调用栈空了,就会从任务队列里取出这个回调引用,交给主线程。主线程根据这个引用找到堆里的函数代码,同时找到它定义时捕获的词法环境(如果是闭包的话),然后把函数代码和它需要的环境一起打包成一个“执行上下文(Execution Context)”。接着,主线程把这个执行上下文推入调用栈,调用栈开始执行其中的代码。执行完毕后,执行上下文出栈,调用栈清空,事件循环再去处理下一个任务。
所以总结一下就是:任务队列存引用 → 事件循环取出并交给主线程 → 主线程打包执行上下文 → 推入调用栈执行。调用栈是主线程的执行场所,主线程是干活的人,事件循环是派活的调度员。无论是宏任务还是微任务,最终的执行位置都一样,都是主线程的调用栈;它们的区别仅仅在于调度时机。微任务在当前宏任务结束后立刻被清空执行,宏任务则要等微任务清空、可能渲染之后才被取出执行。
|
1 2 3 4 5 6 7 8 9 10 11 |
for (var i = 0; i < 10; i++) { setTimeout(() => { console.log(i); // 打印 10 次 10 }, 0); } for (let i = 0; i < 10; i++) { setTimeout(() => { console.log(i); // 依次打印 0~9 }, 0); } |
var 声明的变量会提升到当前函数作用域或全局作用域顶部,没有块级作用域,所以整个 for 循环只有一个 i。每次循环只是注册一个定时器回调,这些回调都形成闭包,并且引用的是同一个 i。当同步代码执行完 for 循环后,i 已经变成 10。之后事件循环执行这些回调时,读取到的都是同一个 i 的最终值,所以打印 10 次 10。
let 在 for 循环头部具有块级作用域,并且 for (let i = ...) 会为每一轮迭代创建新的 i 绑定。每次注册的定时器回调都捕获了那一轮迭代自己的 i,而不是共享同一个变量。因此后续回调执行时,分别读取各自捕获的 i,所以打印 0~9。
一句话总结:var 时所有回调共享同一个 i;let 时每轮迭代产生新的 i 绑定,回调各自捕获对应的 i。
|
1 2 3 4 5 6 7 8 9 10 |
async function foo() { console.log('1'); await Promise.resolve(); console.log('2'); } console.log('3'); foo(); console.log('4'); // 3 → 1 → 4 → 2 |
async 函数执行时,会同步执行到第一个 await。await 后面的代码相当于 .then 回调,会进入微任务队列。所以 1 同步打印,遇到 await 后,2 进入微任务;3 和 4 是同步代码先执行;同步代码结束后,清空微任务,打印 2。
|
1 2 3 4 5 6 7 8 9 |
async function foo() { console.log('a'); await null; console.log('b'); } foo(); console.log('c'); // a → c → b |
所以这句话很重要: await 后面的代码相当于 .then 回调,会进入微任务队列 。await null 也会让出执行权,后面的代码进入微任务。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function async1() { console.log('1'); await async2(); console.log('2'); } async function async2() { console.log('3'); } console.log('4'); setTimeout(() => { console.log('5'); }, 0); async1(); new Promise((resolve) => { console.log('6'); resolve(); }).then(() => { console.log('7'); }); console.log('8'); // 输出顺序:4 → 1 → 3 → 6 → 8 → 2 → 7 → 5 |
同步代码打印 4、1、3、6、8;微任务队列中,先入队的是 await async2() 让出的 console.log('2'),接着是 .then 的 console.log('7')。由于微任务队列是先进先出,所以依次打印 2、7。最后执行宏任务 setTimeout 打印 5。
|
1 2 3 4 5 6 7 8 9 10 11 |
setTimeout(() => { console.log('timeout'); }, 0); Promise.resolve().then(() => { console.log('micro1'); Promise.resolve().then(() => { console.log('micro2'); }); }); console.log('sync'); // sync → micro1 → micro2 → timeout |
微任务中产生的新微任务,会在当前清空阶段继续执行,直到微任务队列为空。不会等到下一个宏任务。上面的例子中,micro2 虽然是在 micro1 内部产生的,但它依然在 timeout 之前执行,说明它被追加到了当前微任务队列中,并优先于下一个宏任务。
'微任务饥饿'是一个专业术语,来源于计算机科学中的'饥饿'(Starvation)概念,指某类任务因为自身不断产生新任务,导致队列永远无法清空,从而永久抢占执行权,让其他任务永远得不到执行。
|
1 2 3 4 |
function loop() { Promise.resolve().then(loop); } loop(); |
上面这段代码就是一个典型例子。每次执行 loop 时,它都会调用 Promise.resolve().then(loop),相当于在微任务队列中又添加了一个新的微任务。 事件循环的规则是:必须清空微任务队列,直到为空,才能进入下一个阶段。在此期间,这些微任务也会不断推入调用栈执行,但由于队列无法清空,调用栈会一直被占用。 由于这个队列永远无法为空,事件循环就会永远卡在“清空微任务”这一步。 后果就是:页面卡死。因为事件循环卡在微任务阶段,永远没有机会进入下一个步骤去执行 requestAnimationFrame、样式计算、渲染,也永远无法取出下一个宏任务(setTimeout、用户点击事件等)。这种代码在前端项目中是绝对要避免的。
setTimeout(fn, 0) 表示尽快把回调放入宏任务队列,而不是立刻执行。它仍然要等待: 执行完当前同步代码→ 清空当前微任务队列→结束当前宏任务→最后再取出setTimeout(fn, 0)宏任务执行。 所以 setTimeout(fn, 0) 只是“尽快排队”,不是“立即执行”。另外,即使定时器已经到期,回调也只是进入宏任务队列,具体什么时候执行还要看前面排队的任务。浏览器对嵌套定时器有最小延迟限制,嵌套层级较深时通常至少延迟 4ms;后台标签页还会对定时器进行节流,因此它的实际执行时间可能比预期更晚。
|
1 2 3 4 5 6 7 |
new Promise((resolve) => { console.log('同步执行'); resolve(); }).then(() => { console.log('微任务'); }); // 同步执行 → 微任务 |
console.log('同步执行') 是同步代码,会立刻执行。.then / .catch / .finally 回调才是微任务。
在 Node.js 中,process.nextTick 有独立队列,优先级高于 Promise 微任务。每执行完一个阶段,Node.js 会先清空 nextTick 队列,再清空 Promise 微任务队列。
浏览器事件循环主要遵循 HTML 规范,核心是宏任务、微任务、渲染时机。Node.js 事件循环分为多个阶段:timers、pending callbacks、idle/prepare、poll、check、close callbacks。每个阶段之间会清空 nextTick 队列和微任务队列。所以学习事件循环时,要区分浏览器环境和 Node.js 环境。
JavaScript 的事件循环机制可以概括为:
重要记忆点:
一句话总结:宿主环境是平台,负责创建主线程、提供 Web API、任务队列和事件循环调度;主线程是执行场所,负责执行 JS 代码、参与渲染;JS 引擎运行在主线程上,负责堆和调用栈。 每个宏任务中的同步代码执行完,立刻清空微任务队列直到为空,然后可能执行 rAF 并渲染,再进入下一个宏任务。