下面内容写于 2022 年,文本描述过多,可能不适合有经验的人看。新的文章在 个人网站 中。
对了,说到事件循环,怎么可以离开这个最知名的视频呢!视频是英文的,但即使你听不懂,单纯看他的操作,我相信你也能够对事件循环有了清晰的认识!
🍕 涉及到的概念
- 事件循环 (
Event Loop) 宏任务(macrotask)宏任务队列(macrotask queue)。在 WHATWG specification 中被简单地称为 task queue。1微任务(microtask)微任务队列(microtask queue)执行栈, 或调用栈 (Call Stack)任务队列(Task Queue, Event Queue), 我这里将宏任务队列和微任务队列统称为任务队列- 异步编程 (asynchronous programming)
- 单线程 (single-thread)
🍕 通过一段代码, 来简单认识 Event Loop
我这里先做定义一个 异步任务源, 任务源可以分配任务, 异步任务源 就是专门分配 异步任务 的。这个想法来自 task-source | WHATWG。我这里不对定义做过多说明,直接看我的例子, 你应该就明白我想表达什么了:
比如 setTimeout(callback) 中的 setTimeout 是一个 异步任务源, 它的参数 callback 就是一个 异步任务, setTimeout 本身的执行是同步的, 只不过在它执行的过程中, 它创建一个 异步任务, 这个 异步任务 不会立马执行。
function test () {
console.log('1') // f1
setTimeout(fn, delay/* 假设是500ms */) // f2
console.log('2') // f3
}
;(function main () {
test()
})()
我专门画了一张图, 来简单描述一下过程

Event Loop
- 首先,
main()会进入执行栈, 然后执行栈会自上而下执行该函数中的各条语句 - 在执行语句的过程中, 如果遇到形如同步函数, 比如
test(), 那么它会先等待其执行完毕, 也就是会将test()压入栈, 然后继续执行test()中的各条语句 - 在执行
f1时,执行栈又遇到了同步函数console.log, 于是它继续先等待其执行完毕, 也就是会先等屏幕输出1, 然后才继续开始下一条语句 - 接着,
执行栈遇到了异步任务源, 也就是setTimeout, 于是他会将setTimeout分配的异步任务(包含 fn 和 delay) 送到某个区域, 这个区域我们先称其为 异步模块 (在浏览器环境中叫 APIs), 然后执行栈就会继续执行下一条语句了。注意此时 fn 并没有执行。
APIs的执行由浏览器单独负责, 他和 JS 的单线程没有关系。记住: JS 是单线程的, 但浏览器是多线程的。 2
执行栈接着执行, 然后会输出2, 此时主函数执行完,执行栈中没有其他的任务需要执行了。- 当
执行栈为空时, 会让Event Loop从一个区域中取出新的任务执行, 这个区域我们称之为任务队列,任务队列中的任务都是等待程序处理的任务, 这些任务的来源就是我们刚刚提到的 异步模块。因为此时时间还没有过去太久(不足500ms), 所以任务队列为空,执行栈也为空 - 异步模块 每过一段时间就会查看一下它维护的那些
异步任务, 经过大约 500ms(实际数值肯定大于500ms) 后, 异步模块 发现有一个异步任务(fn) 可以执行了, 于是将这个 fn 发送给任务队列, 此时任务队列不为空 执行栈为空, 并且任务队列有任务在等待执行, 于是Event Loop从中取出任务(fn), 并发送到执行栈中执行, 于是执行栈继续执行, 并输出了3
从上面的步骤可以看到, 一个重要的时间点就是 执行栈 为空, 并且这个时间点后, 相关操作都是由 Event Loop 处理的, Event Loop 负责调控整个流程。 当然 异步模块 中的内容是另外的一部分, 正因如此, 当 执行栈 中执行任务时, 异步模块 可以对异步任务进行计时。
总的来说, 执行栈 执行任务, 执行完后, 就会让 Event Loop 从 任务队列 中取出新的任务送到 执行栈 中执行。注意, 当 执行栈 不为空时, 任务队列 中的任务是无法进入 执行栈 中执行的。
现在, 让我们了解两个新名词, 宏任务 和 微任务, 前面的 异步任务 并不是常见的名词, 而是我自己在此处定义的名词, 异步任务 其实就是 宏任务 和 微任务 的总称。
🍕 宏任务和微任务
宏任务 和 微任务 是两类不同的 异步 任务。3
宏任务队列 的数据结构 不是队列, 而是 集合。4
记住这个概念很重要, 因为队列是有序的, 而集合是无序的, 所以在 宏任务队列 中, 先到达的任务 不一定 会先执行。
微任务队列 的数据结构是 队列, 所以 微任务队列 中任务的执行一定是有序的。微任务队列 还有这么一个特点, 当 微任务队列 中的 微任务 开始执行时, 它可以继续添加新的 微任务 到 微任务队列 中, 并且 微任务队列 一旦开始执行, 就会执行到 微任务队列 为空。换句话说, 如果不断的有新的 微任务 加入到 微任务队列 中来, 那么 宏任务 将不断的被阻塞, 无法执行。这种情况导致的最常见的后果就是页面无法响应你的鼠标或者滚轮, 因为与用户的交互是属于 宏任务。为了处理无限递归的 微任务, 听说以前的 Nodejs 中, 会提供一个机制来限制最大的递归数量, 但我没有在官方文档中找到具体的内容。from1, from2
微任务队列 中的任务会一次性执行完, 带来的好处是它确保了每一个 微任务 之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。5
如果要给 微任务 和 宏任务 定一个优先级, 那么你可以认为 微任务 的优先级更高。但我认为, 与其记住谁的优先级更高, 不如记住这么一句话: 每一个宏任务执行之前, 必须确保微任务队列为空。6
下面给出已知的 宏任务 和 微任务
- 宏任务
setTimeoutsetIntervalsetImmediate(Node 独有)requestAnimationFrame(浏览器独有)- I/O
- UI rendering (浏览器独有)
- 微任务
process.nextTick(Node 独有)- Promises (准确的说是 Promise.then() 中 then 的回调函数, 而不是 new promise(callback) 携带的回调函数)
Object.observeMutationObserverqueueMicrotask
🍕 通过一段代码来理解宏任务和微任务
setTimeout(() => { // l-1
console.log("宏任务: 计时任务1") // l-3
Promise.resolve().then(() => { // l-4
console.log("微任务1") // l-5
})
}, 500);
setTimeout(() => { // l-2
console.log("宏任务: 计时任务2") // l-6
Promise.resolve().then(() => { // l-7
console.log("微任务2") // l-8
})
}, 500);
先讨论真正有用的, 也就是Node11之后版本和浏览器的版本, 下面以浏览器内核进行解释:
l-数字代表某行代码APIs是浏览器中的一个机制, 详细的结果不清楚, 只知道一些异步API的处理, 都是由它进行处理的, 当异步函数执行完毕时, 也是由它负责发送给任务队列。7宏任务队列, 由宏任务组成的队列, 宏任务队列分为 计时器队列 (Expired Timer Callbacks, 即到期的setTimeout/setInterval)、IO事件队列(I/O Events)、即时队列 (Immediate Queue, 即 setImmediate)、关闭事件处理程序队列 (close Handlers)。8 9微任务队列, 由微任务组成的队列
- 首先, 浏览器自上而下的执行(执行的过程中
执行栈中进行), 先执行l-1, 发现是setTimeout, 于是将它的参数(callback,delay)发送给APIs - 然后继续识别
l-2, 发现又是setTimeout, 于是继续将它的参数发送给APIs APIs接收到setTimeout的内容后, 会进行计时, 当经过 delay(也就是500ms) 后, 会将 callback(也就是l-1的回调函数) 发送给任务队列中的宏任务队列。这样的事情APIs干了两次(因为有两个setTimeout)。- (继2) 当
执行栈为空时,Event Loop会将任务队列中的任务发送到执行栈中执行。不过此时任务队列为空, 故什么都不执行 - 经过 delay 时间后, 两个计时器的回调函数将会被
APIs发送给宏任务队列 - 此时
执行栈为空, 并且微任务队列为空,宏任务队列非空, 故可以将宏任务队列中的第一个宏任务送到执行栈中执行 执行栈执行l-1的回调函数, 先执行l-3, 此时直接输出"宏任务: 计时任务1"- 继续执行
l-4, 发现是Promise, 于是将 then 的回调函数送到APIs(反正是类似APIs的异步处理模块, 但该模块不属于 JS 的单线程范畴), 然后执行了 resolve 了, 于是 then 的回调函数被发送到了微任务队列中 执行栈执行完l-3和l-4后, 又为空了, 于是Event Loop继续查看任务队列- 此时的
任务队列中,微任务队列有了新的微任务, 故先执行微任务, 也就是将l-5送入执行栈中执行, 此时输出"微任务1" - 执行完
l-5后,执行栈为空,微任务队列为空, 于是Event Loop再从宏任务队列中取出一个宏任务送往执行栈 - 后面的就和前面的重复(循环)了
- 先执行
l-6, 输出"宏任务: 计时任务2" - 在执行
l-7, 执行完后会微任务队列又增加了一个微任务 执行栈又为空, 继续查看任务队列, 取出微任务送往执行栈- 执行
l-8, 输出"微任务2" 结束,任务队列又为空, 不作任何操作, 等待异步模块继续发送某些宏任务或微任务到任务队列中, 比如用户突然点击了某个绑定了回调事件的按钮, 或者某个网络请求请求结束, 或者是之前设置的某些定时任务到了触发的时间了等等…
看完上面的过程, 我们其实可以发现, 打开网页时, 除了网页文档中的 script 脚本是直接送入 执行栈, 其他的 任务, 其实都是从 任务队列 中取出的了。或者更加简单一点, 我们可以直接认为, 文档中最开始的 script 其实就是在 任务队列 中的。可能是 宏任务, 也可能是 微任务。反正记住一点, 最先开始执行的肯定是 script 脚本中的内容, 其他的内容, 就都是从 任务队列 中取出的了, 而 任务队列 中的内容, 是由异步模块(比如 APIs, 其实我也就只知道一个 APIs 了)发送给我们的。
对于 宏任务 和 微任务, 不需要在意谁先执行谁后执行, 只需要记住一点就可以了: 当一个 宏任务 想要执行时, 必须确保 微任务队列 为空。记住这一点后, 其他的都能够直接推理出来了, 比如 微任务队列 不为空时, 永远轮不到 宏任务 执行, 换句话说, 我们要小心使用 微任务队列, 不然会出现死循环的情况, 这也是为什么官网不建议我们用太多 queueMicrotask() 函数。
🍕 NodeJS11 之前的 Event Loop (不重要, 可忽略)
下面来谈一点 “过时” 的东西, 前面的分析, 在现在这个时间点(22.12.15)都是对的。而在之前, 也就是 NodeJS11版本之前(不包括11), node 和 浏览器的 Event Loop 机制是不一样的, 最大的区别就在于 宏任务 和 微任务。前面已经说了,每一个 宏任务 执行时, 都要确保 微任务队列 为空, 这是新版本的标准。在此之前的版本有一点点不同,之前的版本所要求的的是 同一类宏任务队列 执行之前, 要确保 微任务队列为空。这个差异导致的结果就是,当存在两个 setTimeout 时, 会先执行完这两个宏任务, 然后再去执行微任务, 所以前面的代码, 用 node11 之前的版本运行时, 会是不一样的结果

node 10 vs 16
有关 nodejs 的 Event Loop 具体的流程图, 可以看下面这张图 10

11版本之前的流程图
在这个过程中, 还发现了一个 让人困惑 有意思的现象, 那就是当我们将两个 setTimeout 的 delay 设置为 0 秒时, 输出的情况是不确定的, 有时候会出现 微任务1 在 宏任务2 之前输出, 如图所示

nodejs10 different output
下面我想试着解释这么一种现象。
首先, 通过输出可以发现, 大多数情况下, 还是先输出两个宏任务, 然后才输出微任务, 这个很好理解, 当 宏任务队列 存在两个 setTimeout 时, 肯定会先执行完两个 setTimeout 后再去查看 微任务队列 (注意这是 NodeJS11 版本之前, 新版本不是这样的)。
那么什么情况下, 会出现先输出 宏任务1 和 微任务1 呢?, 我认为关键就在于程序具体执行的细节中。上一段话中, 我们说了 当 宏任务队列 存在两个 setTimeout 时, 肯定会先执行完两个 setTimeout, 但实际运行时, 宏任务队列 中一定会存在两个 setTimeout 吗? 或者应该这么问, 当第一个 setTimeout 运行完后, 另外一个 setTimeout 真的存在 宏任务队列 中吗? 答案应该是不一定的, 让我逐帧来分析一下:
在开始分析之前, 容许我再重复一下上面的代码:
setTimeout(() => { // l-1
console.log("宏任务: 计时任务1") // l-3
Promise.resolve().then(() => { // l-4
console.log("微任务1") // l-5
})
}, 0)
setTimeout(() => { // l-2
console.log("宏任务: 计时任务2") // l-6
Promise.resolve().then(() => { // l-7
console.log("微任务2") // l-8
})
}, 0)
- 首先,
l-1和l-2都会在执行栈中等待执行 执行栈先执行l-1, 此时会将l-3,4,5送到 异步模块 中。为方便描述, 记l-3,4,5为s1。执行栈继续执行l-2, 此时会将l-6,7,8送到 异步模块 中。 记l-6,7,8为s2
因为 异步模块 不属于 JS 单线程的范畴, 所以 异步模块 的内容和
执行栈中的内容是可以并发进行的, 这就导致了一种分歧: 当执行栈为空时, 异步模块 可能还未将s1发送给宏任务队列, 也可能已经将s1发送给宏任务队列。
理由: 我们设置的延迟时间是 0, 理论上s1被送往 异步模块 时, 异步模块 应该马上将其发送给任务队列, 但实际上, 异步模块 应该会每隔一段时间, 才检查s1的延迟时间是否已经到期, 才决定是否将s1送往宏任务队列。
下面我们考虑的是情况是, 当执行栈为空时, 只有s1已经被送往宏任务队列。
- 当
执行栈将s2送往 异步模块 后,执行栈为空, 此时宏任务队列只有s1,微任务队列为空, 于是Event Loop将s1送往执行栈执行 - 关键来了!, 存在这么一个时间节点,
执行栈在执行s1, 异步模块 在等待s2的时间到期,任务队列为空。当执行栈先执行完s1时,l-4(Promise callbacks) 会被送往了 异步模块, 并且 异步模块 还未将s2送往宏任务队列。 也就是说, 此时的 异步模块 同时存在s2和l-4(Promise callbacks), 并且 异步模块 会先将微任务l-4(Promise callbacks) 送往微任务队列, 而s2还停留在 异步模块 中。
虽然我不清楚 异步模块 具体实现的源代码, 甚至都不敢保证 node 中存在 异步模块 这个机制, 但因为 微任务 是优于 宏任务 的, 所以, 当同时存在 0 秒延迟的
setTimeout和微任务(Promise callbacks)时, 即使setTimeout是率先到达 异步模块 的, 我也认为微任务是有机会先于setTimeout被发送到任务队列的。
- 异步模块 先将
l-4发送到微任务队列, 此时,执行栈为空,s2还未送往宏任务队列,微任务队列中存在l-4, 于是Event Loop就先将l-4送往执行栈中执行了, 从而导致了 微任务1 先于 宏任务2 输出。后面的分析就没有什么需要做笔记的了。
总的来说, 我的解释就是: 存在这么一个时间节点, 异步模块 中同时存在 宏任务2 和 微任务1。并且, 虽然 宏任务2 先于 微任务1 进入 异步模块, 但 异步模块 还是可能先将 微任务1 发送到 任务队列, 从而导致了 微任务1 先于 宏任务2 执行。
其实, 我不确定上面的解释是否是正确的, 因为我不清楚 nodejs 的源代码是如何编写的, 更不确定 nodejs 是否真的存在一个 异步模块, 但因为浏览器存在一个 APIs, 所以我感觉 nodejs 可能也有一个 异步模块 。
下面我再用别人给出的 nodejs11版本之前的 Event Loop 图, 来解释一下:

关键的时间点1, setTimeout1 开始执行, 但 setTimeout2 还没有进入宏任务队列中

关键时间点2, 只要 微任务1 能够在 s2 还未执行时进入到队列中, 那么它就有很大概率先于 s2 执行
好了, 这一部分, 仅仅只是感觉有意思的, 所以想着纪录一下, 初次学习(说的就是我)不要 只想不做, 不然容易误入歧途, 最好休息一下, 放空放空大脑, 或者去看看大佬的文章, 再回头来思考一下。
🍕 总结
Event Loop是一种机制, 它指示了异步任务任务之间的运行规则。- JS 的单线程, 体现在
执行栈只有一个, 并且只有执行栈为空时, 才有机会将新的任务送入执行栈中执行。 - 每一个宏任务执行之前, 必须确保
微任务队列为空。两个setTimeout的回调函数, 属于两个宏任务。
🍕 参考资料
- JS 的异步机制一探 - ByteEE
- NodeJS Event Loop: JavaScript Event Loop vs Node JS Event Loop - Deepal Jayasekara(很优秀的系列文章, 这里有翻译版, 不过最新的一篇没有翻译 —— 有关Nodejs和浏览器的对比)
- Difference between microtask and macrotask within an event loop context - stack overflow
- Event Loop - WHATWG(想要更深入时必读)
- what is an event loop in javascript, (不长, 可简单的认识什么是 Event Loop)
- JavaScript Event Loop, (不是很长, 介绍的是 Event Loop, 图表更丰富一些)
- Understanding the Event Loop, Callbacks, Promises, and Async/Await in JavaScript
- event-loop
- 带你了解事件循环机制(Event Loop)


















