想象一家餐厅:厨房里只有一位主厨(主线程),他站在唯一的灶台(调用栈)前,一道菜一道菜地做。顾客点单、外卖通知、后厨定时器响铃……这些“待办事项”被写在不同的小本本上,堆成几摞——这就是任务队列。主厨做完手头的菜,就去翻翻哪一摞上的活最急,然后接着做下一道。而“微任务”就像服务员手里的即时小纸条,主厨每做完一道菜,必须先把这些小纸条处理完,才能接新订单。这套“做完一道→处理急单→再取下一道”的循环节奏,就是事件循环。 JavaScript 是单线程语言,可浏览器里又要响应点击、又要跑动画、还要发网络请求,为什么界面还能流畅运行?答案就藏在这套调度机制里。这篇文章用一家厨房的视角,带你彻底看清:哪些活算“宏任务”、哪些算“微任务”、Promise 为什么总爱插队、页面渲染在什么时候发生、Node 的厨房和浏览器有什么不同。
搞懂事件循环是理解 JavaScript 异步世界的地基,也是面试必问、写高性能前端/Node 代码的底层能力。本课让你从“背结论”升级为“看得懂调度”。
可视化演示
在有序数组中查找目标值22,通过不断缩小搜索范围快速定位,时间复杂度O(log n)。
本节配有交互动效(进入视口后自动加载,约 3KB)。
二分查找 — 初始状态 有序数组共10个元素,查找目标值22 T=O(log n) 初始搜索范围为整个数组
调用栈与上下文
主厨站在灶台前,只能同时盯一口锅。如果一道菜需要先切葱再炒蛋,他必须按顺序把“切葱”和“炒蛋”这两步排好。这就像正在执行的函数调用:每呼叫一个新函数,相当于在灶台边放上一个新菜板(执行上下文),用完就必须拿走,因为台面空间有限。
在 JavaScript 中,调用栈就是这块“台面”,它是一个后进先出(LIFO)的结构。当你调用 f1(),创建上下文 ε₁ 压栈;f1 里又调用 f2(),ε₂ 压到上面;f2 里再调用 f3(),ε₃ 继续压。执行顺序是:先完成最顶层的 f3,再回到 f2,最后回到 f1。这就好比叠盘子:最后放上去的盘子先被拿走。
每个执行上下文里装着这个函数“干活需要的家伙什”:参数、局部变量、作用域链、this 指向。递归调用无限进行、从未返回时,台面上的菜板越摞越高,最终栈溢出(RangeError: Maximum call stack size exceeded)——就像菜板真的堆到天花板,厨房直接罢工。
回到厨房:主厨每做完一道菜,就把对应菜板收走。因为菜板是“后放的先收”,所以你的 f3 总是先把结果端出来,再轮到 f2、f1 继续调味道。理解了这个栈式节奏,才能明白为什么“同步代码会卡住后续的一切”。
- 上下文创建每次函数调用都会生成一个新的执行上下文,其中包含参数、局部变量与作用域链,相当于给这道菜单独准备一个菜板。
- 压栈入栈新上下文被压到栈顶,只有栈顶的上下文正在执行。
- 出栈返回函数 return 后,栈顶上下文弹出销毁,控制权交还给下一个栈帧。
事件循环机制
既然主厨只有一个人,那餐厅怎么同时接几十桌客人的单?答案是:主厨并不“同时做”,而是快速轮换。他把所有订单先按种类记下来,做完手里的菜就回头取下一单。这个“做完一单→看下一单”的周而复始,就是事件循环(Event Loop)。
JavaScript 的主线程就是那位主厨。它同一时刻只执行一个任务:执行调用栈中的代码。当一个宏任务执行完毕栈空时,事件循环登场,它负责从宏任务队列取一个任务塞给主厨,等主厨做完,又去检查微任务队列,把微任务依次清空,然后询问浏览器:“要不要渲染一下?”需要就渲染,再取下一个宏任务。
事件循环不是 JavaScript 语言本身的东西,而是宿主环境(浏览器或 Node)提供的调度器。在浏览器里,它由“事件循环线程”这件事承载;在 Node 里,它由 libuv 库实现。但二者的核心骨架一致:取宏任务 → 执行 → 清空微任务 → 按需渲染 → 下一个宏任务。理解这个循环,就拿到了异步世界的地图。
厨房经理每小时走一圈:收新单(宏任务),处理服务员递来的急单(微任务),再让人把出锅的菜端上去(渲染)。只要经理还在转,餐厅就永远不会“饿死”——这就是事件循环最重要的价值:让单线程永不空闲。
- 取宏任务从宏任务队列头部取出一个任务,把它交给调用栈执行。
- 清空微队列宏任务执行完毕后,立刻把所有微任务按顺序执行完,执行过程中新产生的微任务也要继续执行,直到队列为空。
- 渲染更新如果页面有变化,浏览器会绘制新的一帧,把结果呈现给用户。
- 继续迭代回到第 1 步,取下一个宏任务,如此周而复始。
宏任务队列
餐厅里有一块巨大的订单板,上面贴满了顾客的各类需求:点餐、加菜、叫服务员。这些订单不可能立刻全部处理,主厨按顺序一张张揭下来做。宏任务(MacroTask)就是这些订单。
宏任务的来源主要有四类:setTimeout / setInterval 定时器回调、用户事件回调(点击、键盘、滚动)、I/O 回调(网络请求完成)、以及渲染相关任务。这些任务都会进入宏任务队列,以先进先出(FIFO)的顺序等待事件循环取用。
关键点是定时器延迟只是“最小时间”。setTimeout(fn, 0) 并不是 0ms 后立刻执行,而是“至少 0ms 后才把 fn 放进宏任务队列”。如果主线程正在执行一段长任务(比如一个 2 秒的死循环),定时器回调就只能乖乖在队列里等待。这就像后厨写着“煮蛋 2 分钟”,但主厨手上有一锅硬菜,蛋熟了也只能放在出餐台上等着。
所以当你用 setTimeout 拆分重任务时,本质是在订单板上重新排队,让老板(用户)能喘口气看到“加载中”的转圈动画。宏任务队列的先进先出,决定了所有异步回调的“先后到达顺序”,这是后续排错的重要线索。
- 定时器注册调用
setTimeout时,浏览器把回调交给计时器线程计时,返回一个 timer ID。 - 计时到期时间到达后,回调并不执行,而是被放入宏任务队列末尾。
- 循环取出事件循环从宏队列头部取出该回调,在调用栈空闲时执行。
微任务队列
服务员手里总会有一叠“即时小纸条”:某桌客人要加水、某桌要结账、某道菜客人催单……这些小条子优先级极高,主厨每做完一道菜就必须赶紧处理,哪怕订单板上还有一堆大订单在排队。微任务(MicroTask)就是这些小纸条。
微任务的来源主要有三类:Promise.then / catch / finally 回调、queueMicrotask() 显式注册的任务、浏览器 MutationObserver 回调。微任务也有自己的队列,它跟宏任务队列最大的区别是处理时机:一个宏任务结束后,事件循环会一次性清空所有微任务,处理完微任务队列之后,才会去取下一个宏任务。
这意味着即使微任务队列中不断产生新的微任务,事件循环也会一直处理,直到队列完全为空——这被称为“递归微任务”或“微任务风暴”,它会阻塞宏任务的执行,甚至让页面假死。正如服务员不停地塞小纸条、主厨永远在解决小纸条,大订单一个都顾不上,餐厅迟早乱套。
微任务“插队”的特性,是 Promise 能高效工作的底层原因。理解这一条,你就掌握了面试里最常考的 “setTimeout vs Promise 谁先执行” 问题的钥匙:不是看谁代码先写,而是看谁进什么队列。
- 宏任务结束调用栈从非空变为空,当前宏任务执行完毕。
- 清空微队列事件循环反复取出微任务执行,直到微队列为空;期间新加入的微任务也会被继续消费。
- 按需渲染浏览器检查是否有渲染需求,决定是否绘制新帧。
- 下一宏任务从宏队列取出下一个任务,开始新一轮。
Promise微任务
你点了一道菜,服务员给你一张回执小票,上面写着“您的菜好了会通知您”。Promise 就是这张回执。它本身不代表菜已经好,而是承诺将来一定会给你一个结果——成功(fulfilled)或失败(rejected)。当结果确定,你的手机(then 回调)就会响起。
从事件循环视角看,Promise 的回调之所以总在 setTimeout 之前执行,是因为 Promise 的回调会进入微任务队列,而非宏任务队列。当 promise.then(f) 被调用时,如果 Promise 状态已经是 settled(已确定),f 会立刻被安排进微任务队列;如果还是 pending(等待中),f 会被挂到该 promise 的 handlers 列表里,等状态确定后再进入微队列。
所以 Promise 的链式调用,比如 p.then(...).then(...),本质是往微任务队列里一级一级排任务。第一级回调执行完,返回的新 Promise 再注册第二级回调,于是第二级也排进微任务队列。这就是为什么 Promise 链的所有步骤会在“同一个宏任务结束后连续跑完”,中间不会插入其他宏任务。
回到厨房,回执小票的好处是:主厨不用停下手头的活去追着菜跑,他只需要在每道菜出锅的空隙,按小票上的顺序通知顾客。Promise 把“异步结果的获取”变成了“有秩序的通知”,让复杂异步逻辑像流水线一样清晰——这是现代前端异步编程的基石。
- 状态确定Promise 经过 resolve 或 reject,从 pending 变为 fulfilled 或 rejected。
- 注册回调
then(onFulfilled, onRejected)把用户回调挂到对应的处理函数集合。 - 排队微任务状态确定后,用户回调以微任务身份进入微任务队列,等待当前宏任务结束。
渲染与rAF
餐厅的体验不只靠菜好吃,还靠上菜节奏:菜一道道出,服务员一道道端,客人看着桌面一点点丰富起来。浏览器的渲染(Paint)就是这个上菜过程。但渲染不是随时进行的,它有固定时机——通常在宏任务和微任务都处理完之后。
在两次任务之间,浏览器会先执行 requestAnimationFrame(rAF) 注册的回调,再进行渲染绘制。可以把 rAF 理解为“上菜前的最后摆盘”:主厨把菜做好了(宏任务),服务员把细节通知处理完(微任务),端菜前再擦一下盘边(rAF),最后才端上桌(渲染)。如果你用 setTimeout 改样式,浏览器可能把多次修改合并到下一次渲染,而 rAF 能保证你在这帧渲染前拿到最新的布局信息。
这种机制适合做动画和帧率控制:比如 60Hz 的屏幕,每帧大约 16.7ms,浏览器在每帧内执行一次“宏任务→微任务→rAF→渲染”的循环。如果你的宏任务或微任务耗时超过 16.7ms,就会导致掉帧(卡顿)。因此,长任务应该切片到多个宏任务中,让每帧的“预算”不被击穿。
前端做动画时,如果你把所有样式改变都塞进 rAF,就能保证“这一帧只绘制一次最终状态”,避免中间态闪烁。理解了 rAF 的位置,你在写滚动轮播、图表动画、骨架屏时,就能主动选择正确的 API,而不是一律 setTimeout。
- 宏任务结束一个宏任务(例如点击回调)执行完,调用栈清空。
- 微任务清空事件循环处理完所有微任务队列中的回调。
- 执行 rAF浏览器在渲染前,执行 rAF 注册的回调,常用于读取滚动位置、计算动画。
- 绘制新帧浏览器把 DOM/CSS 变化合成并绘制到屏幕上。
浏览器与Node
同样是开餐厅,街边小吃店(浏览器)和中央厨房(Node)的经营流程很不一样。小吃店以“给顾客上菜”为中心:每个宏任务后要清微任务、要渲染、要处理动画。中央厨房则以“准时备餐”为中心:它按阶段处理定时器、I/O、连接等,并不需要渲染页面。
Node.js 的事件循环由 libuv 库实现,它是一个多阶段循环,主要有:timers(定时器回调)、pending callbacks(推迟到下一轮的 I/O 回调)、poll(轮询,处理新 I/O 事件并阻塞等待)、check(执行 setImmediate 回调)、close callbacks(关闭事件回调)。每个阶段都有对应的队列,阶段之间会清空微任务。
最关键的差异是 process.nextTick:它的优先级高于 Promise 微任务。每次进入一个阶段、或执行完一个回调后,Node 会先清空 nextTick 队列,再清空 Promise 微任务队列。所以在 Node 里写 process.nextTick 时,它会比任何 Promise.then 都先执行——相当于老板亲自塞给主厨的“最高优先级纸条”。
在 Node 后端写代码时,不要把 process.nextTick 和 Promise.then 混为一谈,否则可能踩到执行顺序的坑。浏览器环境没有 nextTick,只有微任务队列;Node 环境有专门的 nextTick 队列——这就是“同样是事件循环,两个平台行为不同”的根本原因。
- timers 阶段执行所有到期的
setTimeout和setInterval回调。 - poll 阶段处理新的 I/O 事件;如果没有事件则阻塞等待。
- check 阶段执行
setImmediate注册的回调。 - nextTick 优先任何阶段切换时,
process.nextTick队列都先于 Promise 微队列被清空。
异步顺序实战
理论都讲完了,现在开始“全店营业演练”。假设我们同时收到一张大订单(一段脚本),里面有同步任务、也有微任务和宏任务。按照厨房规则,主厨会先把手头所有菜做完(同步代码),再迅速处理小纸条(微任务),最后才轮到订单板上其他大单(宏任务)。
给出一道经典顺序题:先执行 console.log(1);然后 Promise.resolve().then(() => console.log(2));再 setTimeout(() => console.log(3), 0)。实际输出是 1 → 2 → 3。为什么不是 1 → 3 → 2?因为同步代码 1 先执行,然后本轮宏任务结束,微任务 2 立刻被清空,最后宏任务 3 才被取到。那张“0ms 定时器”即便恨不得马上跑,也排不过微任务的插队。
调试技巧上,console.log 加时间戳、打点时标注“同步/微/宏”,就能直观看到调度顺序;性能优化上,如果发现页面卡顿,先看看是不是主线程执行了太长的同步任务,或某个宏任务里递归产生大量微任务。按“同步 → 微任务 → rAF/渲染 → 宏任务”的顺序审视代码,大多数异步 bug 都能定位。
“纸上得来终觉浅”,事件循环这章尤其适合拿真实代码边跑边悟。下次再看到“顺序诡异”的输出时,不要慌——把每行代码贴好“同步/微/宏”的标签,再用厨房经理的眼光明察秋毫,答案自然浮出水面。
- 同步执行当前脚本中的所有同步语句按顺序执行,调用栈从满到空。
- 清空微队列把当前已排队的微任务全部执行完。
- rAF/渲染进入渲染帧时,rAF 回调先执行,然后浏览器绘制。
- 宏任务事件循环从宏队列取出下一个宏任务,开始新一轮循环。
AI 应用场景
LLM 流式对话逐字渲染
阶段:AI 前端交互与流式渲染
解决的问题:大模型接口以流式(SSE/WebSocket)逐 token 返回内容。传统做法是每收到一个 token 就用 innerHTML 整体更新,导致主线程被频繁重排拖垮,页面卡顿甚至白屏。利用事件循环规则,将 token 文本追加封装为微任务,让每次数据到达在当前宏任务后紧凑执行;滚动容器的高度更新放到 requestAnimationFrame 中,保证一帧只滚动一次。
具体案例:某 AI 对话应用收到 2000 个 token 时,如果每 token 同步更新,耗时约 600ms 造成明显掉帧;改为微任务拼接文本 + rAF 控制滚动后,主线程每帧稳定在 16ms 内,实现了打字机般的平滑输出。
AI 数据大屏实时刷新
阶段:AI 实时数据可视化
解决的问题:AI 训练看板、证券行情等场景每秒钟推送数千条数据,如果每条数据到达都直接操作 DOM,宏任务队列会瞬间堆满,页面几乎无法点击。工程师把网络数据的解析放在宏任务中切片处理,把要绘制的图形数据收集起来,在 requestAnimationFrame 中统一绘制一帧,浏览器就能保持满帧运行。
具体案例:某智能工厂的产线监控大屏每 500ms 收到 2000 个传感器读数。前端用宏任务切片 + rAF 合帧,把重绘次数从每帧 20 次降到每帧 1 次,CPU 占用率下降 70%。
Node 网关并发调度多个 AI API
阶段:AI 后端服务编排
解决的问题:用户提问需要同时调用多个大模型 API(如 GPT-4 与本地模型对比),若逐个同步等待,Node 的事件循环会被长时间占用,其他请求全部排队。用 Promise.all 并行发起请求,每个响应到达后通过微任务更新结果,事件循环可以继续处理新的连接,网关吞吐量大幅提升。
具体案例:某 LLM 聚合网关将 3 个模型请求改为 Promise.all 后,单实例 QPS 从 80 提升到 320,且响应时长的“尾部延迟”明显下降。
Python 演示
# 模拟 JS 事件循环的迷你 Python 实现from collections import dequeimport heapq class EventLoop: def __init__(self): self.macro_q = deque() # 宏任务队列 self.micro_q = deque() # 微任务队列 self.timers = [] # 定时器最小堆 self.clock = 0 def add_macro(self, fn, name): self.macro_q.append((fn, name)) def add_micro(self, fn, name): self.micro_q.append((fn, name)) def add_timer(self, delay, fn, name): heapq.heappush(self.timers, (self.clock + delay, name, fn)) def run_ticks(self, n): for _ in range(n): # 到期的定时器转成宏任务 while self.timers and self.timers[0][0] <= self.clock: _, tname, tfn = heapq.heappop(self.timers) print(f" [到期] {tname} 入宏队列") self.macro_q.append((tfn, tname)) if not self.macro_q: print(f"T={self.clock}: 无宏任务") self.clock += 1 continue fn, name = self.macro_q.popleft() print(f"T={self.clock}: 执行宏任务 {name}") fn() while self.micro_q: mfn, mname = self.micro_q.popleft() print(f" -> 执行微任务 {mname}") mfn() self.clock += 1 def demo(): loop = EventLoop() def task_a(): print(" [A] 注册两个微任务") loop.add_micro(lambda: print(" [微] 微1"), "微1") loop.add_micro(lambda: print(" [微] 微2"), "微2") def task_b(): print(" [B] 注册一个定时器") loop.add_timer(2, lambda: print(" [宏] 延时任务"), "延时") loop.add_macro(task_a, "宏A") loop.add_macro(task_b, "宏B") loop.add_timer(1, lambda: print(" [宏] 到时任务"), "到时") loop.run_ticks(4) if __name__ == "__main__": demo()
self.macro_q = deque()用先进先出队列模拟浏览器宏任务队列。heapq.heappush(self.timers, (self.clock + delay, name, fn))用最小堆管理定时器,时间一到就交给宏队列。while self.timers and self.timers[0][0] <= self.clock:把当前已经到期的定时器全部转入宏任务队列。fn, name = self.macro_q.popleft()事件循环每轮只取一个宏任务,对应“主厨一次只做一道菜”。while self.micro_q:一个宏任务结束后立刻把所有微任务清空,这是最核心的调度规则。loop.run_ticks(4)让模拟循环跑 4 轮,可清楚看到定时器延迟进入队列的过程。
常见误区
误区我以为 `setTimeout(fn, 0)` 就是 0 毫秒后立刻执行 fn,所以它应该比普通函数更早执行,至少不会排队太久。
正解0 只是“最早入队时间”,意思是“至少 0ms 后把 fn 放进宏任务队列”。如果当前调用栈还在跑同步长任务,或者队列前面还有别的宏任务,fn 就必须等待。它是“尽量快”,而不是“现在就做”。厨房里定时器响了,主厨也得先忙完手头这锅菜。
为何常见:“0“这个数字太有欺骗性,直觉把”延迟 0ms“等同于”立刻执行”,忽略了事件循环排队的中间步骤。
误区把 `Promise.resolve().then(fn)` 写在 `setTimeout` 之前,发现 fn 先打印了,就以为 then 是同步回调,不需要排队。
正解then 的回调是微任务,虽然优先于宏任务,但仍然排在同步代码之后。同步代码是“主厨手头在做的菜”,微任务是小纸条,主厨做完当前这道菜才处理。所以 then 永远不可能比它前面的同步代码先执行。
为何常见:微任务与同步代码间隔极小,日志里往往紧挨着打出来,初学者容易把“先于 setTimeout”误判为“同步”。
误区我以为每执行一个宏任务,就交替执行一个微任务,像煎饼一样一层夹一层。
正解不是轮流。一个宏任务结束后,必须把微任务队列全部清空,才能开始下一个宏任务。即使微任务里有 100 个任务,也会连续执行 100 个,期间不取任何宏任务。只有微队列为空,事件循环才会回到宏队列。
为何常见:当队列里恰好各一个任务时,日志显示出“宏→微→宏→微”的假节奏,让人误以为二者按 1:1 轮换。
误区rAF 和 setTimeout 一样,都是把回调丢进某个队列延迟执行,只是频率受刷新率限制,所以我把 rAF 归为宏任务。
正解rAF 有独立的调度时机,它在“宏任务+微任务处理完成之后、浏览器渲染之前”执行。它不是从宏队列取出的任务,而是渲染帧流程的一部分。浏览器保证每一帧最多回调一次,这是 setTimeout 无法做到的。
为何常见:从“推迟执行”的外观看,rAF 和定时器很像,容易让人忽略它跟渲染帧的强绑定关系。
误区既然都叫事件循环,浏览器和 Node 应该行为一致,所以我把 Node 里的 process.nextTick 当成微任务,和 Promise 一样排队。
正解Node 的 libuv 循环按阶段执行:timers → poll → check 等,而且 process.nextTick 有自己的队列,优先级比 Promise 微任务更高。同样一段异步代码,在浏览器和 Node 里执行顺序可能不同。餐厅模式不同,后厨流程自然不同。
为何常见:官方文档把 nextTick 和微任务都归为“异步 API”,如果不看执行顺序的优先级,很容易画上等号。
面试题
Q1. JavaScript 中“调用栈”最准确的描述是?
- A.存储所有变量的内存空间
- B.后进先出的函数调用记录栈← 正确答案
- C.保存事件回调的执行队列
- D.浏览器绘制图像的高速缓存
答案:B。调用栈的本质是后进先出(LIFO)的函数调用记录结构。每次函数调用压入一个执行上下文,函数返回时弹出。A 是堆的概念,C 是任务队列,D 是渲染相关缓存,都和调用栈无关。
Q2. 下列哪一项属于宏任务?
- A.Promise 的 then 回调
- B.queueMicrotask 注册的回调
- C.setImmediate 注册的回调← 正确答案
- D.MutationObserver 触发的回调
答案:C。宏任务包括 setTimeout、setInterval、setImmediate、I/O 事件和 UI 渲染等。Promise.then、queueMicrotask、MutationObserver 都是微任务。所以选 setImmediate。
Q3. 以下哪些属于微任务?
- A.Promise 的 then 回调← 正确答案
- B.process.nextTick 回调← 正确答案
- C.setTimeout 延迟回调
- D.queueMicrotask 回调← 正确答案
答案:ABD。Promise.then、process.nextTick、queueMicrotask 都属于微任务/nextTick 队列;setTimeout 回调属于宏任务。微任务会在宏任务结束后优先清空,因此 A、B、D 会先于 C 执行。
Q4. 关于一段 JS 代码的执行顺序,正确的是?
- A.同步代码 → 微任务 → 宏任务← 正确答案
- B.宏任务 → 同步代码 → 微任务
- C.微任务 → 宏任务 → 同步代码
- D.同步代码 → 宏任务 → 微任务
答案:A。主线程先执行当前脚本的同步代码;执行结束后清空微任务队列;之后才从宏任务队列取下一个宏任务。所以是“同步 → 微任务 → 宏任务”,这是事件循环的基本调度顺序。
Q5. setTimeout(fn, 0) 表示什么?
- A.fn 立即同步执行
- B.fn 在当前宏任务结束后执行
- C.fn 尽快进入宏队列执行← 正确答案
- D.fn 指定 0 毫秒后执行
答案:C。0 是最小延迟时间,表示 fn 最快在 0ms 后被放进宏任务队列,而不是立即执行。若调用栈正忙或队列前面有任务,fn 还会更晚。因此“尽快进入宏队列”最准确。
Q6. 关于 requestAnimationFrame 描述正确的是?
- A.发生在浏览器渲染之前← 正确答案
- B.在宏微任务之后执行← 正确答案
- C.微任务清空后才触发← 正确答案
- D.属于宏任务的一种
答案:ABC。rAF 是渲染帧流程的一部分:宏任务结束 → 微任务清空 → 执行 rAF 回调 → 渲染绘制。它不属于宏任务队列,因此 D 错误。A、B、C 都描述了 rAF 的正确时机,适合做动画与合帧渲染。
Q7. 在 Node.js 事件循环中,关于 process.nextTick 说法正确的是?
- A.nextTick 与 Promise 微任务同时执行
- B.nextTick 早于 Promise 微任务执行← 正确答案
- C.Promise 微任务早于 nextTick 执行
- D.nextTick 与宏任务一起排队执行
答案:B。Node 中 process.nextTick 拥有独立队列,优先级最高,它在每个阶段切换时先于 Promise 微任务被清空。A 错在“同时”,C 顺序反了,D 错在把 nextTick 归入宏任务。
Q8. 执行 console.log(1); Promise.resolve().then(() => console.log(2)); setTimeout(() => console.log(3), 0); 输出顺序是?
- A.输出顺序为 1 → 2 → 3← 正确答案
- B.输出顺序为 1 → 3 → 2
- C.输出顺序为 3 → 2 → 1
- D.输出顺序为 2 → 1 → 3
答案:A。同步代码 1 先执行;随后宏任务结束后清空微任务,2 被打印;最后事件循环才取到宏任务 setTimeout,打印 3。因此顺序是 1 → 2 → 3,再次印证微任务优先于宏任务。
Q9. 以下哪些操作会阻塞事件循环?
- A.长循环同步处理海量数据← 正确答案
- B.宏任务中递归派发微任务← 正确答案
- C.用定时器分片处理大任务
- D.同步执行复杂字符串解析← 正确答案
答案:ABD。长循环和复杂解析会长时间占用调用栈,让宏微任务都无法执行;宏任务里递归派发微任务会导致微队列永远清不空,同样卡死循环。用定时器分片处理是把大任务切碎放进宏队列,反而能避免阻塞。
Q10. 浏览器中一次事件循环迭代的正确顺序是?
- A.宏任务→渲染→微任务的方向
- B.宏任务→微任务→rAF→渲染← 正确答案
- C.微任务→宏任务→渲染的顺序
- D.rAF→宏任务→微任务的先后
答案:B。浏览器每轮的完整顺序为:执行一个宏任务 → 清空微任务队列 → 执行 rAF 回调 → 渲染绘制。宏任务不可能在微任务之后,rAF 也不在宏任务前,所以只有 B 符合事件循环的真实流程。