JavaScript

JS事件循环机制

8490字 约43min
840阅读

前言

在快节奏的开发任务中,我们越来越依赖各种基于 JavaScript 的框架。框架确实能快速提高开发速度,但我们不能只停留在对框架和语言的基础应用上。写完越来越多的 bug 之后,会慢慢体会到:了解底层原理对一个程序员来说非常必要。掌握底层原理之后,我们才能站在更高的维度,更快速、更精准、更合理地解决工作学习中遇到的问题。本文将从 JavaScript 的基础运行机制出发,一起整理探讨 JavaScript 的事件循环机制(Event Loop)。

JavaScript 在设计之初就是单线程语言。遇到耗时操作时,我们会把需要耗时的异步操作交给宿主环境处理,等合适的时机再把回调放入任务队列,等待 JavaScript 空闲时执行。这种“任务队列 + 事件循环”的方式,就是 JavaScript 用来实现单线程异步编程的事件驱动机制(event-driven)。在理解 JavaScript 的事件循环机制之前,需要先了解一些核心概念:

一、核心概念

理解事件循环,先建立一张运行时全景图:JS 引擎包含堆和调用栈;宿主环境提供 Web API / Node API、宏任务队列、微任务队列、rAF 回调列表、事件循环和渲染机会。在这张全景图里,有一条贯穿全文的主线:主线程负责执行,宿主环境负责提供能力和调度。主线程负责执行同步 JS、DOM 操作、事件回调、微任务、rAF,并参与渲染;宿主环境负责计时、网络、排队和调度。核心一句话:主线程执行,宿主环境提供能力和队列,事件循环负责调度。本章以浏览器为主,Node.js 差异只做标注。

在深入每个概念之前,我们先理清几个核心角色的职责和关系:

  • 宿主环境:平台,负责提供 Web API、任务队列、事件循环,并创建主线程。浏览器和 Node.js 都是宿主环境。
  • 主线程:宿主环境提供的执行场所,同一时间只能执行一个任务。负责执行 JS、处理 DOM、响应用户交互并参与渲染。
  • JS 引擎:运行在主线程上的代码执行器,提供堆和调用栈。它只负责执行代码,不负责调度和宿主能力。
  • 调用栈:主线程中管理函数调用的数据结构,后进先出。同步代码在这里执行。
  • 堆:JS 引擎的内存区域,存放对象、函数、数组等引用类型数据。
  • 任务队列:宿主环境维护的队列,存放异步回调的引用。分为宏任务队列和微任务队列。
  • 事件循环:宿主环境提供的调度机制,负责从队列中取出回调,交给主线程的调用栈执行,并协调渲染时机。
  • 宏任务 / 微任务:异步回调的分类。微任务优先级高于下一个宏任务,且都在主线程的调用栈执行,区别只在调度时机。

用一句话概括它们的关系:宿主环境提供平台、能力和队列;主线程负责执行;JS 引擎提供堆和调用栈;事件循环负责调度;渲染由浏览器在特定时机进行。

1. 主线程

JavaScript 通常被称为单线程语言,主要指主线程同一时间只能执行一个任务。主线程负责:执行 JavaScript 代码、处理 DOM 操作、处理用户交互事件、参与浏览器渲染流程。注意:主线程不等于调用栈,调用栈只是主线程中管理函数调用的数据结构;主线程也不等于事件循环,事件循环是宿主环境提供的调度机制,不是线程。

2. 堆(Heap)

堆用来存放对象、函数、数组等引用类型数据。变量本身保存在栈或作用域中,变量里保存的是对堆中对象的引用。

3. 调用栈(Call Stack)

调用栈管理函数调用关系,后进先出(LIFO)。同步代码在调用栈中依次执行。调用栈清空,表示当前宏任务的同步代码执行完毕,但不代表所有任务结束,还要看微任务队列。

4. 宿主环境 / Web API / Node API

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 引擎自己执行的,而是这样的流程:

  1. 调用 setTimeout 时,是把回调 fn 交给宿主环境。
  2. 宿主环境负责计时,JS 引擎继续往下执行。
  3. 时间到了以后,宿主环境把 fn 的引用放入宏任务队列。
  4. 事件循环在合适时机把它取回主线程,由 JS 引擎推入调用栈执行。

所以记住一句话:JS 引擎提供语言能力,宿主环境提供 API、计时、排队和调度。

5. 任务队列(Task Queue)

任务队列存放异步回调,分为宏任务队列和微任务队列。每个队列内部通常是先进先出(FIFO),但浏览器可能维护多个任务队列,不同任务源之间的执行顺序由宿主环境调度。注意:宏任务和微任务不是同一个队列,微任务优先级高于下一个宏任务。并且,队列里存放的是回调函数的引用,不是函数调用本身。

6. 事件循环(Event Loop)

事件循环是宿主环境提供的调度机制,协调调用栈、任务队列、微任务队列和渲染,它不是线程。核心顺序:执行当前宏任务 → 同步代码执行完、调用栈清空 → 清空微任务队列 → 如果浏览器决定渲染,执行requestAnimationFrame回调并进行样式计算、布局、绘制、合成 → 取出下一个宏任务 → 再清空微任务队列,循环往复。

口诀:宏任务 → 清空微任务 →(可能 rAF + 渲染)→ 下一个宏任务。关键:每执行完一个宏任务,必须清空微任务队列直到为空,然后才进入下一个宏任务;微任务中产生的新微任务也在当前清空阶段继续执行,直到队列为空。

7. 宏任务(Macro Task)

常见宏任务:初始 script 整体代码、setTimeout、setInterval、I/O、网络回调、UI 事件、postMessage、MessageChannel;Node.js 额外有 setImmediate、close callbacks。注意:UI Rendering 不是宏任务;requestAnimationFrame也不是普通宏任务,它属于渲染前回调。

8. 微任务(Micro Task)

常见微任务: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 之后的代码不会同步执行,而是进入微任务。

9. requestAnimationFrame(渲染前回调)

requestAnimationFrame 是浏览器提供的 API,用于在下一次重绘之前执行回调。它既不属于宏任务,也不属于微任务,而是渲染前回调。执行时机: 当前宏任务执行完、微任务队列清空后,并且浏览器决定渲染,且你注册了 rAF 回调时,才会在样式计算、布局、绘制之前执行。顺序:宏任务 → 微任务(清空)→ requestAnimationFrame 回调 → 样式计算 → 布局 → 绘制。注意:如果不调用 requestAnimationFrame,即使浏览器渲染,也不会有 rAF 回调。由此可以看出requestAnimationFrame非常适合做动画,因为它能保证回调在渲染前执行,从而让 DOM 修改和渲染在同一帧内完成,避免不必要的重排和重绘。

10. UI Rendering(渲染)

渲染是浏览器特有流程,不是宏任务也不是微任务,包括样式计算、布局、绘制、合成等。通常发生在当前宏任务执行完、微任务清空后、rAF回调执行后,但浏览器不保证每轮事件循环都渲染,是否渲染由屏幕刷新率、页面性能、页面可见性、是否有视觉变化、rAF 回调列表是否为空等决定。相对顺序是固定的:渲染发生在一个宏任务及其所有微任务执行完之后;如果有 rAF 回调,它们在渲染之前执行。主线程主要参与样式计算、布局和部分绘制,合成等可能由其他线程完成。 另外,如果微任务一直无法清空(比如微任务饥饿),浏览器同样无法进入渲染阶段。

11. 核心概念关系图

事件循环运行时全景图

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

一句话回顾主线:主线程负责执行,宿主环境负责提供能力和队列,事件循环负责调度;宏任务和微任务的区别只在调度时机,不在执行位置。

二、事件循环基本流程

1、事件循环图解

事件循环流程

事件循环可以简化为:宏任务 → 清空微任务 →(可能 rAF + 渲染)→ 下一个宏任务。完整流程如下:

  1. 执行当前宏任务。(初始时,整个 script 代码就是一个宏任务)
  2. 调用栈中执行同步代码 ,直到调用栈清空。
  3. 清空微任务队列。执行微任务过程中如果又产生新的微任务,也要继续执行,直到微任务队列彻底清空。
  4. 浏览器可能进行渲染。不是每次事件循环都会渲染;如果浏览器决定渲染,且有已注册的 requestAnimationFrame 回调,才会在渲染前执行。
  5. 从宏任务队列中取出下一个宏任务,执行。
  6. 当前宏任务执行完后,再次清空微任务队列。
  7. 循环往复。

关键点:

  • 同步代码优先。
  • 微任务优先于下一个宏任务。
  • 调用栈清空宏任务中的同步代码后,立刻清空微任务队列,然后再进入下一个宏任务。
  • 微任务中产生的新微任务,继续在当前清空阶段执行。
  • requestAnimationFrame 和渲染不是每轮都有。

2、事件循环过程示例详解

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

用一句话就是:先同步,再微任务,再宏任务;每个宏任务结束后,都要清空一次微任务队列。

3、 从任务队列到调用栈详细过程

前面我们讲了事件循环的宏观流程,这里再向下深挖一下 ,看看一个回调从任务队列被取出之后,到真正执行,中间到底发生了什么。

当事件循环发现调用栈空了,就会从任务队列里取出这个回调引用,交给主线程。主线程根据这个引用找到堆里的函数代码,同时找到它定义时捕获的词法环境(如果是闭包的话),然后把函数代码和它需要的环境一起打包成一个“执行上下文(Execution Context)”。接着,主线程把这个执行上下文推入调用栈,调用栈开始执行其中的代码。执行完毕后,执行上下文出栈,调用栈清空,事件循环再去处理下一个任务。

所以总结一下就是:任务队列存引用 → 事件循环取出并交给主线程 → 主线程打包执行上下文 → 推入调用栈执行。调用栈是主线程的执行场所,主线程是干活的人,事件循环是派活的调度员。无论是宏任务还是微任务,最终的执行位置都一样,都是主线程的调用栈;它们的区别仅仅在于调度时机。微任务在当前宏任务结束后立刻被清空执行,宏任务则要等微任务清空、可能渲染之后才被取出执行。

三、高频输出题与易错点

1. for 循环与闭包

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。

2. async/await 与微任务

async 函数执行时,会同步执行到第一个 await。await 后面的代码相当于 .then 回调,会进入微任务队列。所以 1 同步打印,遇到 await 后,2 进入微任务;3 和 4 是同步代码先执行;同步代码结束后,清空微任务,打印 2。

所以这句话很重要: await 后面的代码相当于 .then 回调,会进入微任务队列 。await null 也会让出执行权,后面的代码进入微任务。

3.async/await + setTimeout + Promise混合题

同步代码打印 4、1、3、6、8;微任务队列中,先入队的是 await async2() 让出的 console.log('2'),接着是 .then 的 console.log('7')。由于微任务队列是先进先出,所以依次打印 2、7。最后执行宏任务 setTimeout 打印 5。

4. 微任务中产生微任务

微任务中产生的新微任务,会在当前清空阶段继续执行,直到微任务队列为空。不会等到下一个宏任务。上面的例子中,micro2 虽然是在 micro1 内部产生的,但它依然在 timeout 之前执行,说明它被追加到了当前微任务队列中,并优先于下一个宏任务。

5. 微任务饥饿

'微任务饥饿'是一个专业术语,来源于计算机科学中的'饥饿'(Starvation)概念,指某类任务因为自身不断产生新任务,导致队列永远无法清空,从而永久抢占执行权,让其他任务永远得不到执行。

上面这段代码就是一个典型例子。每次执行 loop 时,它都会调用 Promise.resolve().then(loop),相当于在微任务队列中又添加了一个新的微任务。 事件循环的规则是:必须清空微任务队列,直到为空,才能进入下一个阶段。在此期间,这些微任务也会不断推入调用栈执行,但由于队列无法清空,调用栈会一直被占用。 由于这个队列永远无法为空,事件循环就会永远卡在“清空微任务”这一步。 后果就是:页面卡死。因为事件循环卡在微任务阶段,永远没有机会进入下一个步骤去执行 requestAnimationFrame、样式计算、渲染,也永远无法取出下一个宏任务(setTimeout、用户点击事件等)。这种代码在前端项目中是绝对要避免的。

6. setTimeout(fn, 0) 不是立即执行

setTimeout(fn, 0) 表示尽快把回调放入宏任务队列,而不是立刻执行。它仍然要等待: 执行完当前同步代码→ 清空当前微任务队列→结束当前宏任务→最后再取出setTimeout(fn, 0)宏任务执行。 所以 setTimeout(fn, 0) 只是“尽快排队”,不是“立即执行”。另外,即使定时器已经到期,回调也只是进入宏任务队列,具体什么时候执行还要看前面排队的任务。浏览器对嵌套定时器有最小延迟限制,嵌套层级较深时通常至少延迟 4ms;后台标签页还会对定时器进行节流,因此它的实际执行时间可能比预期更晚。

7. Promise executor 是同步的

console.log('同步执行') 是同步代码,会立刻执行。.then / .catch / .finally 回调才是微任务。

8. process.nextTick 不是普通微任务

在 Node.js 中,process.nextTick 有独立队列,优先级高于 Promise 微任务。每执行完一个阶段,Node.js 会先清空 nextTick 队列,再清空 Promise 微任务队列。

9. 浏览器和 Node.js 的事件循环不同

浏览器事件循环主要遵循 HTML 规范,核心是宏任务、微任务、渲染时机。Node.js 事件循环分为多个阶段:timers、pending callbacks、idle/prepare、poll、check、close callbacks。每个阶段之间会清空 nextTick 队列和微任务队列。所以学习事件循环时,要区分浏览器环境和 Node.js 环境。

四、总结

JavaScript 的事件循环机制可以概括为:

  1. 执行当前宏任务,初始时是整体 script。
  2. 同步代码在调用栈中执行,调用栈清空。
  3. 当前宏任务的同步代码执行完、调用栈清空后,立刻清空微任务队列,直到为空。
  4. 浏览器可能进行渲染;如果有已注册的 requestAnimationFrame 回调,会在渲染前执行。
  5. 从宏任务队列取下一个宏任务执行。
  6. 再清空微任务队列,再取下一个宏任务,循环往复。

重要记忆点:

  • 同步代码优先。
  • 微任务优先于下一个宏任务。
  • 每个宏任务中的同步代码执行完、调用栈清空后,立刻清空微任务队列直到为空,然后才进入下一个宏任务。
  • 任务队列里存的是回调函数的引用,不是函数调用本身;事件循环取出引用后交给主线程,主线程找到函数代码和词法环境,打包成执行上下文,再推入调用栈执行。
  • 无论宏任务还是微任务,最终都在主线程的调用栈执行;区别只在调度时机,不在执行位置。
  • new Promise 的 executor 是同步执行的。
  • .then / .catch / .finally 的回调才是微任务。
  • Promise.resolve().then(...).then(...) 不是在一个微任务里执行的,每个 .then 都会产生一个新的微任务。
  • setTimeout(fn, 0) 不是立即执行,只是尽快进入宏任务队列。
  • async/await 中,await 后面的代码相当于 .then 回调,进入微任务。
  • var 在 for 循环中共享同一个变量,let 每轮迭代创建新绑定。
  • process.nextTick 在 Node.js 中优先级高于 Promise 微任务。
  • UI Rendering 不是宏任务,渲染发生在特定时机。
  • requestAnimationFrame 不是宏任务也不是微任务,且不是每轮都有。
  • 微任务中不断产生新微任务,会导致微任务队列永远无法清空,阻塞渲染和后续宏任务,这叫“微任务饥饿”。
  • 如果一个宏任务执行时间过长,浏览器无法进入渲染阶段,会导致页面掉帧、卡顿,这也是长任务需要拆分的原因。
  • 一句话区分宏任务和微任务:宏任务是由宿主环境发起的(如 setTimeout、UI 事件),微任务是语言本身发起的(如 Promise.then、queueMicrotask)。微任务优先于下一个宏任务,且共享同一个调用栈。

一句话总结:宿主环境是平台,负责创建主线程、提供 Web API、任务队列和事件循环调度;主线程是执行场所,负责执行 JS 代码、参与渲染;JS 引擎运行在主线程上,负责堆和调用栈。 每个宏任务中的同步代码执行完,立刻清空微任务队列直到为空,然后可能执行 rAF 并渲染,再进入下一个宏任务。

野生小园猿
励志做一只遨游在知识海洋里的小白鲨
查看“野生小园猿”的所有文章 →

相关推荐