页面为什么会卡死:从一个转不起来的 loading 说透 JS 事件循环

有个 bug 报上来时描述特别朴素:点了按钮,页面卡死三四秒,期间界面点不动,转圈的 loading 也不出现,等它缓过来结果直接就显示了。翻代码一看,setLoading(true) 和 setLoading(false) 一个没漏,可 spinner 就是死活不肯露面。根子在一个人人听过却很少想透的事实:JavaScript 是单线程的——那段同步的重循环死死霸占着唯一的线程,浏览器既没机会把 spinner 画出来,也没机会响应点击。这篇就从这个转不起来的 loading 讲起,把事件循环真正讲透:它凭什么用一条线程扛住所有异步、调用栈和任务队列怎么配合、微任务和宏任务到底谁先谁后,以及最关键的——为什么阻塞主线程是前端性能的头号杀手,又该怎么用切片、Web Worker、MessageChannel 把一个卡死页面的大任务变成一个不卡的。

这个 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) 排在最后"。规则其实只有三条,记住就再也不会错:

  • 先把同步代码跑完——也就是 15,它们在调用栈上直接执行,根本不进任何队列。
  • 每当调用栈清空,先把"微任务队列"一次性掏干净——Promise.then 的回调是微任务,所以 3 紧接着就跑;而 3 里又产生了下一个 then(4),它也是微任务,会在同一轮里继续被掏出来执行。微任务会"连环"清空,期间不给宏任务、也不给渲染任何插队机会。
  • 微任务全清空了,才轮到取一个宏任务——setTimeout 的回调(2)是宏任务,所以它无论 delay 写不写 0,都得等微任务跑光才轮得到。

用一张表把常见的异步 API 归一下类,以后判断顺序就有据可依:

类型 典型来源 执行时机
同步代码 普通函数调用、循环 立刻在调用栈上跑,最优先
微任务 microtask Promise.then/catch/finallyqueueMicrotaskawait 之后的代码、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 和普通对象(大数据可用 TransferableSharedArrayBuffer 避免拷贝开销)。判断标准很简单:任务是"会偶尔卡一下的中等计算",切片够用;是"持续重型 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),而不是微任务。下面这张决策图,把"该用哪种武器"一次理清:

几条可以直接抄走的铁律

  1. 主线程是唯一的,渲染和交互都靠它。任何长时间占着调用栈不返回的同步代码,都会让页面"假死"——状态明明改了,却一帧反馈都给不出。
  2. "设了 loading 却不显示",九成是主线程被同步代码堵死了。排查第一步永远是:这段逻辑里有没有不让步的同步重循环。
  3. 记牢执行顺序:同步 > 微任务(全清)> 渲染 > 宏任务(取一个)。setTimeout(fn, 0) 永远晚于同一轮的 Promise.then。
  4. 想让出主线程给渲染,用宏任务切片(setTimeout),别用微任务(Promise.then)递归。后者会被连环清空,饿死渲染,等于没切。
  5. 中等计算用切片,重型 CPU 计算用 Web Worker。切片是"不卡",Worker 才是"既不卡又不拖慢主线程"。
  6. Promise 不开线程。把同步重计算用 Promise 包一层,是"假异步",该卡还卡——异步的前提是底层有别的东西(IO、定时器、Worker)在替你干活。
  7. 性能问题先量再改。用 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.nextTicksetImmediate 这两个浏览器里没有的家伙。

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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Python 服务内存只涨不跌:从一次 OOM 揪出几个经典内存陷阱

2026-5-29 21:46:51

技术教程

Go 并发踩坑实录:一个共享 map 如何让整个服务瞬间崩溃

2026-5-29 22:00:20

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索