JavaScript
事件循环:为什么 Promise 总比 setTimeout 先执行
只要记住“一个任务、清空微任务、可能渲染”这三步,绝大多数执行顺序题和页面卡顿问题都能想明白。
先看一道题
下面这段代码会按什么顺序输出?先自己想一下,再点按钮真正运行一次。
console.log('1 同步');
setTimeout(() => console.log('2 定时器'), 0);
Promise.resolve().then(() => console.log('3 微任务'));
queueMicrotask(() => console.log('4 微任务'));
console.log('5 同步');这个互动示例需要启用 JavaScript。
答案是 1、5、3、4、2。setTimeout 的延迟明明写的是 0,却排在最后。原因不在定时器的精度上,而在浏览器调度代码的规则上。
一轮循环只做三件事
JavaScript 主线程一次只能做一件事。浏览器用一个循环来安排这些工作,每一轮大致是:
- 从任务队列里取出一个任务执行,直到调用栈清空。
- 把微任务队列全部清空,包括执行过程中新加入的微任务。
- 如果到了该刷新屏幕的时候,就执行一次渲染,然后进入下一轮。
一整段 <script> 本身就是一个任务。所以上面的例子是这样走的:同步代码跑完(1、5),清空微任务(3、4),下一轮才轮到定时器回调(2)。
| 类型 | 常见来源 |
|---|---|
| 任务(也叫宏任务) | <script>、setTimeout、setInterval、用户事件回调、MessageChannel |
| 微任务 | Promise 的 then/catch/finally、await 之后的代码、queueMicrotask、MutationObserver |
await 其实是把函数切成了两段
await 不会阻塞线程。函数执行到 await 时先返回,后面的代码被放进微任务,等当前同步代码跑完再继续。
async function run() {
console.log('A');
await null;
console.log('C');
}
run();
console.log('B');
// 输出:A、B、C这也解释了一个常见的误解:把代码写进 async 函数并不会让它变成异步的。await 之前的部分依然是同步执行的,如果那里有一段耗时的循环,页面照样会卡住。
微任务太多也会卡死页面
因为微任务队列必须清空才能进入渲染,所以不断产生新微任务会让页面彻底失去响应。下面的代码会冻结整个标签页,而把 queueMicrotask 换成 setTimeout 则不会,因为每个定时器回调之间浏览器都有机会渲染和响应输入。
function loop() {
queueMicrotask(loop); // 永远清不空,页面再也不会渲染
}
loop();长任务要主动让出主线程
用户感受到的卡顿,大多来自长时间占用主线程的任务。超过 50 毫秒的任务通常被称为长任务。处理大量数据时,可以隔一段时间主动让出一次:
async function processAll(items, handle) {
let last = performance.now();
for (const item of items) {
handle(item);
if (performance.now() - last > 50) {
await new Promise((resolve) => setTimeout(resolve)); // 让浏览器有机会渲染和响应输入
last = performance.now();
}
}
}这里必须用 setTimeout。如果写成 await Promise.resolve(),继续执行的仍然是微任务,浏览器根本没有机会插进来。较新的 Chromium 还提供了 scheduler.yield(),使用前要先做特性检测。
几个容易踩坑的细节
- **
setTimeout(fn, 0)不是 0 毫秒。** 嵌套超过 5 层后,浏览器会把最小延迟钳制到 4 毫秒;后台标签页中的定时器还会被进一步限流。 - **动画应该用
requestAnimationFrame。** 它的回调在下一次渲染之前执行,正好和屏幕刷新同步;页面处于后台时也不会执行。 - **真实点击和
el.click()的顺序不一样。** 用户点击时,每个监听器执行完调用栈都会清空,微任务在两个监听器之间就会运行;用脚本调用el.click()时调用栈还没清空,微任务要等所有监听器都跑完。
小结
遇到顺序问题,就按这三步推演:当前任务跑完,清空微任务,可能渲染。遇到卡顿问题,就检查有没有哪个任务或者哪串微任务长时间占着主线程。