想象一家餐厅:厨房里只有一位主厨(主线程),他站在唯一的灶台(调用栈)前,一道菜一道菜地做。顾客点单、外卖通知、后厨定时器响铃……这些“待办事项”被写在不同的小本本上,堆成几摞——这就是任务队列。主厨做完手头的菜,就去翻翻哪一摞上的活最急,然后接着做下一道。而“微任务”就像服务员手里的即时小纸条,主厨每做完一道菜,必须先把这些小纸条处理完,才能接新订单。这套“做完一道→处理急单→再取下一道”的循环节奏,就是事件循环。 JavaScript 是单线程语言,可浏览器里又要响应点击、又要跑动画、还要发网络请求,为什么界面还能流畅运行?答案就藏在这套调度机制里。这篇文章用一家厨房的视角,带你彻底看清:哪些活算“宏任务”、哪些算“微任务”、Promise 为什么总爱插队、页面渲染在什么时候发生、Node 的厨房和浏览器有什么不同。

岗位关联

搞懂事件循环是理解 JavaScript 异步世界的地基,也是面试必问、写高性能前端/Node 代码的底层能力。本课让你从“背结论”升级为“看得懂调度”。

目录
  1. 可视化演示
  2. 调用栈与上下文
  3. 事件循环机制
  4. 宏任务队列
  5. 微任务队列
  6. Promise微任务
  7. 渲染与rAF
  8. 浏览器与Node
  9. 异步顺序实战
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

在有序数组中查找目标值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 继续调味道。理解了这个栈式节奏,才能明白为什么“同步代码会卡住后续的一切”。

调用栈=[ε1,ε2,…,εn],后进先出\text{调用栈} = [\varepsilon_1, \varepsilon_2, \ldots, \varepsilon_n], \quad \text{后进先出}
调用栈的本质:函数嵌套调用时,先压栈后弹栈,所以最后调用的函数最先执行完。
  1. 上下文创建每次函数调用都会生成一个新的执行上下文,其中包含参数、局部变量与作用域链,相当于给这道菜单独准备一个菜板。
    εnew={params,vars,scope}\varepsilon_{\text{new}} = \{\text{params}, \text{vars}, \text{scope}\}
  2. 压栈入栈新上下文被压到栈顶,只有栈顶的上下文正在执行。
    callStack.push(εnew)\text{callStack.push}(\varepsilon_{\text{new}})
  3. 出栈返回函数 return 后,栈顶上下文弹出销毁,控制权交还给下一个栈帧。
    εtop=callStack.pop()\varepsilon_{top} = \text{callStack.pop}()

事件循环机制

既然主厨只有一个人,那餐厅怎么同时接几十桌客人的单?答案是:主厨并不“同时做”,而是快速轮换。他把所有订单先按种类记下来,做完手里的菜就回头取下一单。这个“做完一单→看下一单”的周而复始,就是事件循环(Event Loop)。

JavaScript 的主线程就是那位主厨。它同一时刻只执行一个任务:执行调用栈中的代码。当一个宏任务执行完毕栈空时,事件循环登场,它负责从宏任务队列取一个任务塞给主厨,等主厨做完,又去检查微任务队列,把微任务依次清空,然后询问浏览器:“要不要渲染一下?”需要就渲染,再取下一个宏任务。

事件循环不是 JavaScript 语言本身的东西,而是宿主环境(浏览器或 Node)提供的调度器。在浏览器里,它由“事件循环线程”这件事承载;在 Node 里,它由 libuv 库实现。但二者的核心骨架一致:取宏任务 → 执行 → 清空微任务 → 按需渲染 → 下一个宏任务。理解这个循环,就拿到了异步世界的地图。

厨房经理每小时走一圈:收新单(宏任务),处理服务员递来的急单(微任务),再让人把出锅的菜端上去(渲染)。只要经理还在转,餐厅就永远不会“饿死”——这就是事件循环最重要的价值:让单线程永不空闲。

循环:取宏任务→执行并清空微任务→渲染→下一次迭代\text{循环}: \quad \text{取宏任务} \rightarrow \text{执行并清空微任务} \rightarrow \text{渲染} \rightarrow \text{下一次迭代}
事件循环是“调度总纲”:宏任务、微任务、渲染都在这一个大循环中按固定顺序运转。
  1. 取宏任务从宏任务队列头部取出一个任务,把它交给调用栈执行。
    task=macroQueue.dequeue()\text{task} = \text{macroQueue.dequeue}()
  2. 清空微队列宏任务执行完毕后,立刻把所有微任务按顺序执行完,执行过程中新产生的微任务也要继续执行,直到队列为空。
    while(microQueue≠∅)  execute()\text{while} (\text{microQueue} \neq \emptyset) \; \text{execute}()
  3. 渲染更新如果页面有变化,浏览器会绘制新的一帧,把结果呈现给用户。
    renderFrame()\text{renderFrame}()
  4. 继续迭代回到第 1 步,取下一个宏任务,如此周而复始。
    goto  step1\text{goto} \; \text{step} 1

宏任务队列

餐厅里有一块巨大的订单板,上面贴满了顾客的各类需求:点餐、加菜、叫服务员。这些订单不可能立刻全部处理,主厨按顺序一张张揭下来做。宏任务(MacroTask)就是这些订单。

宏任务的来源主要有四类:setTimeout / setInterval 定时器回调、用户事件回调(点击、键盘、滚动)、I/O 回调(网络请求完成)、以及渲染相关任务。这些任务都会进入宏任务队列,以先进先出(FIFO)的顺序等待事件循环取用。

关键点是定时器延迟只是“最小时间”。setTimeout(fn, 0) 并不是 0ms 后立刻执行,而是“至少 0ms 后才把 fn 放进宏任务队列”。如果主线程正在执行一段长任务(比如一个 2 秒的死循环),定时器回调就只能乖乖在队列里等待。这就像后厨写着“煮蛋 2 分钟”,但主厨手上有一锅硬菜,蛋熟了也只能放在出餐台上等着。

所以当你用 setTimeout 拆分重任务时,本质是在订单板上重新排队,让老板(用户)能喘口气看到“加载中”的转圈动画。宏任务队列的先进先出,决定了所有异步回调的“先后到达顺序”,这是后续排错的重要线索。

setTimeout(f,tmin)  ⇒  f 最早 tmin 后进入宏队列\text{setTimeout}(f, t_{min}) \;\Rightarrow\; f \text{ 最早 } t_{min} \text{ 后进入宏队列}
setTimeout 的时间参数是最短等待时间,不是精确执行时间;真正执行要等事件循环取到这个宏任务。
  1. 定时器注册调用 setTimeout 时,浏览器把回调交给计时器线程计时,返回一个 timer ID。
    timerID=registerTimer(f,tmin)\text{timerID} = \text{registerTimer}(f, t_{min})
  2. 计时到期时间到达后,回调并不执行,而是被放入宏任务队列末尾。
    f→macroQueuef \rightarrow \text{macroQueue}
  3. 循环取出事件循环从宏队列头部取出该回调,在调用栈空闲时执行。
    f→event loopcallStackf \xrightarrow{\text{event loop}} \text{callStack}

微任务队列

服务员手里总会有一叠“即时小纸条”:某桌客人要加水、某桌要结账、某道菜客人催单……这些小条子优先级极高,主厨每做完一道菜就必须赶紧处理,哪怕订单板上还有一堆大订单在排队。微任务(MicroTask)就是这些小纸条。

微任务的来源主要有三类:Promise.then / catch / finally 回调、queueMicrotask() 显式注册的任务、浏览器 MutationObserver 回调。微任务也有自己的队列,它跟宏任务队列最大的区别是处理时机:一个宏任务结束后,事件循环会一次性清空所有微任务,处理完微任务队列之后,才会去取下一个宏任务。

这意味着即使微任务队列中不断产生新的微任务,事件循环也会一直处理,直到队列完全为空——这被称为“递归微任务”或“微任务风暴”,它会阻塞宏任务的执行,甚至让页面假死。正如服务员不停地塞小纸条、主厨永远在解决小纸条,大订单一个都顾不上,餐厅迟早乱套。

微任务“插队”的特性,是 Promise 能高效工作的底层原因。理解这一条,你就掌握了面试里最常考的 “setTimeout vs Promise 谁先执行” 问题的钥匙:不是看谁代码先写,而是看谁进什么队列。

宏任务结束⇒微队列全部清空⇒渲染⇒下一个宏任务\text{宏任务结束} \Rightarrow \text{微队列全部清空} \Rightarrow \text{渲染} \Rightarrow \text{下一个宏任务}
微任务队列的优先级高于宏任务队列:每个宏任务结束,必须先清空所有微任务,才能进入下一轮。
  1. 宏任务结束调用栈从非空变为空,当前宏任务执行完毕。
    callStack=∅\text{callStack} = \emptyset
  2. 清空微队列事件循环反复取出微任务执行,直到微队列为空;期间新加入的微任务也会被继续消费。
    while  (microQueue≠∅):dequeueAndExecute()\text{while} \; (\text{microQueue} \neq \emptyset): \text{dequeueAndExecute}()
  3. 按需渲染浏览器检查是否有渲染需求,决定是否绘制新帧。
    maybeRender()\text{maybeRender}()
  4. 下一宏任务从宏队列取出下一个任务,开始新一轮。
    nextTask=macroQueue.dequeue()\text{nextTask} = \text{macroQueue.dequeue}()

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(x)  ⇒  .then(f)  ⇒  f∈microQueue\text{Promise.resolve}(x) \;\Rightarrow\; \text{.then}(f) \;\Rightarrow\; f \in \text{microQueue}
Promise 的 then 回调总在微任务队列中排队,这就是“Promise 总比定时器先执行”的根本原因。
  1. 状态确定Promise 经过 resolve 或 reject,从 pending 变为 fulfilled 或 rejected。
    state∈{pending,fulfilled,rejected}\text{state} \in \{\text{pending}, \text{fulfilled}, \text{rejected}\}
  2. 注册回调then(onFulfilled, onRejected) 把用户回调挂到对应的处理函数集合。
    handlers={onFulfilled,onRejected}\text{handlers} = \{ \text{onFulfilled}, \text{onRejected} \}
  3. 排队微任务状态确定后,用户回调以微任务身份进入微任务队列,等待当前宏任务结束。
    f→microQueuef \rightarrow \text{microQueue}

渲染与rAF

餐厅的体验不只靠菜好吃,还靠上菜节奏:菜一道道出,服务员一道道端,客人看着桌面一点点丰富起来。浏览器的渲染(Paint)就是这个上菜过程。但渲染不是随时进行的,它有固定时机——通常在宏任务和微任务都处理完之后。

在两次任务之间,浏览器会先执行 requestAnimationFrame(rAF) 注册的回调,再进行渲染绘制。可以把 rAF 理解为“上菜前的最后摆盘”:主厨把菜做好了(宏任务),服务员把细节通知处理完(微任务),端菜前再擦一下盘边(rAF),最后才端上桌(渲染)。如果你用 setTimeout 改样式,浏览器可能把多次修改合并到下一次渲染,而 rAF 能保证你在这帧渲染前拿到最新的布局信息。

这种机制适合做动画和帧率控制:比如 60Hz 的屏幕,每帧大约 16.7ms,浏览器在每帧内执行一次“宏任务→微任务→rAF→渲染”的循环。如果你的宏任务或微任务耗时超过 16.7ms,就会导致掉帧(卡顿)。因此,长任务应该切片到多个宏任务中,让每帧的“预算”不被击穿。

前端做动画时,如果你把所有样式改变都塞进 rAF,就能保证“这一帧只绘制一次最终状态”,避免中间态闪烁。理解了 rAF 的位置,你在写滚动轮播、图表动画、骨架屏时,就能主动选择正确的 API,而不是一律 setTimeout。

宏任务→微任务→rAF→渲染\text{宏任务} \rightarrow \text{微任务} \rightarrow \text{rAF} \rightarrow \text{渲染}
rAF 不是宏任务,而是渲染帧流程的一部分:它在宏任务、微任务之后、浏览器绘制之前执行。
  1. 宏任务结束一个宏任务(例如点击回调)执行完,调用栈清空。
    taskDone⇒stack empty\text{taskDone} \Rightarrow \text{stack empty}
  2. 微任务清空事件循环处理完所有微任务队列中的回调。
    microQueue drained\text{microQueue drained}
  3. 执行 rAF浏览器在渲染前,执行 rAF 注册的回调,常用于读取滚动位置、计算动画。
    rAF callbacks→execute\text{rAF callbacks} \rightarrow \text{execute}
  4. 绘制新帧浏览器把 DOM/CSS 变化合成并绘制到屏幕上。
    paint()\text{paint}()

浏览器与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 队列——这就是“同样是事件循环,两个平台行为不同”的根本原因。

Node:  timers→poll→check,nextTick≻Promise\text{Node}: \;\text{timers} \rightarrow \text{poll} \rightarrow \text{check}, \quad \text{nextTick} \succ \text{Promise}
浏览器事件循环以渲染为中心;Node 的 libuv 循环以 I/O 阶段为中心,且 nextTick 优先于 Promise 微任务。
  1. timers 阶段执行所有到期的 setTimeout 和 setInterval 回调。
    timers→execute due callbacks\text{timers} \rightarrow \text{execute due callbacks}
  2. poll 阶段处理新的 I/O 事件;如果没有事件则阻塞等待。
    poll→wait for I/O\text{poll} \rightarrow \text{wait for I/O}
  3. check 阶段执行 setImmediate 注册的回调。
    setImmediate→execute\text{setImmediate} \rightarrow \text{execute}
  4. nextTick 优先任何阶段切换时,process.nextTick 队列都先于 Promise 微队列被清空。
    nextTickQueue≻promiseMicroQueue\text{nextTickQueue} \succ \text{promiseMicroQueue}

异步顺序实战

理论都讲完了,现在开始“全店营业演练”。假设我们同时收到一张大订单(一段脚本),里面有同步任务、也有微任务和宏任务。按照厨房规则,主厨会先把手头所有菜做完(同步代码),再迅速处理小纸条(微任务),最后才轮到订单板上其他大单(宏任务)。

给出一道经典顺序题:先执行 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 都能定位。

“纸上得来终觉浅”,事件循环这章尤其适合拿真实代码边跑边悟。下次再看到“顺序诡异”的输出时,不要慌——把每行代码贴好“同步/微/宏”的标签,再用厨房经理的眼光明察秋毫,答案自然浮出水面。

S=[同步▹微▹rAF▹渲染▹宏],往复循环S = [\text{同步} \triangleright \text{微} \triangleright \text{rAF} \triangleright \text{渲染} \triangleright \text{宏}], \quad \text{往复循环}
一条脚本的执行总顺序:先跑完同步代码,再清空微任务,最后才轮到宏任务;帧循环中还要插入 rAF 和渲染。
  1. 同步执行当前脚本中的所有同步语句按顺序执行,调用栈从满到空。
    sync code→stack drained\text{sync code} \rightarrow \text{stack drained}
  2. 清空微队列把当前已排队的微任务全部执行完。
    microQueue drained\text{microQueue drained}
  3. rAF/渲染进入渲染帧时,rAF 回调先执行,然后浏览器绘制。
    rAF→paint\text{rAF} \rightarrow \text{paint}
  4. 宏任务事件循环从宏队列取出下一个宏任务,开始新一轮循环。
    next macro→execute\text{next macro} \rightarrow \text{execute}

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 演示

python63 行
# 模拟 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()
目的:用 Python 模拟事件循环的调度逻辑——观察宏任务执行完毕后,微任务必须先清空,再到期的定时器才进入宏队列。
  1. self.macro_q = deque()用先进先出队列模拟浏览器宏任务队列。
  2. heapq.heappush(self.timers, (self.clock + delay, name, fn))用最小堆管理定时器,时间一到就交给宏队列。
  3. while self.timers and self.timers[0][0] <= self.clock:把当前已经到期的定时器全部转入宏任务队列。
  4. fn, name = self.macro_q.popleft()事件循环每轮只取一个宏任务,对应“主厨一次只做一道菜”。
  5. while self.micro_q:一个宏任务结束后立刻把所有微任务清空,这是最核心的调度规则。
  6. 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 中“调用栈”最准确的描述是?

单选题 · easy

  • A.存储所有变量的内存空间
  • B.后进先出的函数调用记录栈← 正确答案
  • C.保存事件回调的执行队列
  • D.浏览器绘制图像的高速缓存

答案:B。调用栈的本质是后进先出(LIFO)的函数调用记录结构。每次函数调用压入一个执行上下文,函数返回时弹出。A 是堆的概念,C 是任务队列,D 是渲染相关缓存,都和调用栈无关。

Q2. 下列哪一项属于宏任务?

单选题 · easy

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

答案:C。宏任务包括 setTimeout、setInterval、setImmediate、I/O 事件和 UI 渲染等。Promise.then、queueMicrotask、MutationObserver 都是微任务。所以选 setImmediate。

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

多选题 · mid

  • A.Promise 的 then 回调← 正确答案
  • B.process.nextTick 回调← 正确答案
  • C.setTimeout 延迟回调
  • D.queueMicrotask 回调← 正确答案

答案:ABD。Promise.then、process.nextTick、queueMicrotask 都属于微任务/nextTick 队列;setTimeout 回调属于宏任务。微任务会在宏任务结束后优先清空,因此 A、B、D 会先于 C 执行。

Q4. 关于一段 JS 代码的执行顺序,正确的是?

单选题 · mid

  • A.同步代码 → 微任务 → 宏任务← 正确答案
  • B.宏任务 → 同步代码 → 微任务
  • C.微任务 → 宏任务 → 同步代码
  • D.同步代码 → 宏任务 → 微任务

答案:A。主线程先执行当前脚本的同步代码;执行结束后清空微任务队列;之后才从宏任务队列取下一个宏任务。所以是“同步 → 微任务 → 宏任务”,这是事件循环的基本调度顺序。

Q5. setTimeout(fn, 0) 表示什么?

单选题 · mid

  • A.fn 立即同步执行
  • B.fn 在当前宏任务结束后执行
  • C.fn 尽快进入宏队列执行← 正确答案
  • D.fn 指定 0 毫秒后执行

答案:C。0 是最小延迟时间,表示 fn 最快在 0ms 后被放进宏任务队列,而不是立即执行。若调用栈正忙或队列前面有任务,fn 还会更晚。因此“尽快进入宏队列”最准确。

Q6. 关于 requestAnimationFrame 描述正确的是?

多选题 · hard

  • A.发生在浏览器渲染之前← 正确答案
  • B.在宏微任务之后执行← 正确答案
  • C.微任务清空后才触发← 正确答案
  • D.属于宏任务的一种

答案:ABC。rAF 是渲染帧流程的一部分:宏任务结束 → 微任务清空 → 执行 rAF 回调 → 渲染绘制。它不属于宏任务队列,因此 D 错误。A、B、C 都描述了 rAF 的正确时机,适合做动画与合帧渲染。

Q7. 在 Node.js 事件循环中,关于 process.nextTick 说法正确的是?

单选题 · hard

  • 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); 输出顺序是?

单选题 · mid

  • 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. 以下哪些操作会阻塞事件循环?

多选题 · hard

  • A.长循环同步处理海量数据← 正确答案
  • B.宏任务中递归派发微任务← 正确答案
  • C.用定时器分片处理大任务
  • D.同步执行复杂字符串解析← 正确答案

答案:ABD。长循环和复杂解析会长时间占用调用栈,让宏微任务都无法执行;宏任务里递归派发微任务会导致微队列永远清不空,同样卡死循环。用定时器分片处理是把大任务切碎放进宏队列,反而能避免阻塞。

Q10. 浏览器中一次事件循环迭代的正确顺序是?

单选题 · hard

  • A.宏任务→渲染→微任务的方向
  • B.宏任务→微任务→rAF→渲染← 正确答案
  • C.微任务→宏任务→渲染的顺序
  • D.rAF→宏任务→微任务的先后

答案:B。浏览器每轮的完整顺序为:执行一个宏任务 → 清空微任务队列 → 执行 rAF 回调 → 渲染绘制。宏任务不可能在微任务之后,rAF 也不在宏任务前,所以只有 B 符合事件循环的真实流程。