想象你走进一家只有一位大厨的餐厅。厨房只有一个灶台,大厨一次只能炒一道菜——这就是 JavaScript 的“单线程”。客人点的主菜会写成订单签放进前厅的订单架排队(宏任务队列);但如果某桌客人吃着吃着喊“再加一份甜品”,服务员(事件循环)会立刻把这桌的追加需求(微任务)递给厨房,而不是先去接待新客人的订单。就这样,大厨在灶台与订单架之间循环忙碌:永远先处理当前桌的追加需求,再迎接下一桌新订单;一桌新客只接一单,但追加需求一次做清。你理解了这套规则,就掌握了 JS 事件循环的精髓。

岗位关联

理解事件循环,等于摸清了 JS 异步执行的“交通规则”,能帮你写出丝滑不卡顿的交互体验,任何 AI 产品落地到网页前端时都离不开这份掌控力。

目录
  1. 可视化演示
  2. 调用栈机制
  3. 事件循环原理
  4. 宏任务队列
  5. 微任务队列
  6. 执行顺序规则
  7. 帧渲染协调
  8. 异步性能陷阱
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

交互展示JavaScript事件循环机制,剖析调用栈、宏任务队列和微任务队列的执行顺序与优先级。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Event Loop执行顺序 调用栈执行同步代码 清空微任务队列(Promise.then) Stack → Micro → Macro 取一个宏任务执行后再次清空微任务

调用栈机制

大厨站在灶台前,出餐口的小台面只能一层一层地叠盘子:先放上来的盘子在下,后放上来的盘子在上。炒好最上面那盘就端走,显露出下面的盘子继续做。这个“盘子摞”就是 JS 的调用栈(Call Stack)。它决定了 JavaScript 单线程执行的基本面貌——同一时刻,灶台上只有一道菜在炒。

JS 引擎执行代码时也是这样。函数调用函数,就像盘子不断往上叠;引擎一次只能执行栈顶的函数,执行完弹出,再继续下一个。这个结构遵循 LIFO(Last In, First Out,后进先出)。当脚本启动时,全局上下文先入栈;每调用一个函数,就生成一个新“帧”压入栈顶;函数内部如果再调用其他函数,就再压一帧。

调用栈有容量上限,通常在几万到十几万帧之间。如果代码出现无限递归——函数不停调用自己——栈帧会一层层压上去,直到超过上限,浏览器抛出“栈溢出(stack overflow)”错误。这是新手最常遇到的崩溃原因之一。

f1→f2→f3⇒执行f3→f2→f1\displaystyle f_1 \rightarrow f_2 \rightarrow f_3 \xRightarrow{\text{执行}} f_3 \rightarrow f_2 \rightarrow f_1

1. f1 入栈:第一个函数开始执行,如同一只盘子先放到灶台上。

f1↑f_1 \uparrow

2. f2 压栈:f1 内部调用 f2,新盘子压住 f1。

f2↑+f1f_2 \uparrow + f_1

3. f3 压栈并返回:f2 内部调用 f3,f3 位于栈顶,最先执行完毕并弹出。

f3↑⇒f3↓f_3 \uparrow \Rightarrow f_3 \downarrow

4. 逐层出栈:f3 弹出后 f2 继续执行并弹出,最后轮到 f1 弹出。

f2↓⇒f1↓f_2 \downarrow \Rightarrow f_1 \downarrow

就像厨师必须先完成最顶层那道菜才能腾出灶台做下面的菜,JS 也必须把当前函数执行完才能回到上一个函数。而排队的订单(异步任务),要等这摞盘子清空后才轮得到。调用栈越小,大厨越轻松;栈一旦爆掉,整家餐厅就得停业重来。

事件循环原理

大厨只有一个灶台,没法同时炒十桌菜。怎么破?餐厅请了一位经理:他不停在厨房和前厅之间巡场,看看灶台有没有空、订单架上有没新单。这位经理,就是事件循环(Event Loop)。他不是厨师,不亲自做菜,只负责“调度”。

事件循环是 JS 引擎深处的调度器。它的日常工作只有一句话:检查调用栈是否为空——为空,就从任务队列里取一个回调放进去执行;不为空,就继续等。这个“检查 → 取任务 → 执行”的过程不断重复,形成一个永不停歇的循环,因此得名“事件循环”。

浏览器环境里有多个任务来源:定时器、网络 I/O、用户交互、DOM 变更等,它们各自排队。事件循环每转一圈,只从宏任务队列取一个任务,然后立刻把微任务队列全部清空,再视情况决定是否进入渲染阶段。注意它不是把队列里的任务一口气执行完,而是“取一个宏任务 → 清空所有微任务 → 再回来取下一个”的节律。

Loop=宏任务⏟取一个→微任务⏟全部清空→渲染→⋯\displaystyle \text{Loop} = \underbrace{\text{宏任务}}_{\text{取一个}} \rightarrow \underbrace{\text{微任务}}_{\text{全部清空}} \rightarrow \text{渲染} \rightarrow \cdots

1. 判断调用栈是否为空:栈非空时同步代码还在执行,事件循环无法插入任务。

stack=∅⇒loop 可取任务\text{stack} = \emptyset \Rightarrow \text{loop 可取任务}

2. 取一个宏任务:从宏任务队列队头取一个任务放入调用栈。

macrohead→stack\text{macro}_{\text{head}} \rightarrow \text{stack}

3. 清空微任务队列:当前宏任务结束后,将所有微任务(含新增)执行完。

while micro not empty: pop and run\text{while micro not empty: pop and run}

经理就这样日复一日地兜圈子:灶台空了吗?有追加需求吗?有新订单吗?只要有任务,他就永远停不下来。这也是为什么 JS 明明只有一个线程,却能让网络请求、定时器、用户点击“看起来像同时发生”——它们都在迎合这套循环节律。

宏任务队列

前台订单架按先来后到排成长队。新客人的订单签插在队尾,厨师做完一单就撕一单,从不插队。这一摞订单签,就是宏任务队列(MacroTask Queue),遵循先进先出(FIFO)。

宏任务有哪些?定时器 setTimeout、setInterval 的回调;网络 I/O、文件读写的完成回调;用户点击、按键的 UI 事件处理函数。它们都属于“独立任务”——每个宏任务都代表一次全新的执行机会,拥有自己独立的调用栈生命周期。从这个意义上说,每个宏任务都像一位全新的客人,值得一整套完整服务流程。

特别要澄清一个著名误解:setTimeout(fn, 0) 并不是“立即执行”,而是把 fn 作为宏任务放到队列尾部。它最早也要等当前脚本(当前宏任务)执行完、微任务队列清空后,才轮得到执行。所以“延时 0”的实际含义是“尽快排队”,而不是“立刻做”。

MacroTask=定时器∪I/O 回调∪UI 事件\displaystyle \text{MacroTask} = \text{定时器} \cup \text{I/O 回调} \cup \text{UI 事件}

1. 定时器回调:setTimeout 到期后,回调函数进入宏任务队列。

setTimeout(fn,0)⇒fn∈macro\text{setTimeout}(fn, 0) \Rightarrow fn \in \text{macro}

2. I/O 回调:网络请求或文件读写完成后,回调进入宏任务队列。

fetch done⇒callback∈macro\text{fetch done} \Rightarrow \text{callback} \in \text{macro}

3. UI 交互事件:用户点击、按键等事件触发处理函数。

click⇒handler∈macro\text{click} \Rightarrow \text{handler} \in \text{macro}

订单架上的每一张单,都是一桌独立客人。厨师不会为了某个订单的特殊备注打乱整个长队的顺序——宏任务一视同仁,先来先做。理解了这一点,你就不会再问“为什么我明明先写的 setTimeout,却后执行”这种问题了。

微任务队列

客人正吃着,突然喊“甜品马上上!”服务员不能丢下这桌去接待新客,必须先把这桌的追加需求传到厨房。这张“加单”,就是微任务(MicroTask)。它是当前桌子(当前宏任务)的延伸,比任何新订单都优先。

微任务和宏任务最大的区别在于“身份”:宏任务是一次全新的执行机会,微任务则是在当前宏任务收尾时插入的小任务。只要有微任务存在,它们就会在当前宏任务结束后、下一个宏任务开始前,被一次性全部执行完。而且——微任务执行过程中如果又产生了新的微任务,依然要在这一轮全部清空,直到队列变为空。

产生微任务的常见途径有三条:Promise.then / catch / finally 的回调、queueMicrotask(fn) 显式注册的函数、以及 MutationObserver 观察 DOM 变化后的回调。它们都进入同一个微任务队列,共享“优先于宏任务”的待遇。

MicroTask=Promise.then∪queueMicrotask∪MutationObserver\displaystyle \text{MicroTask} = \text{Promise.then} \cup \text{queueMicrotask} \cup \text{MutationObserver}

1. Promise 的回调:Promise 状态变为已决(fulfilled)后,then 回调进入微任务队列。

P.resolve⇒onFulfilled∈microP.\text{resolve} \Rightarrow \text{onFulfilled} \in \text{micro}

2. queueMicrotask:显式注册一个微任务函数。

queueMicrotask(fn)⇒fn∈micro\text{queueMicrotask}(fn) \Rightarrow fn \in \text{micro}

3. MutationObserver 回调:监听 DOM 变化,变化发生后回调成为微任务。

mutation⇒observer∈micro\text{mutation} \Rightarrow \text{observer} \in \text{micro}

桌边的追加需求虽然琐碎,却必须优先伺候。这就是为什么在同一个宏任务里,Promise.then 往往比 setTimeout 更早执行——它们根本不在同一条优先级线上,一个是加单,一个是新客。

执行顺序规则

现在把整套流程串起来。经理的巡场规律其实只有一句话:先清空微任务队列,再取一个宏任务。完整规则可以写成:

执行主脚本(视为第一个宏任务)→ 清空微任务队列(含新产生的微任务)→ 取下一个宏任务 → 清空微任务队列 → 如有需要就渲染 → 回到第 3 步。

这套规则里有三个经典的“坑”值得特别注意。第一,微任务执行中产生的新微任务,会在本轮继续清空,不会推迟到下一轮;第二,宏任务执行中产生的新宏任务(比如 setTimeout 里再套 setTimeout),必须等下一轮才执行;第三,正因如此,Promise 天然比 setTimeout 先执行——一个是微任务,一个是宏任务,差距从入队那一刻就注定了。

一轮=一个宏任务⏟只取一个+全部微任务⏟直到队列为空\displaystyle \text{一轮} = \underbrace{\text{一个宏任务}}_{\text{只取一个}} + \underbrace{\text{全部微任务}}_{\text{直到队列为空}}

1. 执行一个宏任务:每一轮从宏任务队列队头只取一个任务执行。

next←shift(macro)\text{next} \leftarrow \text{shift}(\text{macro})

2. 清空全部微任务:只要微任务队列非空就持续执行,新加进来也要执行。

drain(micro)\text{drain}(\text{micro})

3. 可选的渲染:有渲染需求时在浏览器帧循环中执行,否则跳过。

render←if needed\text{render} \leftarrow \text{if needed}

掌握了“先微后宏、一轮一宏”的节奏,再去看网上各种“这段代码输出顺序是什么”的题目,就像老熟客看菜单一样一目了然。你不需要死记硬背,只需要把自己代入那位巡场的经理,按规则走一遍就知道下一个该轮到谁。

帧渲染协调

餐厅里有位品控经理,每道菜出锅前都会快速检查摆盘、确认温度,再推出餐口。浏览器也有类似角色:它会周期性执行渲染(paint),把最新的样式和布局绘制到屏幕上。而渲染之前,需要一个“最后调整”的窗口,让动画元素换上正确的位置和样式。这个窗口,就是 requestAnimationFrame(rAF)。

rAF 的特殊之处在于:它既不是宏任务,也不是微任务,而是独立于两者之外、在浏览器渲染新一帧之前统一执行的“帧任务”。它每帧最多调用一次,并且严格跟随屏幕刷新率(通常是 60Hz)。相比之下,setTimeout 回调既不保证时间精确,也不关心屏幕刷新节奏,后台标签页里还会被节流甚至停掉。

用 rAF 做动画有三点优势:第一,跟随刷新率,避免掉帧;第二,页面切到后台时自动暂停,节省资源;第三,回调在渲染前触发,能保证样式调整不会错过当前帧的绘制时机。

Frame=MacroTask+MicroTask+rAF+渲染\displaystyle \text{Frame} = \text{MacroTask} + \text{MicroTask} + \text{rAF} + \text{渲染}

1. 宏任务与微任务更新状态:常规异步回调在一帧中先执行,完成数据更新。

macro+micro⇒更新数据\text{macro} + \text{micro} \Rightarrow \text{更新数据}

2. rAF 回调调整布局:渲染前最后一次调整元素位置与样式。

rAF callbacks⇒调整位置\text{rAF callbacks} \Rightarrow \text{调整位置}

3. 浏览器渲染绘制:把计算结果绘制为屏幕像素,展示给用户。

paint⇒呈现给用户\text{paint} \Rightarrow \text{呈现给用户}

动画要用 rAF 而不是 setTimeout,就是因为品控经理会在每道菜“出锅上桌前”最后把关。如果你用 setTimeout 做动画,回调可能落在帧与帧之间,出现画面抖动——菜早就炒好了,却没赶上品控检查的窗口。

异步性能陷阱

厨房最容易出乱子的情况有三种:顺序搞反、灶台被占死、赶单导致撒汤。异步代码也一样,最常见的三个性能陷阱是:执行顺序混淆、渲染阻塞和微任务风暴。

执行顺序混淆源于对“先写先执行”的直觉。交互事件和定时器同时触发时,新手常以为代码书写顺序就是执行顺序。真正的钥匙是“先微后宏”:把所有回调按宏任务与微任务分类,心里就有一张准确的运行时间表。渲染阻塞更常见:一段耗时超过 16.6ms 的同步代码,会让浏览器来不及绘制下一帧,用户感受到的便是卡顿掉帧。方案是拆帧——把一个大任务切成多个小宏任务,让渲染有机会插队。

微任务风暴最隐蔽也最危险:如果微任务不断创建新的微任务(比如不收敛的递归 then),事件循环会永远停留在微任务阶段,宏任务队列被“饿死”,页面将无法响应用户操作。记住一条底线:微任务里不要写没有终止条件的递归。

帧预算=1000ms60fps≈16.6ms\displaystyle \text{帧预算} = \frac{1000\text{ms}}{60\text{fps}} \approx 16.6\text{ms}

1. 帧预算定义:每秒显示 60 帧,则每帧只能占用约 16.6 毫秒。

budget=1000/60\text{budget} = 1000 / 60

2. 超预算的后果:任务挤占渲染时间,页面因此掉帧卡顿。

time>16.6ms⇒jank\text{time} > 16.6\text{ms} \Rightarrow \text{jank}

3. 拆帧优化:把长任务切分成多个短任务,让每段都不超过预算。

long→∑chunks≤budget\text{long} \rightarrow \sum \text{chunks} \leq \text{budget}

好的厨房,井井有条:经理调度有方、灶台不堵、品控准时。好的异步代码也一样——任务分得清主次,长任务拆得开,微任务收得住。你的页面就会像流水席一样顺畅,而不是像高峰期厨房那样兵荒马乱。

AI 应用场景

AI 模型前端部署的性能优化

阶段:AI 应用部署与渲染优化

解决的问题:传统方式把整张推理结果一次性渲染,遇到 200ms 的推理耗时会让页面直接卡死。理解事件循环后,工程师可以把推理过程拆成多个宏任务,每个片段完成后用微任务同步更新状态,用户能边等边看结果,体验像“边做边端菜”。

具体案例:一个 AI 图像上色模型在浏览器端执行分割与上色,原来单张图片让 JS 线程阻塞 180ms,动画明显卡顿。工程师将任务拆成 14ms 以内的宏任务分片,每片完成后用微任务更新进度条,最终卡顿感消失,用户感知速度提升近一倍。

AI 对话应用的流式渲染调度

阶段:模型推理输出后的 UI 渲染

解决的问题:LLM 逐字输出时,每个 token 都触发一次 DOM 更新会引发布局抖动。借助 requestAnimationFrame 这个“帧前品控窗口”,把文本更新合并到每一帧渲染前,确保屏幕只在需要时刷新。

具体案例:某 AI 助手网页每秒输出 40 个 token,若每个 token 立即更新 DOM 会触发 40 次渲染;改为 rAF 批处理后在 60Hz 屏幕上最多渲染 60 次,实际稳定在 30-45 次,CPU 占用降低约 60%,用户看到的是流畅的逐字浮现。

Node.js 后端 AI 服务调度优化

阶段:AI 服务端请求处理与响应

解决的问题:AI 网关服务在并发请求下,日志写入、指标采集与推理结果回调会争抢执行时机。利用宏任务/微任务的优先级差异,让推理结果回调(微任务)先于日志上报(宏任务)返回,相当于“桌边加单优先于新客订单”。

具体案例:某模型网关基于 Node.js 运行,把“模型请求完成后的响应回调”放微任务、“日志刷盘与遥测上报”放宏任务队列,结果 P99 延迟从 280ms 降到 190ms,吞吐量提升约 30%。

Python 演示

python48 行
# -*- coding: utf-8 -*-"""用 Python 模拟 JS 事件循环:宏任务与微任务调度"""from collections import deque  def simulate_event_loop():    # 宏任务队列(新客订单)    macro = deque()    # 微任务队列(当前桌加单)    micro = deque()    log = []     def add_macro(name):        macro.append(name)     def add_micro(name):        micro.append(name)     # 主脚本开始:注册一批异步任务    add_micro("Promise.then #1")    add_micro("Promise.then #2")    add_macro("setTimeout 回调")    add_macro("fetch 回调")    add_macro("click 回调")     while macro or micro:        # 1. 每次取宏任务前,先清空整个微任务队列        while micro:            task = micro.popleft()            log.append("[微任务] " + task)            # 模拟微任务产生新微任务(本轮必须继续清空)            if task == "Promise.then #1":                add_micro("queueMicrotask")        if not macro:            break        # 2. 宏任务先进先出,一次只取一个        task = macro.popleft()        log.append("[宏任务] " + task)        # 宏任务可能产生新的微任务        if task == "setTimeout 回调":            add_micro("setTimeout 产生 then")     return log  if __name__ == "__main__":    for line in simulate_event_loop():        print(line)
目的:用两个 Python 队列模拟 JS 事件循环的宏任务与微任务调度,直观展示“先清微任务、再取宏任务”的执行顺序。
  1. while macro or micro:只要两个队列还存在任务,事件循环就继续转圈。
  2. while micro:每次取宏任务之前,先一口气清空所有微任务,这是事件循环的第一原则。
  3. task = micro.popleft()微任务按先进先出顺序执行,和宏任务一样都讲究先来后到。
  4. if task == "Promise.then #1": add_micro("queueMicrotask")模拟微任务执行中产生新微任务,证明新微任务本轮也会被清空。
  5. task = macro.popleft()宏任务一次只取一个,取完立刻回头检查微任务队列。
  6. if task == "setTimeout 回调": add_micro("setTimeout 产生 then")模拟宏任务产生微任务,展示这个微任务会在下一个宏任务前被优先执行。

常见误区

误区我明明写了 `setTimeout(fn, 0)`,为什么 fn 不是马上执行,而是等了好几毫秒才开始跑?

正解延时 0 的真实含义是“尽快排队”,而不是“立即执行”。fn 会被放入宏任务队列尾部,在执行它之前,当前脚本、所有微任务、可能存在的渲染都要先完成。就像你喊“再快点上菜”,厨房也得先把正在做的那道菜出锅,再把你这桌的加单排队做出来。

为何常见:数字 0 太有欺骗性,让人本能地把它等同于“立刻”。其实定时器只保证“最小间隔”,不保证“准时到达”。

误区JS 里有 setTimeout、有 Promise,看起来能同时处理多件事,那它一定是多线程的。

正解JS 主线程永远是单线程,所有异步只是把任务排队,靠事件循环错峰执行,同一时刻仍然只在一段代码。真正的并行要交给 Web Worker 创建独立线程。

为何常见:把“不阻塞”误认为“同时运行”。事件循环产生了一种并行的错觉,但本质上仍是严格排队的单行道。

误区微任务和宏任务差不多,应该是逐个轮换着执行吧,比如微任务、宏任务、微任务、宏任务。

正解调度尺度完全不对。宏任务每轮只取一个,而微任务必须在当前宏任务结束后全部清空,包括执行期间新产生的微任务。旧世界是“一桌一桌接待”,微任务是“一单加菜全部做完”。

为何常见:把宏任务“一次一个”的经验错误套用到微任务上,忽略了“清空队列”和“取一个任务”两种调度语义的本质区别。

误区既然 rAF 能做动画,那我所有需要定时执行的回调都用它,是不是更好?

正解rAF 不是通用定时器,它绑定的是渲染帧。它只会在浏览器准备绘制新帧前调用一次,后台标签页会自动暂停。适合动画、绘制、帧同步操作;不适合定时轮询、业务轮询等“到点就做”的场景。

为何常见:两者都能“每隔一段时间执行回调”,表面相似,但背后的驱动力完全不同:一个是屏幕刷新,一个是计时器到期。

面试题

Q1. 下列哪项属于宏任务?

单选题 · easy

  • A.Promise 的 then 回调
  • B.MutationObserver 回调
  • C.setTimeout 定时回调← 正确答案
  • D.queueMicrotask 任务

答案:C。Promise.then、MutationObserver、queueMicrotask 都是微任务,只有 setTimeout 回调进入宏任务队列。类比餐厅:前三者是“当前桌加菜”(微任务),setTimeout 是“新客订单”(宏任务),新客只能等加菜做完才被接待。

Q2. 微任务队列在哪个时刻被清空?

单选题 · easy

  • A.每执行一个宏任务之前
  • B.当前宏任务结束后、下一宏任务开始前← 正确答案
  • C.所有宏任务执行完之后
  • D.浏览器空闲时才清空

答案:B。事件循环的规则是当前宏任务执行完,立刻清空整个微任务队列,然后才从宏任务队列取下一个。A 把时间点说成了“之前”;C 错误,不是等全部宏任务结束;D 是 requestIdleCallback 的行为,与微任务无关。

Q3. 执行以下代码,输出顺序是?console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3));

单选题 · mid

  • A.1 → 2 → 3
  • B.1 → 3 → 2← 正确答案
  • C.3 → 2 → 1
  • D.2 → 1 → 3

答案:B。同步代码 1 最先执行;脚本结束后微任务队列被清空,3 先于 setTimeout 回调执行;宏任务 2 最后才轮到。这是“先微后宏”最经典的代码验证,对应餐厅里“先满足加菜,再接待新客”的调度顺序。

Q4. requestAnimationFrame 回调的执行时机是?

单选题 · mid

  • A.每个宏任务结束后
  • B.每个微任务结束后
  • C.浏览器渲染新一帧之前← 正确答案
  • D.达到定时器延时之后

答案:C。rAF 在浏览器渲染新一帧之前统一执行,每帧只调用一次,且独立于宏任务与微任务。它就像品控经理在上菜前做的最后检查——不是和订单混在一起排队,而是在每道菜端出去之前把关。

Q5. 以下哪段代码最可能抛出“栈溢出”错误?

单选题 · mid

  • A.死循环 while 只耗 CPU 不溢出
  • B.无限递归不断压栈导致溢出← 正确答案
  • C.Promise 链式 then 不导致溢出
  • D.setTimeout 递归排队也不溢出

答案:B。无限递归让函数帧一层层压入调用栈,最终超过容量上限抛出栈溢出。死循环只占用 CPU 不压栈;Promise 链和 setTimeout 递归都是把任务放进队列,不消耗调用栈帧,因此不会栈溢出。

Q6. 执行以下代码,正确的输出顺序是?setTimeout(() => console.log('A')); new Promise((r) => { console.log('B'); r(); }).then(() => console.log('C')); console.log('D');

单选题 · hard

  • A.A → B → C → D
  • B.B → D → C → A← 正确答案
  • C.D → B → C → A
  • D.B → C → D → A

答案:B。new Promise 里的 B 是同步代码先输出;接着同步 D 输出;微任务 C 在脚本结束后被清空;最后宏任务 A 才执行。顺序是 B → D → C → A。关键在于区分“同步代码、微任务、宏任务”三个梯队。

Q7. 事件循环处理微任务时,微任务执行中新增的微任务会怎样?

单选题 · hard

  • A.留到下一宏任务结束后再执行
  • B.本轮全部清空,直到队列为空← 正确答案
  • C.被插入宏任务队列尾部
  • D.被浏览器直接丢弃

答案:B。微任务执行中产生的新微任务依然加入本轮微任务队列,事件循环会一直清空到队列为空才去取下一个宏任务。这是微任务与宏任务的关键差异:“加菜加到一半又点了新加菜,厨房照样先做完再迎接新客。”

Q8. 以下哪些属于微任务?

多选题 · mid

  • A.Promise 的 then 回调← 正确答案
  • B.queueMicrotask 注册的调用← 正确答案
  • C.setTimeout 回调
  • D.MutationObserver 回调← 正确答案

答案:ABD。Promise.then、queueMicrotask、MutationObserver 回调都属于微任务,会在当前宏任务结束后被全部清空。setTimeout 回调则进入宏任务队列,需要等微任务清空后才轮到。

Q9. 关于事件循环的说法,正确的有哪些?

多选题 · mid

  • A.每轮循环只取一个宏任务← 正确答案
  • B.微任务队列必须全部清空← 正确答案
  • C.宏任务队列也要全部清空
  • D.rAF 回调属于宏任务

答案:AB。事件循环每轮只取一个宏任务,但必须把微任务队列全部清空。C 错,宏任务不会一次性整队清空;D 错,rAF 是独立于宏微任务的帧回调。可以理解为“新客一桌一桌接待,加菜一次全上齐”。

Q10. 微任务持续产生新微任务(微任务风暴)时会引发什么?

多选题 · extreme

  • A.宏任务队列被饿死无法执行← 正确答案
  • B.当前宏任务永远无法结束
  • C.浏览器可能长时间无法渲染← 正确答案
  • D.微任务自动降级为宏任务

答案:AC。微任务风暴让事件循环一直停留在微任务阶段,宏任务队列得不到执行(A 对),浏览器也无法进入渲染步骤(C 对)。当前宏任务其实已经结束,只是事件循环被困在微任务阶段,所以 B 不准确;微任务不会自动降级,D 错误。