事情是这样的。
上周我在写一个页面,代码逻辑简单到不行,先发个请求拿数据,然后更新 DOM,完事儿。结果页面上数据死活不更新,console 里打印的顺序跟我写的顺序完全不一样。
我当时就愣住了。
那种感觉就像你明明把钥匙放在桌上,转头它跑到冰箱里去了。你敢信???
后来我发现,不是代码出了问题,是我对 JS 事件循环的理解出了偏差。而且这个问题,我敢说,90% 的前端在头两年都踩过。不只是新手,就连写了好几年 JS 的老油条,偶尔也会被一个 async/await 和 setTimeout 混在一起的执行顺序给干懵。
今天就用一个 HTML 文件,把这个事儿彻底聊透。
先讲一个最基础的东西,JS 是单线程的。
这个大家都知道,但你有没有想过,为啥 JS 要被设计成单线程,你想想看,如果 JS 可以同时操作 DOM,一个线程在删节点,另一个线程在改这个节点的文字,浏览器到底听谁的,这不就打架了吗。
所以 JS 选择了最简单的方案,一个主线程,一次只做一件事。这就是所谓的同步代码执行,调用栈(Call Stack)里的函数一个接一个跑,上一个不出栈,下一个就别想进来。
js
console.log('同步代码 1');
console.log('同步代码 2');
这没啥好说的,按顺序执行,1 然后 2。
但问题来了。
如果所有代码都是同步的,那一个网络请求发出去,整个页面就得卡住等响应,用户点什么都没反应。这谁受得了。
所以浏览器给 JS 配了一套异步机制,也就是事件循环,Event Loop。
这套机制的核心,我用自己的话来理解,就是一个不断轮询的消息队列,加上一个微任务队列,再加上一个宏任务队列。
我知道这几个词听着挺唬人的,我还是用大白话举个例子。
你去奶茶店点单,点完了店员给你一个取餐号,你不会站在柜台前面死等,你会退到一边刷手机。等到叫号了,你再去取。
同步代码就是当场给你做的那杯,立刻拿到。
宏任务,setTimeout、setInterval 这些,就是取餐号,等着被叫。
微任务,Promise.then、MutationObserver 这些,是店员在你取餐前顺手塞给你的吸管和纸巾,得先处理完,才能叫下一个号。
这个比喻可能不太完美,但它帮我理解了很多东西。说真的,面试的时候被问到 Event Loop,脑子里出现的就是这个奶茶店的画面。
说回正题。
我写了一个 HTML 文件,把 JS 事件循环里各种情况全塞进去了。同步代码、Promise、async/await、queueMicrotask、MutationObserver、setTimeout,一个都不少。
html
<script>
console.log('同步代码 1');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('setTimeout 1 内部微任务');
});
}, 0);
const promise1 = new Promise((resolve) => {
console.log('Promise 构造函数');
resolve();
console.log('Promise 构造函数内 resolve 后');
});
promise1.then(() => {
console.log('Promise.then 1');
setTimeout(() => {
console.log('Promise.then 1 内部 setTimeout');
}, 0);
});
async function asyncFn() {
console.log('async 函数同步部分');
await Promise.resolve();
console.log('await 后微任务');
}
asyncFn();
console.log('同步代码 2');
queueMicrotask(() => {
console.log('queueMicrotask 微任务');
});
const observer = new MutationObserver(() => {
console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1');
</script>
你先别往下翻,试试自己心算一下输出顺序。
算完了吗。
好,我们来一行一行拆。
第一步,JS 引擎开始执行这段 script,这本身就是一个宏任务。V8 开始从上到下跑同步代码。
第一行,console.log('同步代码 1'),直接打印,没啥好说的。
接着遇到 setTimeout,浏览器说,这个我不立即执行,我给你扔到宏任务队列里排着。
注意了,哪怕延迟写的是 0,它也不会立刻执行。它一定会等当前所有同步代码跑完、所有微任务清空、甚至可能等浏览器渲完一帧,才会被捞出来执行。setTimeout 的 0 不是真的 0,它是最小延迟 4ms 起步的一个「尽快」。
然后到了 new Promise。
这里有一个特别容易被坑的点。Promise 构造函数里的代码,是同步执行的。所以 console.log('Promise 构造函数') 和 console.log('Promise 构造函数内 resolve 后') 会立刻打印。哪怕你在中间调了 resolve(),后面的同步代码照跑不误,不会因为 resolve 了就停下来。
resolve 之后发生了一件事,.then 里的回调被塞进了微任务队列,等着。
接着 asyncFn() 调用,函数里 console.log('async 函数同步部分') 是同步的,直接执行。然后遇到 await Promise.resolve()。
这里又是一个高频踩雷点。
await 后面的所有代码,其实就是 .then 回调的语法糖。所以 console.log('await 后微任务') 也被塞进微任务队列。很多人以为 await 能把异步变同步,不是的,它的「等待」其实只是把后面的代码变成了微任务,不会阻塞当前同步代码的执行。
然后 console.log('同步代码 2'),同步执行。
queueMicrotask,顾名思义,扔进微任务队列。
MutationObserver,DOM 变化监听,回调也是微任务。我通过 div.setAttribute('data-test', '1') 触发了它,回调被扔进微任务队列。
到这里,同步代码全部跑完。
此时的状态是这样的。
微任务队列里塞了四个任务,按入队顺序分别是,Promise.then 1、await 后微任务、queueMicrotask 微任务、MutationObserver 微任务。宏任务队列里有一个 setTimeout 1。
然后事件循环开始清空微任务队列。微任务队列的执行规则很简单,来一个清一个,清的过程中如果有新的微任务入队,继续清,直到队列为空。
按入队顺序,依次打印 Promise.then 1、await 后微任务、queueMicrotask 微任务、MutationObserver 微任务。
等一下,这里有一个细节。
在 Promise.then 1 的回调里,又调了一个 setTimeout,这个新的 setTimeout 被扔进宏任务队列,排在之前的 setTimeout 1 后面。
所以当微任务队列全部清空之后,宏任务队列里的情况变了,现在有两个宏任务,setTimeout 1 在前,Promise.then 1 内部 setTimeout 在后。
微任务清空,浏览器可能会在这中间插一次页面渲染,如果需要的话。重绘、重排、动画帧、垃圾回收,这些都不是队列里的任务,它们有自己独立的时机。
然后开始下一轮事件循环,从宏任务队列里取出第一个任务。
setTimeout 1 执行,打印 setTimeout 1。然后里面又有一个 Promise.resolve().then,产生了一个新的微任务,被塞进当前的微任务队列。这个宏任务执行完,事件循环又去清空微任务队列,打印 setTimeout 1 内部微任务。
接着取下一个宏任务,也就是在 Promise.then 1 里塞进去的那个 setTimeout,打印 Promise.then 1 内部 setTimeout。
所以最终的输出顺序是。
javascript
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout
你算对了吗。
说实话,我第一次看到这个输出的时候,脑子是懵的。我寻思了一下,我没寻思明白。然后又跑了一遍,又寻思了一遍,才慢慢捋清楚。
这里我想聊一个更深一点的东西。
为什么事件循环要设计成这么一套层层嵌套的机制,看起来同步代码、微任务、宏任务搅在一起,复杂得要命。
其实吧,这个设计的出发点特别朴素,就是不要让 JS 阻塞页面。
想象一下,如果没有微任务这个概念,所有异步回调都扔到宏任务队列里排着,那像 MutationObserver 这种 DOM 变化的响应,就得等前面的 setTimeout 全部跑完才能执行。用户可能已经看到了一个中间状态的 DOM,一闪而过的脏界面。
所以微任务的设计目的,就是在当前宏任务结束之后、浏览器渲染之前,给你一个快速通道,去处理那些紧急的、需要立刻响应的事情。Promise 的回调、DOM 变化的监听,这些都是典型的「赶紧处理掉,别拖到下一帧」的场景。
听到这里你可能会问,那页面渲染呢,渲染在哪个环节。
渲染不在队列里。它更像是事件循环在每轮之间的一个可选步骤。浏览器会判断,当前帧需不需要重绘,如果需要,就在微任务清空之后、下一个宏任务开始之前插一脚。这也是为什么长时间占用主线程会导致页面卡顿,你一直占着不放,渲染根本没机会进场。
换个角度想,浏览器内核其实就是一个微型操作系统。
我一个朋友跟我说过这句话,我觉得特别对。事件循环就是它的进程调度器,宏任务是时间片,微任务是中断处理,渲染是用户界面的刷新。requestAnimationFrame 是你在渲染前一刻的抢占回调,requestIdleCallback 是空闲时间片里的低优先级任务。
你写 JS 代码的时候,其实是在给这个微型操作系统的调度器编排任务。你决定哪些事情同步做、哪些事情异步做、哪些事情高优先级、哪些可以往后排。听起来是不是有点像在写一个迷你内核。
这件事让我想起一个道理。
很多时候我们觉得一个东西「反直觉」,不是因为它设计得不好,而是因为我们还没看到它要解决的问题的全貌。Event Loop 这个机制,在 09 年 Node.js 刚出来那会儿就被 Ryan Dahl 实现在了 libuv 里,12 年之后浏览器端也标准化了。十几年过去了,它从来没被推翻过,说明这个设计是经得起考验的。
你想想看,每天有几十亿人在用浏览器,每个页面里都有 JS 在跑,Event Loop 就在那里,无声无息地调度着每一行代码。它不酷,但它可靠。它就像城市地下的排水系统,你平时根本不会注意到它,但一旦它出了问题,整个城市就瘫痪了。
我写这篇文章,不是想当谁的老师。我自己也还在摸索,前两周还被一个 async/await 和 Promise 混合使用的执行顺序搞懵过。
但我发现,当你真的静下心来,用一个 HTML 文件把所有情况跑一遍,观察输出、推演过程、验证猜想,你对 JS 的理解会上一个台阶。这种感觉太爽了,那些以前觉得玄学的 bug,突然就有了清晰的因果链。
如果你也一直被 Event Loop 搞得头疼,我建议你也写一个这样的 demo,把各种异步场景塞进去,猜输出,然后验证。不用一次搞懂所有东西,先搞懂同步 → 微任务 → 宏任务这个基本管线,其他的慢慢往上加。
我把上面那个 HTML 文件放到 GitHub 上了,你可以直接复制下来跑。链接在文末。
理解事件循环不是为了面试,虽然它确实是高频面试题。理解它的真正价值在于,当你写异步代码的时候,你脑子里有一个清晰的执行模型,你知道每一行代码会在什么时候被执行,你能预判那些诡异的 bug,而不是出了问题之后一脸懵地疯狂 console.log。
这种感觉,就像一个篮球运动员不再需要低头看球,球感已经刻在肌肉记忆里了。
代码也是这样的。
以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个点赞~
谢谢你看我的文章,我们,下次再见。
/ 作者:不会敲代码1