Rucky 前端笔记

JavaScript

事件循环:为什么 Promise 总比 setTimeout 先执行

只要记住“一个任务、清空微任务、可能渲染”这三步,绝大多数执行顺序题和页面卡顿问题都能想明白。

先看一道题

下面这段代码会按什么顺序输出?先自己想一下,再点按钮真正运行一次。

JavaScript
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 主线程一次只能做一件事。浏览器用一个循环来安排这些工作,每一轮大致是:

  1. 从任务队列里取出一个任务执行,直到调用栈清空。
  2. 把微任务队列全部清空,包括执行过程中新加入的微任务。
  3. 如果到了该刷新屏幕的时候,就执行一次渲染,然后进入下一轮。

一整段 <script> 本身就是一个任务。所以上面的例子是这样走的:同步代码跑完(1、5),清空微任务(3、4),下一轮才轮到定时器回调(2)。

类型常见来源
任务(也叫宏任务)<script>、setTimeout、setInterval、用户事件回调、MessageChannel
微任务Promise 的 then/catch/finally、await 之后的代码、queueMicrotask、MutationObserver

await 其实是把函数切成了两段

await 不会阻塞线程。函数执行到 await 时先返回,后面的代码被放进微任务,等当前同步代码跑完再继续。

JavaScript
async function run() {
  console.log('A');
  await null;
  console.log('C');
}

run();
console.log('B');
// 输出:A、B、C

这也解释了一个常见的误解:把代码写进 async 函数并不会让它变成异步的。await 之前的部分依然是同步执行的,如果那里有一段耗时的循环,页面照样会卡住。

微任务太多也会卡死页面

因为微任务队列必须清空才能进入渲染,所以不断产生新微任务会让页面彻底失去响应。下面的代码会冻结整个标签页,而把 queueMicrotask 换成 setTimeout 则不会,因为每个定时器回调之间浏览器都有机会渲染和响应输入。

JavaScript
function loop() {
  queueMicrotask(loop); // 永远清不空,页面再也不会渲染
}
loop();

长任务要主动让出主线程

用户感受到的卡顿,大多来自长时间占用主线程的任务。超过 50 毫秒的任务通常被称为长任务。处理大量数据时,可以隔一段时间主动让出一次:

JavaScript
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() 时调用栈还没清空,微任务要等所有监听器都跑完。

小结

遇到顺序问题,就按这三步推演:当前任务跑完,清空微任务,可能渲染。遇到卡顿问题,就检查有没有哪个任务或者哪串微任务长时间占着主线程。

延伸阅读