人人都会AI编程

15.6 事件循环与 UI 渲染的执行顺序

更新时间:2026-07-11

在掌握了事件流和常用事件类型之后,有一个更深层的问题随之而来:用户交互触发的 JavaScript 代码,和浏览器更新页面的“渲染”之间,到底谁先谁后?为什么有时候连续修改 DOM,页面却没有出现中间状态?答案隐藏在事件循环与 UI 渲染的协作机制之中。

15.6.1 宏观视角:一帧之内发生了什么

现代浏览器的渲染频率通常是每秒 60 帧(60 FPS),这意味着每隔大约 16.7 毫秒,浏览器就有机会在屏幕上绘制出新的一帧。一个典型的帧周期包含以下步骤(简化版):

  1. 执行一个宏任务(例如一次 setTimeout 回调、一次用户事件处理)。
  2. 清空微任务队列(所有 Promise.then()MutationObserver 回调等)。
  3. 判断是否需要渲染(如果没有任务空闲或没有视觉变化,可能跳过渲染)。
  4. 如果需要渲染:触发 requestAnimationFrame 回调。
  5. 执行样式计算、布局(回流)、绘制(重绘)、合成等渲染流水线。
  6. 如果仍有时间,等待下一轮事件循环的开始。

关键在于:JavaScript 代码的执行和页面的渲染是交替进行的,JavaScript 的修改要等到渲染步骤才能真正显示在屏幕上

15.6.2 微任务与宏任务对渲染的影响

第 6 章已经详细介绍了宏任务(如 setTimeoutsetInterval、I/O 事件)和微任务(如 Promise.then()queueMicrotask())的概念。它们在渲染时机上的差异会影响视觉效果的感知。

微任务会阻塞渲染
宏任务执行完毕后,引擎会立即清空整个微任务队列,然后才考虑是否要渲染。如果微任务中又产生了新的微任务,它们会一直被执行,直到队列完全为空。这意味着,如果在一个宏任务中连续产生大量微任务,并且这些微任务处理时间很长,浏览器就无法在这期间进行渲染,页面会表现出“卡顿”甚至“假死”。

宏任务通常不会在一个帧内连续执行
由于每个宏任务结束后都会给渲染留下机会(尽管不一定每次都渲染),将一个大任务拆分成多个较短的宏任务,能让浏览器有机会在任务之间“喘口气”,及时更新界面。

渲染前的一个关键时机:requestAnimationFrame
在即将进行样式计算和布局之前,浏览器会触发所有排定的 requestAnimationFrame 回调。这是 JavaScript 在本次渲染之前,最后能够调整 DOM 或 CSS 的机会。因此,视觉动画相关的逻辑放在 requestAnimationFrame 中是最合适的,它能保证代码在每一帧渲染前执行,避免不必要的重排重绘。

15.6.3 代码实测执行顺序

下面用一段测试代码来直观感受执行顺序。在你的浏览器中打开控制台运行:

console.log('1. 同步代码开始');

setTimeout(() => {
    console.log('2. 宏任务 setTimeout');
    document.body.style.backgroundColor = 'lightblue';
}, 0);

Promise.resolve().then(() => {
    console.log('3. 微任务 Promise.then');
    document.body.style.backgroundColor = 'lightgreen';
});

requestAnimationFrame(() => {
    console.log('5. requestAnimationFrame 回调');
});

console.log('4. 同步代码结束');

在大多数现代浏览器中,控制台输出顺序大致为:

1. 同步代码开始
4. 同步代码结束
3. 微任务 Promise.then
5. requestAnimationFrame 回调
2. 宏任务 setTimeout

并且,你不会看到页面瞬间闪过 lightgreen。因为微任务执行完后才会触发渲染,背景色被改成 lightgreen 后,紧接着可能就进入渲染流水线,最终你看到的背景色就是 lightgreen(除非 setTimeout 随后再次修改)。而 setTimeout 的回调将在下一次事件循环(通常是下一帧或稍后)执行,所以页面背景最终会变成 lightblue

15.6.4 实际开发中的指导原则

  • 避免长时间连续占用主线程:无论是同步代码还是不断追加微任务,都会阻塞渲染。当遇到大数据计算时,应当考虑使用 requestAnimationFrame 时间切片、Web Worker 或分批执行宏任务来释放主线程。
  • 利用微任务完成状态同步:微任务的执行时机在下一轮渲染之前,非常适合用来处理框架层面的状态更新和批量 DOM 变更。这也是 Vue 的 nextTick 使用微任务(优先 Promise,降级到宏任务)的原因,它确保开发者可以在同一事件循环中获取到最新的 DOM。
  • 视觉动画绑定 requestAnimationFrame:如果需要为动画代码精确地匹配屏幕刷新率,避免在 setTimeout 中处理动画,因为它的执行时机与渲染帧并不同步,容易导致卡顿和丢帧。
  • 事件处理中的性能考量:如果某个高频事件(如 scrollmousemove)的处理函数重量较重,应使用防抖或节流,或者在事件代理中限制计算量,确保一次宏任务不会严重拖慢渲染。

事件循环与 UI 渲染的协作,是浏览器能够同时保持高效交互和流畅视觉的关键。理解它们的先后关系和优先级,就能写出既准确响应用户操作、又不会让界面卡顿的前端代码。