浏览器事件循环

面试回答: JavaScript 在渲染主线程上按调用栈同步执行。定时器、网络和用户事件由浏览器处理,满足条件后把回调排入任务队列;当前任务结束时,浏览器会执行一次微任务检查点,持续清空 Promise、queueMicrotask 等微任务。随后浏览器才有机会执行渲染相关工作,再进入下一个任务。因此常用的理解顺序是“一个任务 → 清空微任务 → 可能渲染 → 下一个任务”,但渲染并不是每轮事件循环都必然发生。

1. 事件循环属于谁?

事件循环不是 ECMAScript 语言本身定义的语法能力,而是浏览器宿主环境用来协调以下工作的调度机制:

  • JavaScript 调用栈;
  • 定时器、网络请求和用户交互等浏览器能力;
  • 任务队列与微任务队列;
  • 样式计算、布局、绘制等渲染工作。

JavaScript 可以保持单线程的执行模型,同时把等待中的工作交给浏览器处理。异步操作完成并不等于回调立即执行,它只是获得了进入相应队列、等待主线程调度的资格。

2. 核心组成

调用栈(Call Stack)

同步代码执行的地方。函数调用时入栈,执行结束后出栈。同一时刻,主线程只能执行调用栈顶部的代码。

浏览器能力(Web APIs)

浏览器负责计时、网络、DOM 事件等工作。例如 setTimeout 会向浏览器注册计时器,而不是让回调在调用栈中等待。

任务队列(Task Queue)

规范通常使用 task;“宏任务”是前端语境中的常见叫法。常见任务来源包括:

  • 首次执行的页面脚本;
  • setTimeoutsetInterval 回调;
  • 点击、输入等用户事件回调;
  • 网络和消息事件回调。

浏览器可能维护多个任务队列,并根据任务来源和调度策略选择下一个任务,因此不能把所有任务简单理解成唯一的先进先出数组。

微任务队列(Microtask Queue)

常见微任务包括:

  • Promise.then/catch/finally
  • queueMicrotask
  • MutationObserver 回调。

当前任务结束后,浏览器会执行微任务检查点,并持续处理微任务,直到队列为空。微任务执行期间新加入的微任务,也会在同一次检查点继续执行。

process.nextTicksetImmediate 属于 Node.js 等环境,不应该混入浏览器事件循环题目中。

3. 一轮事件循环如何推进?

可以用下面的顺序建立心智模型:

  1. 浏览器选择并执行一个任务。
  2. 任务中的同步代码依次进入调用栈执行。
  3. 调用栈清空后,执行微任务检查点,直到微任务队列为空。
  4. 如果到达合适的渲染时机,浏览器执行动画回调和渲染流水线。
  5. 浏览器选择下一个任务,开始下一轮调度。
Task
  └── 同步 JavaScript
        └── Microtask checkpoint
              └── 可能执行 requestAnimationFrame 与页面渲染
                    └── Next Task

“可能渲染”很重要:浏览器通常会结合刷新率、页面是否可见以及是否有视觉更新来决定是否渲染,并不是执行完每个任务都固定绘制一次。

4. 经典执行顺序

console.log('1');

setTimeout(() => {
  console.log('2');

  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

new Promise((resolve) => {
  console.log('4');
  resolve();
}).then(() => {
  console.log('5');
});

console.log('6');

执行过程如下:

  1. 当前脚本是一个任务,依次打印 1
  2. setTimeout 注册计时器,回调稍后进入任务队列。
  3. Promise 构造函数同步执行,打印 4.then 回调进入微任务队列。
  4. 继续执行同步代码,打印 6
  5. 当前任务结束,清空微任务队列,打印 5
  6. 定时器回调作为后续任务执行,打印 2,并产生新的 Promise 微任务。
  7. 定时器任务结束,再次清空微任务队列,打印 3

最终输出:

1 → 4 → 6 → 5 → 2 → 3

关键不是记住结果,而是每次都区分:当前正在执行的同步代码、当前任务结束后的微任务,以及后续任务。

5. 微任务与页面渲染

微任务会在浏览器获得下一次渲染机会之前清空,因此大量连续微任务可能长期占用主线程:

function scheduleForever() {
  queueMicrotask(scheduleForever);
}

scheduleForever();

每个微任务又创建下一个微任务,微任务队列始终无法清空,后续任务和页面渲染都会被推迟。这称为微任务饥饿

因此,微任务并不是“越多越快”。需要主动让出主线程的长任务,不应该通过递归微任务无限拆分。

6. requestAnimationFrame 在哪里执行?

requestAnimationFrame(rAF)回调既不是普通任务,也不是微任务。浏览器准备绘制新一帧时,会在样式计算、布局和绘制之前调用本帧的 rAF 回调。

它适合修改下一帧需要展示的视觉状态:

requestAnimationFrame(() => {
  element.style.transform = 'translateX(100px)';
});

需要注意:

  • rAF 跟随浏览器的渲染节奏,不保证固定为 16.67ms;
  • 页面进入后台时,rAF 通常会被暂停或明显降频;
  • rAF 回调执行时间过长,同样会阻塞渲染并造成掉帧。

requestIdleCallback 则用于浏览器判断当前帧还有空闲预算时执行低优先级工作,两者的定位不同。

7. 高频追问

setTimeout(fn, 0) 会立即执行吗?

不会。0 表示达到最小延迟后,回调可以进入任务队列。它仍要等待当前任务、当前微任务以及排在前面的其他任务完成,还可能受到嵌套定时器最小延迟和后台页面节流影响。

为什么 Promise 回调通常早于setTimeout

不是因为 Promise 的计算速度更快,而是因为调度阶段不同:Promise 回调进入微任务队列,会在当前任务结束时执行;定时器回调属于后续任务。

修改 DOM 后可以立刻读到新值吗?

JavaScript 层面的 DOM 属性通常已经改变,但屏幕像素不一定已经更新。浏览器要等主线程交还控制权,并获得合适的渲染机会后,才会完成样式、布局和绘制。

Node.js 事件循环和浏览器一样吗?

不一样。两者都有任务与微任务概念,但 Node.js 还包含 timers、poll、check 等阶段,并有 process.nextTick 队列。做浏览器题时不应使用 Node 专属 API 推导结果。

VuenextTick 与事件循环有什么关系?

Vue 会批量调度 DOM 更新,并让 nextTick 回调在更新完成后执行。其具体实现与版本有关,但理解微任务检查点有助于理解为什么同步修改状态后,DOM 不一定立刻完成更新。详见 Vue nextTick

8. 总结

  • 异步操作完成后,回调只是进入队列等待,并不会抢占当前调用栈。
  • 一个任务结束后,浏览器会清空微任务,再获得处理渲染和后续任务的机会。
  • 渲染不是普通任务,也不是每轮事件循环都必然发生。
  • 分析输出题时,先写同步代码,再写本轮微任务,最后处理后续任务。