我有一支技术全面、经验丰富的小型团队,专注高效交付中等规模外包项目,有需要外包项目的可以联系我
我们曾经特别信任 setTimeout(0)—— 觉得它能“把活往后挪一下,让界面先流畅起来”。
结果现实给了我们一记耳光: 用户点了按钮,UI 一秒没反应; 帧率掉了、转圈圈晚了一拍、客服工单多到爆。
问题不在这个 API 本身,而在——我们脑子里那套“队列模型”,从一开始就是错的。
直到我们把事件循环画出来,看清每一段代码到底排在哪个队列后, UI 的体感才真正被救回来。
那个我们一直假装看不见的事件循环顺序
我们以前写了一堆代码,用的是这样的心态:
“现在不跑,但下一刻再跑一下就好。” ——然后上线之后,页面在新电脑上看着还行, 可一到中低端机型,立刻开始抽搐抖动。
直到我们把事件循环拆开画成一张图, 才发现问题根本不是“速度”, 而是顺序:
+——————————+
| Script runs |
| sync work |
+————–+————–+
|
v
+————–+————–+
| Microtasks (Promises etc.) |
+————–+————–+
|
v
+————–+————–+
| Render / Paint |
+————–+————–+
|
v
+————–+————–+
| Macrotasks (setTimeout etc.)|
+——————————+
UI 卡顿的比例,一下子就降下来了—— 只因为我们不再幻想“计时器可以插队”。
把定时器当成“下一轮”, 永远不要把它当成“立刻”。
所有跟用户体验强相关的步骤, 应该尽可能排在 渲染之前, 而不是丢进一个你控制不了顺序的宏任务队列里。
0 延迟,从来不是“马上”:它只是排队靠后一点而已
我们曾天真地以为:setTimeout(fn, 0) = “快一点”,“尽快跑”。
实际含义是:等这一轮所有事情都忙完, 再来考虑你这个回调。
当页面在忙(滚动、布局、事件、任务挤成一团)时, 你的“0 延迟”一点都不“快”。
最简单的例子:
console.log(‘A’);
setTimeout(() => console.log(‘B’), 0);
console.log(‘C’);
你会得到:
A
C
B
这还算正常。 但一旦加上现实里的负载, 情况就完全变味了:
console.log(‘A’);
setTimeout(() => console.log(‘B’), 0);
console.log(‘C’);
// 更贴近真实页面
window.addEventListener(‘scroll’, () => {
// 一些很重的工作
for (let i = 0; i < 3e6; i++) {}
});
for (let i = 0; i < 3e6; i++) {} // 很长的一次同步执行 在负载之下,B 不只是排在 C 后面那么简单, 它会被各种布局、输入事件、其他任务一遍遍往后挤。 于是那些“用 0 延迟来展示反馈”的小聪明, 在用户眼里就变成了:按钮点了,啥也没发生。 我们后来彻底把 UI 反馈 从 zero-delay 计时器里剥离出来, 对于视觉反馈这件事, 我们开始有意识地“先 paint,再干活”。 Promise 为啥老是“插队”?因为微任务就是比定时器优先级高 在事件循环里,还有一个被我们忽视的角色:微任务(Microtask)。 它的典型代表就是 Promise.then。 微任务总是排在宏任务(setTimeout 之类)前面。 这意味着,只要你代码里存在 Promise 链, 靠 setTimeout(0) 做顺序控制的那点“小把戏”, 就基本上输得连渣都不剩。 看这个经典序列: console.log(‘X’); Promise.resolve().then(() => console.log(‘Y’));
setTimeout(() => console.log(‘Z’), 0);
console.log(‘W’);
真正输出的顺序是:
X
W
Y
Z
也就是说:
先跑当前脚本:X、W
再跑微任务队列:Y
最后才轮到宏任务队列:Z
我们后来做了一个明确的分工:
用微任务(Promise / queueMicrotask)做“顺序控制”——把一些很小、必须紧挨着当前逻辑的工作收尾好
用定时器做“真正要往后推一轮的事情”——跟 UI 体感无关的、可以晚一点处理的后台逻辑
UI 相关的节奏,不再交给 0 延迟的定时器。
先让浏览器画一帧,再去堵塞它的大脑
我们“转圈圈”迟迟不出现的真正原因是:浏览器压根没空画那一帧。
点击后,我们立刻:
改了样式,想显示一个 spinner
接着就开始执行一大段重逻辑同步代码
浏览器根本没有机会在中间插入一次 paint, 所以用户眼里看到的是: 按钮点了 → 卡住 → 一段时间后 → 结果出来,但圈圈几乎没看到。
修复方式其实非常简单直白:
给浏览器一帧时间,把视觉反馈先画出去, 再开始堵主线程。
可以把时间线想象成这样:
+—————————-+
| t: click handler starts |
| show spinner (style change)|
+————+—————+
|
v
+————+—————+
| requestAnimationFrame() |
| next frame -> heavy work |
+—————————-+
先把 UI 状态改了(比如 spinner.hidden = false), 然后用 requestAnimationFrame 把重工作挂到下一帧之后。
用户立刻就能看到“我点了,它有反应了”, 即便后面的计算依旧需要 200ms 才结束, 体感也完全不一样。
从那以后,我们把所有“先给个反馈,再算一堆东西”的场景, 全部改成了“paint first, work later” 模式。
高负载之下,性能数据告诉我们的真相
我们做了几组简单测量,看不同负载下:
丢帧率(Frame drops)
计时器偏移(Timer skew,实际触发时间和预期间隔的偏差)
得到的表大概是这样:
+—————–+————-+————-+
| Scenario | Frame drops | Timer skew |
+—————–+————-+————-+
| idle page | 0% | ~4–6 ms |
| light scroll | 5–8% | 12–20 ms |
| heavy JS turn | 25–40% | 40–120 ms |
+—————–+————-+————-+
意思是:
页面闲着:一切都很漂亮
轻微滚动:还能接受,但延迟已经可测
有一大块 JS 同步执行: 帧直接丢到 1/4 甚至 2/5,计时器误差能飘到 100ms 上下
用户是先感受到“动画不顺畅”, 才会在日志里看到“任务跑了 80ms”。
所以我们开始盯的不是“某个任务跑了多久”, 而是:
在真实交互下,这段代码 吃掉了多少帧。
我们是怎么把“卡顿”和“转圈圈”一起修掉的?
最终落地的方案,其实只做了三件事:
点按钮 → 立刻显示 spinner
用 requestAnimationFrame 把重逻辑挂到下一帧
做完重逻辑 → 立刻关掉 spinner
代码像这样:
button.addEventListener(‘click’, () => {
spinner.hidden = false; // 先让它显出来
requestAnimationFrame(() => {
// 浏览器会先 paint 这一帧,展示 spinner
doHeavyWork(); // 这里是 CPU 密集的重工作
spinner.hidden = true;
});
});
function doHeavyWork() {
const t = performance.now() + 200; // 模拟 ~200ms 工作量
while (performance.now() < t) {}
}
上线之后的变化非常明显:
“按钮点了没反应”的投诉基本消失
中端手机上的滚动变得顺滑很多
用户不再觉得页面“卡到怀疑人生”
从那之后,我们约定:只要是“先动 UI 再做事”的场景,一律上 requestAnimationFrame。它成了我们的默认模式,而不是特殊优化。
阻止“定时器风暴”:每一个循环都必须有死亡时间
真正让主线程窒息的,有时候不是一次重任务, 而是那些被遗忘的 Interval 和递归 Timeout, 在背后悄悄堆积出一场“计时器风暴”。
我们做了两件简单的事:
- 给所有 interval 一个“寿命”和“关机键”
// 启动一个定时任务
const id = setInterval(tick, 250);
function stop() {
clearInterval(id);
}
// 页面不可见时,干掉它
window.addEventListener(‘visibilitychange’, () => {
if (document.hidden) stop();
});
任何后台循环,要么有明确停止条件, 要么在页面不可见时主动被杀掉。没有“永远跑着的 interval”。
- 用 AbortController 把“过期异步请求”裁掉
let ctrl = new AbortController();
async function load(url) {
const r = await fetch(url, { signal: ctrl.signal });
return r.json();
}
function refresh(url) {
ctrl.abort(); // 先取消上一轮
ctrl = new AbortController(); // 换一个新的 controller
load(url).catch(() => {});
}
刷新频繁请求时, 我们不再让旧请求在那儿慢吞吞结束, 因为这些已经不再被需要,只是在占资源。
结果是:
内存稳定下来
队列长度缩短
主线程空闲时间明显增多
我们开始把背景循环当成稀缺资源, 而不是“反正多一个也没关系”。
Microtask:只管排队,不要抢着上屏
有些时候,我们确实需要保证一小段逻辑紧挨着当前任务收尾, 但又不想把所有东西塞进 Promise 链, 搞出一堆难追踪的异步调用。
这时候,queueMicrotask 就成了更干净的选择。
updateState();
queueMicrotask(() => {
// 在当前执行栈结束后,立刻跑
// 但仍在 timers 之前
validateState();
notifySubscribers();
});
// 避免:为了凑这个顺序到处 new Promise
微任务让我们可以:
把几步“小收尾逻辑”绑在一块
又不会像一长串 Promise 那样, 把整帧时间都耗尽,饿死下一次渲染
我们给它的定位很明确:“流程内的小排队”,而不是“偷偷塞 UI 更新的入口”。
最后想说的那句真相
setTimeout(0) 从来没骗你。 撒谎的是我们自己脑子里那套想象:
“我把它放到 0ms 计时器里, 浏览器就会立刻帮我做。对吧?”
现实世界里,它要排队等:
当前脚本
所有微任务
一次完整的布局和渲染
以及别的宏任务
等这些都忙完了, 它才轮到。
我们真正实现“流畅”的那一刻,是在:
接受了这个队列的真实顺序
学会了先让浏览器画一帧
把细小的顺序控制交给微任务
给所有定时器和后台任务设好“死线”
事件循环的模型其实不复杂, 复杂的是我们什么时候愿意承认:
用户活在的是帧率和体感里,不是在我们以为“已经很快的那段代码”里。
全栈AI·探索:涵盖动效、React Hooks、Vue 技巧、LLM 应用、Python 脚本等专栏,案例驱动实战学习,点击二维码了解更多详情。
声明:来自JavaScript 每日一练,仅代表创作者观点。链接:https://eyangzhen.com/3898.html