事件循环(Event Loop)就是让单线程在"不阻塞"的前提下处理并发的核心机制。
为什么需要事件循环
JavaScript 是单线程语言,也就是说,它同一时刻只能做一件事。可它又要同时应付网络请求、定时器、用户点击、动画渲染------如果傻等每一件事完成,页面早就卡死了。现实中有大量"耗时但不该卡住页面"的操作。如果主线程停下来死等一个网络请求返回,那这几百毫秒里页面就完全僵住了,点击、滚动全部失效。
所以聪明的工程师们想出了一个好办法:把耗时操作交给浏览器底层(其他线程)去处理,完成后把对应的回调排进队列,主线程在空闲时再逐个取出执行。这套"交出去、排队、空闲时再处理"的调度机制,就是事件循环。它让单线程也能实现"看起来并发"的效果。
四个核心角色
整套机制由四个角色协作完成。先看它们的分工:调用栈 执行代码,Web APIs 在后台处理耗时操作,两条任务队列 存放等待执行的回调,事件循环在主线程空闲时把回调送回去执行。下面逐个展开。
调用栈(Call Stack)
当前正在执行的函数所在的地方。它遵循后进先出:函数被调用就压栈,返回就弹栈。所有同步代码都在这里跑。这里有一条贯穿全文的关键规则------只要调用栈不空,事件循环就不会去碰任何队列。换句话说,同步代码永远优先,异步回调只能等栈空了才有机会。
Web APIs
JS 引擎本身只能执行同步代码------它不会计时,也不会发网络请求。setTimeout、fetch、DOM 事件监听这些能力不属于 JS 语言 ,而是浏览器作为宿主环境额外提供的,真正的执行逻辑跑在主线程之外的其他线程上。这正是异步的根基:把耗时操作挪到别的线程,主线程才不会被占住。
两条任务队列
上面提到回调会"进队列排队",但队列其实有两条,优先级不同
| 队列 | 包含什么 | 优先级 |
|---|---|---|
| 微任务队列(Microtask) | Promise.then / .catch、await 后的代码、queueMicrotask、MutationObserver |
高 |
| 宏任务队列(Macrotask) | setTimeout、setInterval、I/O、UI 渲染、事件回调 |
低 |
进了队列不代表会马上执行,还得等事件循环来取;而事件循环只在调用栈清空时才来取。两条队列谁先被取,取多少,正是下一节要讲的核心规则。
事件循环(Event Loop)
前面三个角色是"场地和容器",事件循环才是那个不停转动的"调度员"。它做的事极其简单却又是整台机器的心脏:反复检查调用栈是否为空,一旦为空,就按固定优先级从队列里取回调送进栈执行。它永不停歇地转圈,这也正是"循环"二字的由来。它具体按什么顺序取,就是下面这条规则。
最关键的一条规则
整套机制可以浓缩成一个循环。每当调用栈清空,事件循环会:
- 把整个微任务队列全部清空(清空过程中新产生的微任务也要一起清完);
- 然后才取一个宏任务执行;
- 执行完这个宏任务后,再次清空所有微任务;
- 回到第 2 步,循环往复。
一句话口诀:
同步代码先跑 → 清空所有微任务 → 取一个宏任务 → 再清空所有微任务 → ......
以 setTimeout(fn, 0) 为例,一个回调要经历四步才会真正执行:
- 交给底层 :主线程执行到这行时,把
fn登记给浏览器的计时器线程,然后立刻返回、继续往下跑。此刻fn既不执行,也不在任何队列里,而是"挂"在 Web APIs 那边。 - 后台等待 :计时器线程独立倒计时(
fetch则是等服务器响应),主线程完全不受影响。 - 放进队列 :等待结束后,其他线程不能擅自往主线程的调用栈里塞东西(JS 是单线程的),它只能把
fn放进任务队列排队------setTimeout进宏任务队列,Promise.then进微任务队列。 - 取出执行:事件循环不断检查调用栈是否清空,一旦清空,就按规则取出回调、压进调用栈执行。
所以 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 → c 和 c → 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 永远不执行)
逐步推演:
- 同步阶段:
.then(a)把 a 入队。.catch(b)和.then(c)此刻还不入队------要等前一环 settle 才被激活。 - 执行 a:先
d入队、e入队,然后throw中断函数 ------f那行永远执行不到,从未被注册。a 抛错使它的 Promise 变成 rejected。 - 此刻队列是
[d, e]。a 既然 reject,下一环.catch(b)现在才有资格入队,排到队尾 →[d, e, b]。 - 依次执行 d、e、b。b 正常返回(没再抛错),它的 Promise 变成 resolved ,于是
.then(c)入队。 - 执行 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中,更多例子可以自己去看看)
- JS Visualizer 9000 --- https://www.jsv9000.app/