微任务和宏任务到底谁先跑?12 段 JS 代码跑出真实执行顺序

Promise.then 和 setTimeout 谁先跑?
背过八股的人都能答上来:微任务先,宏任务等下一轮。
我把这句话拆成了 12 段代码,让浏览器自己跑一遍。
每跑完一段,页面把真实执行顺序按毫秒打出来,再和服务端写在样例里的预期对一次。
12 段全部跑完,12 条断言全部对上,一条没差。
成品只有一个 index.html,双击就能跑,不联网、不引第三方库。
案例目录在这里,下载下来直接打开:
https://atomgit.com/deli007/demo_park/tree/main/codearts-event-loop-visualizer
为什么值得看:顺序你背得下来,毫秒数背不下来
先看最短的一段。
setTimeout(() => record('A'), 0);
Promise.resolve().then(() => record('B'));
record('C');
页面上跑出来的顺序是 C、B、A,时间戳 0ms、1ms、1ms。
A 写在第一行,排在最后。
它比 B 只晚了 1 毫秒,但性质完全不同:B 在当前这一轮就处理掉了,A 要排到下一轮。
这三个数字是浏览器现场给的,不是我抄的。
换成 9 号样例,微任务里再排微任务,顺序是 D、A、B、C。
同步代码 D 先走,然后是微任务 A,A 里塞进去的 B 排在 C 前面。
看清楚这条的原因,比背十条结论有用。
一、12 段样例覆盖了什么
页面左边是可点击切换的样例列表,右边是预设对照表。
| # | 主题 | 真实输出顺序 |
|---|---|---|
| 1 | 同步代码 vs setTimeout 0 | A → C → B |
| 2 | Promise.then vs setTimeout | C → B → A |
| 3 | await 一个已 resolve 的 Promise | B → A |
| 4 | queueMicrotask 的插入位置 | A → C → B |
| 5 | await 与 Promise.then 的先后 | C → A → B |
| 6 | for 循环里的 setTimeout | Done → 0 → 1 → 2 |
| 7 | 多个 then 的排队顺序 | C → A → B |
| 8 | async 函数里 await 前后 | A → C → B |
| 9 | 微任务里再产生微任务 | D → A → B → C |
| 10 | Promise.all 与单个 then | Done → A → A,B |
| 11 | setTimeout 0 与 1ms | C → A → B |
| 12 | then 返回普通值与 Promise | Done → A → B |
这 12 行的顺序全部是页面跑出来的,不是我按记忆写的。
对照表里还有一列"常见错误",写的是最容易赌错的那个顺序。
跑完一段,页面把实际顺序和预期顺序摆在一起比,给出预测正确或错误,外加一个匹配率。
二、三个阶段怎么分出来的
中间那三个面板不是装饰,每一步输出都会落进其中一个。
顺序是这样的:代码同步执行时打出的记录进"同步阶段",当前这一轮排队的记录进"微任务队列",定时器回调里打出的记录进"宏任务队列"。
判断依据是运行时标记,不是靠猜。
同步阶段一开始就是真,等主函数返回了才切到微任务;只要进过 setTimeout 的回调,就切到宏任务。
2 号样例跑完的分工是这样:C 在同步阶段,B 在微任务队列,A 在宏任务队列。
三个面板各自只有一个色块,看一眼就知道这一行归谁。

9 号样例更能说明问题:同步阶段只有 D,微任务队列排着 A、B、C 三块,宏任务队列是空的。

三、验证与踩坑:12 条断言,和我在验收时改掉的五处
页面底部有一个"运行自检"按钮。
点一下,12 段代码真的跑一遍,每条都列出预期、实际和结论。
结果写在最下面:共 12 条断言:通过 12 条,失败 0 条,也就是 12/12 通过。

第一版交付的页面跑通了,但验收时我改了五处。
第一处,队列面板活不过 100 毫秒。
第一版在运行结束后就把三个面板清空,我等一秒再看,面板已经是空的。
改成保留结果,下一次运行开始时才清。
第二处,微任务面板收下了所有输出。
第一版把最近三条记录一股脑塞进微任务面板,同步打出的 C 也在里面。
现在按真实阶段归属渲染,同步、微任务、宏任务各进各的面板。
第三处,报错时上一次的结果被抹掉。
第一版一进运行函数就清空输出区,代码写错的那一刻,上一段跑好的顺序也没了。
改成先做运行保护和语法校验,两道都过了才清空。
第四处,死循环保护的写法太窄,报错文案也不对。
第一版只匹配 while(true) 和 for(;;),而且这条提示被挂在"语法错误"上,死循环根本不是语法错误。
现在换成正则判断,文案改成"运行保护"。
第五处,数字被打上了引号。
6 号样例真实输出的是数字 0、1、2,输出行却渲染成 record(‘0’)。
现在字符串加引号,数字不加。
四、失败路径:语法错误和死循环
第一种失败是代码写坏。
我把编辑器改成 setTimeout(() => record(‘X’), 0) 后面多打一个反括号,再点运行。
页面顶部立刻出红字:语法错误:Unexpected token ‘)’。
关键是下面没有白屏,上一次跑出来的 C、B、A 和三个面板都还在。

第二种失败是死循环。
我把代码换成 let n = 0; while (1) { n++; },点运行。
页面给出红字:运行保护:检测到可能的死循环写法,这段代码没有执行。
这条保护是正则匹配,只挡 while (1)、while (true)、for (;;) 这几种最容易写出来的写法。
换成 while (n < 1e9) 这种有条件的写法仍然会真的跑满主线程,页面会卡住。
改之前那一版是字符串匹配,while (1) 能直接绕过去,我实测点下去标签页就不再响应了。
自检那 12 条断言也有边界要说清楚。
期望值是我按规范写死的,断言只能证明浏览器实际输出等于我写的预期,证明不了预期本身一定对。
12 条里第 1 条和第 4 条的顺序完全一样,第 5 条和第 7 条也一样,覆盖度没有看上去那么满。
阶段归属也是运行时标记推出来的:定时器回调里再产生的微任务,会被记成宏任务。
12 段样例里没有这种写法,所以不影响结论,但换成别的代码就会不准。
五、本地复现
页面是单文件,不需要构建。
python3 -m http.server 8080
浏览器打开 http://localhost:8080/index.html,或者直接双击 index.html。
想核对上面的数字,先点运行自检,看到 12 条全过,再回到 2 号样例单跑一遍。
单跑的时候盯三个地方:输出序列的毫秒、三个面板各是谁、右下角的匹配率。
六、准备环境:进入码道 Web
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后直接对话,不用装软件。
我提交给它的需求是这样一段话:
做一个单文件网页应用 index.html:JavaScript 事件循环可视化实验室。页面内置 12 段 JS 代码样例,覆盖同步代码与 setTimeout 0 的顺序、Promise.then 与 setTimeout 的顺序、await 一个已 resolve 的 Promise 之后代码的时机、queueMicrotask 的插入位置、await 与 Promise.then 的先后、for 循环里的 setTimeout、多个 then 的排队顺序、async 函数里 await 前后的输出、微任务里再产生微任务的排队、Promise.all 与单个 then 的顺序、setTimeout 嵌套 0 与 1 毫秒的顺序、then 返回普通值和 Promise 的差别。代码里用 record('A') 这样的调用记录每一步。中间要有运行按钮,点击后用 new Function 真实执行代码,真实调用 setTimeout、Promise、queueMicrotask,把每一步实际执行的先后顺序按序号记录到输出区。左边是可点击切换的样例列表,中间上方是调用栈、微任务队列、宏任务队列三个面板,中间下方是带毫秒的真实输出序列。右边是预设对照表,给出正确顺序和常见错误猜测顺序,运行完自动比对并给出匹配率。底部要有运行自检按钮,把 12 段代码全部真跑一遍,逐条列出断言,最后汇总显示共 12 条断言、通过多少条、失败多少条。允许编辑代码,语法错误或死循环要给中文红色提示,并保留上一次的有效结果,不能白屏。界面中文,深色主题,不要外部依赖和网络请求。完成后打开预览,让我直接看到运行效果。
七、使用码道体会
这次最值钱的动作,是把验收口径写进需求里。
我在需求里点名要一个可点的自检按钮,要求它把预期、实际、通过与否都渲染在页面上。
交付时就有了 12 条可点可验的断言,文章里的数字才有出处。
第二点是要求"真跑",而不是"显示正确答案"。
代码用 new Function 现场执行,顺序由浏览器给,页面只是记录者。
这一点决定了这篇文章里的毫秒数是不是真的。
第三点是截图要自己逐张放大看。
队列面板活不过 100 毫秒、报错时结果被抹掉,这两个问题读代码很容易滑过去,截图一看就露。
第四点是边界要如实写。
自检里那两条重复顺序、阶段归属的局限、死循环保护只挡几种写法,写出来比藏起来有用。
八、总结
微任务先于宏任务,这句话只值一行。
真正能带走的是那三个数字:同步代码 0ms,微任务在同一轮里 1ms 就处理完,宏任务要等下一轮。
想验证自己有没有记错,就跑一遍自检,再挑 2 号和 9 号单跑,看三个面板各是谁。
案例目录(下载下来双击就能跑):
https://atomgit.com/deli007/demo_park/tree/main/codearts-event-loop-visualizer
- 点赞
- 收藏
- 关注作者
评论(0)