一次性讲清楚事件循环机制(Event Loop)

事件循环(Event Loop)就是让单线程在"不阻塞"的前提下处理并发的核心机制。

为什么需要事件循环

JavaScript 是单线程语言,也就是说,它同一时刻只能做一件事。可它又要同时应付网络请求、定时器、用户点击、动画渲染------如果傻等每一件事完成,页面早就卡死了。现实中有大量"耗时但不该卡住页面"的操作。如果主线程停下来死等一个网络请求返回,那这几百毫秒里页面就完全僵住了,点击、滚动全部失效。

所以聪明的工程师们想出了一个好办法:把耗时操作交给浏览器底层(其他线程)去处理,完成后把对应的回调排进队列,主线程在空闲时再逐个取出执行。这套"交出去、排队、空闲时再处理"的调度机制,就是事件循环。它让单线程也能实现"看起来并发"的效果。

四个核心角色

整套机制由四个角色协作完成。先看它们的分工:调用栈 执行代码,Web APIs 在后台处理耗时操作,两条任务队列 存放等待执行的回调,事件循环在主线程空闲时把回调送回去执行。下面逐个展开。

调用栈(Call Stack)

当前正在执行的函数所在的地方。它遵循后进先出:函数被调用就压栈,返回就弹栈。所有同步代码都在这里跑。这里有一条贯穿全文的关键规则------只要调用栈不空,事件循环就不会去碰任何队列。换句话说,同步代码永远优先,异步回调只能等栈空了才有机会。

Web APIs

JS 引擎本身只能执行同步代码------它不会计时,也不会发网络请求。setTimeoutfetch、DOM 事件监听这些能力不属于 JS 语言 ,而是浏览器作为宿主环境额外提供的,真正的执行逻辑跑在主线程之外的其他线程上。这正是异步的根基:把耗时操作挪到别的线程,主线程才不会被占住。

两条任务队列

上面提到回调会"进队列排队",但队列其实有两条,优先级不同

队列 包含什么 优先级
微任务队列(Microtask) Promise.then / .catchawait 后的代码、queueMicrotaskMutationObserver
宏任务队列(Macrotask) setTimeoutsetInterval、I/O、UI 渲染、事件回调

进了队列不代表会马上执行,还得等事件循环来取;而事件循环只在调用栈清空时才来取。两条队列谁先被取,取多少,正是下一节要讲的核心规则。

事件循环(Event Loop)

前面三个角色是"场地和容器",事件循环才是那个不停转动的"调度员"。它做的事极其简单却又是整台机器的心脏:反复检查调用栈是否为空,一旦为空,就按固定优先级从队列里取回调送进栈执行。它永不停歇地转圈,这也正是"循环"二字的由来。它具体按什么顺序取,就是下面这条规则。

最关键的一条规则

整套机制可以浓缩成一个循环。每当调用栈清空,事件循环会:

  1. 把整个微任务队列全部清空(清空过程中新产生的微任务也要一起清完);
  2. 然后才取一个宏任务执行;
  3. 执行完这个宏任务后,再次清空所有微任务
  4. 回到第 2 步,循环往复。

一句话口诀:

同步代码先跑 → 清空所有微任务 → 取一个宏任务 → 再清空所有微任务 → ......

setTimeout(fn, 0) 为例,一个回调要经历四步才会真正执行:

  1. 交给底层 :主线程执行到这行时,把 fn 登记给浏览器的计时器线程,然后立刻返回、继续往下跑。此刻 fn 既不执行,也不在任何队列里,而是"挂"在 Web APIs 那边。
  2. 后台等待 :计时器线程独立倒计时(fetch 则是等服务器响应),主线程完全不受影响。
  3. 放进队列 :等待结束后,其他线程不能擅自往主线程的调用栈里塞东西(JS 是单线程的),它只能把 fn 放进任务队列排队------setTimeout 进宏任务队列,Promise.then 进微任务队列。
  4. 取出执行:事件循环不断检查调用栈是否清空,一旦清空,就按规则取出回调、压进调用栈执行。

所以 Web APIs、队列、事件循环是流水线上的三个工位,分工清晰:Web APIs 负责"在后台等",队列负责"等完后排队",事件循环负责"主线程空闲时叫号"。 一个回调的完整旅程是:

复制代码
调用栈(登记)→ Web APIs(后台等待)→ 队列(等待被取)→ 调用栈(执行)

记住一个最容易混淆的点:回调在"后台等待"阶段并不在任何队列里 ,只有等待完成的那一刻才被移进队列。这就解释了为什么 fetch 的回调往往排在最后------它在 Web APIs 那里等网络,等很久才进队列。

接下来看看题目,实操一下吧~

下面每道题都先给代码,请你先自己推一遍执行顺序,再往下看答案和解析。难度循序渐进。


题目 1:同步与延迟

js 复制代码
setTimeout(function a() {}, 1000);
setTimeout(function b() {}, 5000);
setTimeout(function c() {}, 0);
function d() {}
d();

a、b、c、d 的执行顺序是什么?


答案:d → c → a → b

很多人会凭书写顺序猜成 d a b c,但这个直觉只对同步代码成立。

执行同步代码时,三个 setTimeout 只是把回调交给计时器 ,并不执行回调本身。它们各自开始倒计时。注册完后继续往下走,d() 是普通函数调用,立刻在调用栈里执行------所以 d 第一个执行,它是同步代码,根本不进队列。

倒计时结束的早晚由延迟决定,回调才依次入队:

  • c 延迟 0ms → 最早入队
  • a 延迟 1000ms → 1 秒后入队
  • b 延迟 5000ms → 5 秒后入队

于是异步部分是 c → a → b ,拼起来就是 d c a b

关键认知:setTimeout 的第二个参数是延迟时间,决定回调何时入队,跟它写在第几行无关。 另外,setTimeout(c, 0) 的 0 也不是真的立刻------它仍是宏任务,必须等同步代码跑完、栈清空后才有机会执行,浏览器还会对其施加最小延迟(嵌套深时约 4ms)。


题目 2:谁是同步、谁在等网络

js 复制代码
fetch('https://www.google.com')
  .then(function a() {});
Promise.resolve()
  .then(function b() {});
Promise.reject()
  .catch(function c() {});

先想想:a、b、c 的执行顺序是什么?


答案:b → c → a

三个回调都是微任务,但入队时机不同。

  • fetch(...) 发起真正的网络请求,返回一个 pending 的 Promise。请求要等服务器响应(几十甚至几百毫秒),在此期间 .then(a) 没资格入队
  • Promise.resolve() 返回一个已经 resolve 的 Promise,.then(b) 立刻入队。
  • Promise.reject() 返回一个已经 reject 的 Promise,.catch(c) 立刻入队。

同步代码跑完时,微任务队列里是 [b, c],而 a 还在等网络。栈一空,事件循环清空微任务:先 b 后 c。等到 fetch 响应回来(远晚于此刻),a 才入队执行。

关键认知:决定微任务执行顺序的不是代码位置,而是它依附的 Promise 何时落定(settle)。 Promise.resolve() / Promise.reject() 创建即落定,fetch 要等真实网络。


题目 3:Promise.all 与并发

js 复制代码
const GOOGLE = 'https://www.google.com';
Promise.all([
  fetch(GOOGLE).then(function b() {}),
  fetch(GOOGLE).then(function c() {}),
]).then(function after() {});

先想想:b、c、after 的执行顺序是什么?


答案:b、c 顺序不确定(可能 c 先于 b),after 永远最后

Promise.all 的职责只有一个:等数组里所有 Promise 都 resolve 后,再触发 after 。它不控制 b 和 c 谁先谁后。

数组在传给 Promise.all 之前就已构造好,两个 fetch 几乎同时发出、并发进行。虽然 URL 相同,但它们是两个独立的网络请求 ,响应回来的时间受连接复用、调度、缓存、网络抖动影响,并不一致。谁先收到响应,谁的 .then 就先入队、先执行。所以 b → cc → b 都可能出现,这是非确定的

而 after 是确定的:Promise.all 必须等两个都 resolve 才 resolve,所以 after 永远殿后。

关键认知:Promise.all 保证结果数组的顺序(results[0] 一定对应第一个 Promise),但不保证回调的执行顺序。 并发请求之间是一场赛跑,书写顺序不构成执行顺序。


题目 4:抛错、catch 与链条接力

js 复制代码
Promise.resolve()
  .then(function a() {
    Promise.resolve().then(function d() {})
    Promise.resolve().then(function e() {})
    throw new Error('OH TEH NOEZ!')
    Promise.resolve().then(function f() {})
  })
  .catch(function b() {})
  .then(function c() {})

先想想:哪些函数会执行,顺序是什么?


答案:a → d → e → b → c(f 永远不执行)

逐步推演:

  1. 同步阶段:.then(a) 把 a 入队。.catch(b).then(c) 此刻还不入队------要等前一环 settle 才被激活。
  2. 执行 a:先 d 入队、e 入队,然后 throw 中断函数 ------f 那行永远执行不到,从未被注册。a 抛错使它的 Promise 变成 rejected
  3. 此刻队列是 [d, e]。a 既然 reject,下一环 .catch(b) 现在才有资格入队,排到队尾 → [d, e, b]
  4. 依次执行 d、e、b。b 正常返回(没再抛错),它的 Promise 变成 resolved ,于是 .then(c) 入队。
  5. 执行 c,结束。

三个核心要点:

  • throw 之后是死代码:f 不是延迟执行,而是压根没被注册。
  • 链式回调一环推一环地激活:每一环必须等上一环 settle 才入队,所以 d、e(在 a 内部直接创建)能插到 b 前面------b 要等 a 执行完、确认 reject 后才入队,慢了一拍。
  • catch 捕获后链条恢复正常 :b 处理掉异常且没再抛错,后面的 .then(c) 当作正常流程继续。

题目 5:把 setTimeout 包装成非阻塞延时

js 复制代码
const pause = (millis) =>
  new Promise(resolve => setTimeout(resolve, millis));
const start = Date.now();
console.log('Start');
pause(1000).then(() => {
  const end = Date.now();
  const secs = (end - start) / 1000;
  console.log('End:', secs);
});

先想想:输出是什么?


答案:先打印 Start,约 1 秒后打印 End: 1(实际是 1.00x 这样的小数)

pause 是手写延时的标准模式。它返回一个 Promise,创建时为 pending ,内部启动了 setTimeout(resolve, millis)------把 resolve 本身作为回调交给计时器,意思是"millis 毫秒后调用 resolve"。计时未到,Promise 一直挂着;到点后 resolve() 被调用,Promise 才 resolve,.then 回调这才入队执行。

时间轴:

复制代码
t = 0ms      console.log('Start')   → 打印 "Start"
t = 0ms      pause(1000) 启动计时器,返回 pending Promise
t = 0ms      同步代码跑完,栈清空,引擎空闲等待
   ...(约 1000ms 后台计时)...
t ≈ 1000ms   resolve() 被调用 → Promise resolve → .then 入队执行

secs 不是精确的 1,因为 setTimeout 承诺的是"至少 等 1000ms"------到点后回调还要排队、等栈空闲才执行,实际间隔通常略大于 1000ms。setTimeout 从来不是精确计时器。

这个模式的价值在于配合 async/await 写出非阻塞延时:

js 复制代码
async function demo() {
  console.log('Start');
  await pause(1000);   // 非阻塞地"等"1秒,引擎此间可处理其他任务
  console.log('End');
}

补充:await 后面的代码本质上等价于写在 .then 里------都是微任务 。前面关于微任务顺序的规则在 async/await 里同样适用,只是语法糖让它看起来不像。

彩蛋:切片 vs Web Worker:怎么选

遇到CPU 密集的长任务该怎么处理。除了宏任务切片,还有一个更彻底的方案------Web Worker,它在独立线程跑,有自己的事件循环,怎么算都不影响主线程。

js 复制代码
// main.js
const worker = new Worker('primes.js');
worker.onmessage = e => console.log(e.data);

// primes.js (Worker 内部,独立线程)
let n = 1;
while (true) {
  if (isPrime(n)) postMessage(n);
  n++;
}

两种方案的取舍:

切片(setTimeout) Web Worker
运行线程 主线程 独立线程
是否阻塞 UI 否(每段让出) 完全不阻塞
计算速度 慢(受调度延迟拖累) 全速
实现复杂度 中(需消息通信)
适用场景 中等任务、简单实现 CPU 密集、长时间计算

一句话:切片是"把大任务剁碎、礼让着跑",Worker 是"换个房间全力跑"。

彩蛋2: Node.js 里的不同

浏览器和 Node 的事件循环大方向一致,但 Node 基于 libuv,宏任务分成更细的几个阶段(timers、pending、poll、check、close),还多了两个特殊角色:

  • process.nextTick:优先级比 Promise 微任务还高;
  • setImmediate:在 check 阶段执行,区别于 setTimeout

前端先吃透浏览器模型,需要写 Node 时再补这部分即可。

(以上例子都在JS Visualizer 9000中,更多例子可以自己去看看)