这个 bug 报上来时,描述特别朴素:"点了那个按钮,页面会卡死三四秒,期间整个界面点不动,转圈的 loading 也不出现,等它缓过来,结果直接就显示出来了。"我第一反应是"loading 状态忘了设吧",可翻代码一看,明明白白地在重活儿之前写了 setLoading(true)、之后写了 setLoading(false)——状态一个没漏。可那个转圈的 spinner,就是死活不肯露面,页面像被人按了暂停键,卡得连鼠标 hover 效果都没了。
问题的根子,在于一个几乎人人都听过、却很少真正想透的事实:JavaScript 是单线程的。那段"重活儿"是一个同步的大循环,它一旦跑起来,就死死霸占着这条唯一的线程,浏览器既没机会把 loading=true 对应的 spinner 画出来,也没机会响应任何点击——因为负责渲染和响应交互的,和正在埋头苦算的,是同一条线程。这篇就从这个"转不起来的 loading"讲起,把 JavaScript 的事件循环真正讲透:它凭什么用一条线程就扛住了所有异步、调用栈和任务队列怎么配合、微任务和宏任务的执行顺序到底谁先谁后、以及最关键的——为什么"阻塞主线程"是前端性能的头号杀手,又该怎么把一个卡死页面的大任务,变成一个不卡的。
先认清:关于事件循环的几个常见误解
在拆解机制前,先把那些关于"JS 异步"的典型误解摆出来。我那次踩的是第一条,但其余几条也都是面试和实战里反复出现的坑:
| 常见误解 / 坑 | 真相 | 后果 |
|---|---|---|
| 异步就是多线程并行 | JS 主线程只有一条,异步是靠事件循环"轮流调度" | 误以为重计算不会卡 UI |
| 同步重循环不影响界面 | 它霸占唯一线程,渲染和交互全被冻住 | 页面卡死、loading 都画不出来 |
| setTimeout(fn, 0) 会立即执行 | 它只是把 fn 排进宏任务队列,得等栈清空+本轮微任务跑完 | 对执行顺序的判断全错 |
| Promise.then 和 setTimeout 一个意思 | then 是微任务、setTimeout 是宏任务,前者总在后者前 | 异步顺序诡异、调试抓狂 |
| 用 Promise 包一下就不卡了 | Promise 不开线程,同步重计算包进去照样阻塞 | "假异步",该卡还卡 |
这张表的共同主题是:JS 的"异步"从来不是"并行",而是"在一条线程上,趁着空闲见缝插针地轮流执行"。搞懂这条线程在什么时候做什么——什么时候执行你的代码、什么时候才轮到渲染、什么时候去队列里取下一个任务——上面这些坑就全通了。下面先把事件循环这台发动机讲清楚,再回头看那个卡死的页面。
第一件事:单线程 + 事件循环,到底是怎么转的
JS 引擎执行代码靠一个调用栈(call stack):函数调用入栈、返回出栈,同一时刻只能跑栈顶那一个——这就是"单线程"的含义。那它怎么处理异步(定时器、网络请求、点击事件)?靠的是引擎之外的任务队列 + 事件循环:异步操作完成后,它的回调不会插队,而是乖乖排进队列,等调用栈完全清空了,事件循环才从队列里取一个出来推上栈执行。画成图是这样:
注意那条虚线——它就是我那个卡死页面的全部真相:渲染(把 spinner 画出来)这一步,只有在"调用栈清空"之后才有机会发生。而我的重计算是一段同步循环,它一直占着调用栈不返回,栈永远不空,于是 setLoading(true) 引起的那次重绘,被死死卡在"等栈清空"的门外,直到三秒后循环跑完、栈终于空了——可这时候计算结果也出来了,spinner 还没来得及画就被最终结果覆盖了。这就是为什么页面会"假死":不是浏览器坏了,是你的同步代码把那条唯一的线程占满了,渲染连插一脚的缝都没有。下一节把这个过程用代码摆出来。
第二件事:那个卡死,到底卡在哪一行
回到那个卡死的页面。把它简化成最小可复现的样子,问题一下就清楚了:
function onClick() {
setLoading(true); // ① 我以为这一行会立刻让 spinner 转起来
// ② 一段同步的"重活儿":几百万次循环,纯 CPU,中途不 await 任何东西
let sum = 0;
for (let i = 0; i < 500_000_000; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
render(sum); // ③ 算完了,把结果画上去
setLoading(false); // ④ 关掉 loading
}
我原本的心智模型是:① 让 spinner 出现 → ② 慢慢算 → ③④ 出结果、收 spinner。但真实发生的是:①②③④ 这四步全是同步代码,它们从头到尾占着同一条调用栈,中间一秒都没还给浏览器。① 的 setLoading(true) 只是改了个状态变量、标记了"该重绘了",但真正的重绘必须等调用栈清空、事件循环走到"渲染"那一步才会发生——而那要等到 ④ 之后。于是浏览器的时间线是这样的:
点击 ──▶ [onClick 入栈,开始独占主线程]
① setLoading(true) → 只标记"需要重绘",并不立刻画
② for 循环 500_000_000 次 ← 主线程被锁死 3~4 秒,这期间:
· spinner 画不出来(渲染轮不到)
· 鼠标 hover、点击全部无响应(事件回调排队等着)
③ render(sum) → 把最终结果标记为"需要重绘"
④ setLoading(false) → 又把 loading 标记为 false
[onClick 出栈,调用栈终于空了]
▼ 事件循环这才开始干活:合并这几次状态 → 渲染一帧
结果:直接看到最终结果,spinner 那一帧从未被画出
看明白了吗?spinner 不是"没设置",而是"设置了,但根本没机会画"。它的"显示"和"隐藏"被压缩进了同一轮渲染——在浏览器眼里,loading 从 true 变 false 之间,连一帧都没间隔过,自然什么都看不到。这就是"同步阻塞主线程"最典型、也最反直觉的后果:你写的状态更新全都生效了,可用户一帧反馈都收不到。
第三件事:微任务和宏任务,到底谁先谁后
搞懂了"栈不空就不渲染",还得搞懂另一半:栈空了之后,事件循环从哪个队列、按什么顺序取任务。这里的关键区分是微任务(microtask)和宏任务(macrotask)。先看一段几乎是面试必考的代码,你能说出输出顺序吗?
console.log('1 同步');
setTimeout(() => console.log('2 setTimeout(宏任务)'), 0);
Promise.resolve()
.then(() => console.log('3 Promise.then(微任务)'))
.then(() => console.log('4 第二个 then(微任务)'));
console.log('5 同步');
// 实际输出顺序:
// 1 同步
// 5 同步
// 3 Promise.then(微任务)
// 4 第二个 then(微任务)
// 2 setTimeout(宏任务)
很多人第一次看到都会卡在"为什么 setTimeout(……, 0) 排在最后"。规则其实只有三条,记住就再也不会错:
- 先把同步代码跑完——也就是
1和5,它们在调用栈上直接执行,根本不进任何队列。 - 每当调用栈清空,先把"微任务队列"一次性掏干净——
Promise.then的回调是微任务,所以3紧接着就跑;而3里又产生了下一个then(4),它也是微任务,会在同一轮里继续被掏出来执行。微任务会"连环"清空,期间不给宏任务、也不给渲染任何插队机会。 - 微任务全清空了,才轮到取一个宏任务——
setTimeout的回调(2)是宏任务,所以它无论 delay 写不写 0,都得等微任务跑光才轮得到。
用一张表把常见的异步 API 归一下类,以后判断顺序就有据可依:
| 类型 | 典型来源 | 执行时机 |
|---|---|---|
| 同步代码 | 普通函数调用、循环 | 立刻在调用栈上跑,最优先 |
| 微任务 microtask | Promise.then/catch/finally、queueMicrotask、await 之后的代码、MutationObserver |
每次栈清空后,一次性全部清空 |
| 宏任务 macrotask | setTimeout/setInterval、事件回调、setImmediate(Node)、MessageChannel |
微任务清空后,每轮只取一个 |
一句话记牢:同步 > 微任务(掏干净)> 渲染(有机会)> 宏任务(取一个)> 再回头掏微任务……如此循环。理解了这个顺序,不光面试题迎刃而解,更重要的是:你能预判自己的异步代码"什么时候才真正跑",也能解释那些"明明 setTimeout 0 了却还是晚于 Promise"的诡异现象。
第四件事:把卡死的大任务,切成不卡的小任务
知道了病根,治法就有了方向:既然主线程不能被一段同步代码长期霸占,那就把那段重活儿"切片",每跑一小段就主动把线程让出去,让浏览器有机会插进来画一帧、响应一次交互。最朴素的切法,是用 setTimeout 把下一片排进宏任务队列——每片之间,事件循环就拿到了渲染和响应的窗口:
function heavyTaskChunked(total, onDone) {
const CHUNK = 1_000_000; // 每片只算一百万次,几毫秒就跑完
let i = 0, sum = 0;
function runChunk() {
const end = Math.min(i + CHUNK, total);
for (; i < end; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
if (i < total) {
// 还没算完:把下一片排进宏任务队列,这一让,浏览器就能画 spinner、响应点击
setTimeout(runChunk, 0);
} else {
onDone(sum);
}
}
runChunk();
}
// 现在 spinner 能正常转,页面也点得动了
setLoading(true);
heavyTaskChunked(500_000_000, (sum) => {
render(sum);
setLoading(false);
});
这一改,体验天差地别:每片之间主线程都空出来一瞬,spinner 转得起来了,点击也有响应了。不过 setTimeout 切片有两个小缺点——它有最小 4ms 的钳制、且无法感知"浏览器现在忙不忙"。更现代的做法是用 requestIdleCallback(挑浏览器空闲时跑)或把切片调度交给框架(React 的并发渲染、Vue 的调度器本质都在做同一件事)。但核心思想是统一的:不要一口气吃完,主动分次让出主线程。
第五件事:真正的并行,得请 Web Worker 出场
切片解决了"卡"的问题,但那段计算总耗时其实没变,甚至因为反复调度还略有增加——它只是"不卡了",并没有"变快"。如果这活儿是真·重型 CPU 计算(图像处理、大数据解析、加解密),最干净的方案是把它整个搬出主线程,交给 Web Worker——它是浏览器给你的另一条真线程,在后台埋头算,算完用消息把结果传回来,全程不碰主线程一根毫毛:
// worker.js —— 在独立线程里运行,随便它算多久都不卡主线程
self.onmessage = (e) => {
const total = e.data;
let sum = 0;
for (let i = 0; i < total; i++) sum += Math.sqrt(i) * Math.sin(i);
self.postMessage(sum); // 算完,把结果发回主线程
};
// 主线程 —— 只负责派活和收货,自己一直保持丝滑
const worker = new Worker('worker.js');
setLoading(true);
worker.postMessage(500_000_000);
worker.onmessage = (e) => {
render(e.data);
setLoading(false);
};
这才是"重计算不卡 UI"的终极答案:切片是在一条线程上"见缝插针",Web Worker 是干脆"另开一条线程并行"。代价是 Worker 与主线程之间只能靠消息通信、不能直接共享 DOM 和普通对象(大数据可用 Transferable 或 SharedArrayBuffer 避免拷贝开销)。判断标准很简单:任务是"会偶尔卡一下的中等计算",切片够用;是"持续重型 CPU 计算",请 Worker。
第六件事:小心微任务"饿死"渲染
最后一个反直觉的坑,正好和切片相反:既然微任务会被"连环掏干净",那如果你不停地往里塞微任务,会怎样?答案是——渲染和宏任务会被活活饿死。因为事件循环的规矩是"微任务不清空,绝不渲染、绝不取宏任务":
// 反例:用微任务做递归"切片",看似让出了主线程,其实没有
function badLoop(i) {
if (i <= 0) return;
doSomeWork();
Promise.resolve().then(() => badLoop(i - 1)); // ← 又排了个微任务
}
badLoop(1_000_000);
// 后果:这一百万个微任务会在"同一轮"里被连续掏空,
// 期间渲染一帧都插不进来 —— 页面照样卡死!
这就是 setTimeout 切片和 Promise.then 切片的本质区别:前者把下一片放进宏任务,每片之间留出了渲染窗口;后者放进微任务,会被一口气清空,等于没切。所以那条铁律要反过来用——想让出主线程给渲染,必须用宏任务(setTimeout / MessageChannel),而不是微任务。下面这张决策图,把"该用哪种武器"一次理清:
几条可以直接抄走的铁律
- 主线程是唯一的,渲染和交互都靠它。任何长时间占着调用栈不返回的同步代码,都会让页面"假死"——状态明明改了,却一帧反馈都给不出。
- "设了 loading 却不显示",九成是主线程被同步代码堵死了。排查第一步永远是:这段逻辑里有没有不让步的同步重循环。
- 记牢执行顺序:同步 > 微任务(全清)> 渲染 > 宏任务(取一个)。setTimeout(fn, 0) 永远晚于同一轮的 Promise.then。
- 想让出主线程给渲染,用宏任务切片(setTimeout),别用微任务(Promise.then)递归。后者会被连环清空,饿死渲染,等于没切。
- 中等计算用切片,重型 CPU 计算用 Web Worker。切片是"不卡",Worker 才是"既不卡又不拖慢主线程"。
- Promise 不开线程。把同步重计算用 Promise 包一层,是"假异步",该卡还卡——异步的前提是底层有别的东西(IO、定时器、Worker)在替你干活。
- 性能问题先量再改。用 Performance 面板看主线程上有没有"长任务(Long Task,>50ms)",别凭感觉猜哪里卡。
顺带说几个常被忽略的细节
把核心机制讲完了,再补几个实战里容易栽跟头、但课本上很少强调的点:
一、await 后面的代码,本质是微任务。很多人以为 await 是"停在这里等",其实它是把函数剩下的部分包成一个微任务、挂到那个 Promise 后面,然后立刻把控制权交还给调用者继续往下跑。所以下面这段的输出顺序常常出人意料:
async function f() {
console.log('A');
await null; // await 一个非 Promise,也会切成微任务
console.log('C'); // ← 这行被推迟到微任务里
}
console.log('start');
f();
console.log('B');
// 输出:start → A → B → C
// 因为 await 之后的 console.log('C') 被排进了微任务,
// 而同步的 'B' 先跑完
二、长任务的"50ms 红线"。浏览器大约每 16.7ms 要画一帧(60fps)。任何在主线程上连续执行超过 50ms 的任务,都会被 DevTools 标成"Long Task",意味着这段时间里至少丢了好几帧、交互也会迟钝。优化的目标,就是把这些红色长条切碎或搬走。
三、requestAnimationFrame 不是定时器。它的回调在"下一次渲染前"执行,天生和刷新率对齐,适合做动画;而 setTimeout 和渲染时机无关,用它做动画会掉帧、卡顿。两者都属于宏任务范畴,但触发时机的语义完全不同,别混用。
那些年我们对"异步"的误会
回头看开头那张误解表,根子其实是同一个:把"异步"想象成了"并行"。在浏览器主线程这个语境里,JavaScript 的异步从来不是"同时干好几件事",而是"在一条线程上,把活儿拆成一段段,趁线程空闲时见缝插针地轮流执行"。理解了这一点,那些坑就都通了——
"重计算包进 Promise 就不卡了"是假的,因为 Promise 不开线程,同步计算照样霸占主线程;"setTimeout 0 会立即执行"是假的,因为它只是排进宏任务、得等栈空和微任务清完;"用了 async 就不会阻塞"也是假的,如果 async 函数里藏着同步重循环,await 之前的部分照样把线程占死。异步能不能不卡,从来不取决于你用了哪个关键字,而取决于"主线程到底有没有被还出来"。
再补一招:用 MessageChannel 做"零延迟"切片
前面用 setTimeout(fn, 0) 切片,有个被很多人忽略的硬伤:浏览器对嵌套的 setTimeout 有最小 4ms 的钳制。也就是说,你写 0,实际每片之间至少被强制歇 4ms,切的片越多,白白浪费的时间就越可观——切一千片,光是这个"税"就是 4 秒。要既让出主线程、又不交这笔税,可以用 MessageChannel:它的消息回调也是宏任务(同样给渲染留窗口),但没有那个 4ms 下限,几乎是"立刻"排到下一轮。
// 用 MessageChannel 造一个"零延迟"的让步函数
function createYielder() {
const { port1, port2 } = new MessageChannel();
let resolveFn = null;
port1.onmessage = () => resolveFn && resolveFn();
// 返回一个 Promise:await 它,就等于"让出主线程一轮"
return function yieldToBrowser() {
return new Promise((resolve) => {
resolveFn = resolve;
port2.postMessage(null); // 触发一个宏任务,但没有 4ms 钳制
});
};
}
const yieldToBrowser = createYielder();
async function heavyTaskFast(total) {
let sum = 0;
for (let i = 0; i < total; i++) {
sum += Math.sqrt(i) * Math.sin(i);
if (i % 1_000_000 === 0) {
await yieldToBrowser(); // 每算一百万次,让浏览器画一帧、响应一次
}
}
return sum;
}
这套写法,把"切片不卡"和"几乎不额外拖慢"两个目标同时拿下了,是很多高性能前端库(比如一些虚拟列表、大数据表格)内部真正在用的调度技巧。当然,如果计算本身极重,该上 Web Worker 还得上——切片再快,也只是把一条线程的活儿排得更密,而 Worker 是实打实多了一条线程。选哪个,始终回到那个问题:你缺的是"让主线程喘口气",还是"多一双手干活"。
顺带提一个排查时的实用动作:打开 DevTools 的 Performance 面板录一段,主线程那一行上凡是标了红色三角的"长任务",就是你要切片或搬走的对象;而 performance.now() 在切片前后各打一个点,能精确量出每片到底跑了多久——把"我觉得这里卡"换成"这段实测 320ms",优化才有靶子。
一个延伸:Node 里的事件循环,长得不太一样
上面讲的是浏览器。如果你也写 Node,得知道它的事件循环虽然同源,但分了更细的"阶段(phase)":timers、pending、poll、check、close 等,依次轮转。最容易踩的差异是 process.nextTick 和 setImmediate 这两个浏览器里没有的家伙。
process.nextTick 的优先级比微任务还高——它会在当前操作结束后、连 Promise 微任务都还没轮到时就插队执行,所以滥用它(比如递归 nextTick)同样能饿死整个事件循环,后果比浏览器里的微任务饥饿更严重。而 setImmediate 则被设计成"在 poll 阶段之后(check 阶段)执行",在 IO 回调里它常常比 setTimeout(fn, 0) 更早跑。记住一个排序就够日常用:process.nextTick > Promise 微任务 > setTimeout/setImmediate(宏任务)。
但万变不离其宗:无论浏览器还是 Node,"一条主线程、同步代码不让步就什么都轮不到"这条铁律完全一致。Node 里一段同步的重计算,照样会把整个服务的请求处理全卡住——区别只在于,卡住的不再是某个用户的页面,而是你所有在线用户的请求。这也是为什么 Node 服务里的 CPU 密集活儿,同样要么切片、要么丢给 worker_threads。
说到底,无论用切片、Worker 还是 worker_threads,工程师真正要养成的,是一种"主线程意识":每写一段可能耗时的逻辑,都下意识问一句——它会不会一直攥着这条线程不撒手?把这个问题变成肌肉记忆,你就很少再会写出那种"状态全设对了、用户却一帧都看不到"的卡死代码了。
写在最后
那个"转不起来的 loading",最后的修复其实只有一行思路:把那段同步重循环搬进 Web Worker。改完之后,spinner 第一次乖乖转了起来,点击也跟手了——而我对前端性能的理解,也从"哪里慢就优化哪里"升级成了"主线程现在还在不在我手里"。
事件循环不是什么高深的黑魔法,它就是一套朴素到近乎笨拙的调度规则:一条线程,一个调用栈,栈空了先掏微任务、给一次渲染机会、再取一个宏任务,周而复始。但前端几乎所有的"卡顿""假死""顺序诡异",追到底都是这套规则在起作用。你不需要把它背得滚瓜烂熟,只需要在下次遇到"明明状态改了页面却没反应"时,本能地反问一句:此刻,主线程被谁占着?想清楚这一句,你就握住了前端性能最底层的那根线。
—— 别看了 · 2026