浏览器事件循环和页面渲染之间是什么关系?
简化版
浏览器事件循环会不断执行宏任务,清空微任务,然后在合适时机进行渲染。常见宏任务有定时器、事件回调、网络回调;微任务有 Promise、MutationObserver。微任务会在当前宏任务结束后立即清空,过多微任务可能阻塞渲染。
详细版
浏览器主线程要处理 JS 执行、事件回调、样式计算、布局、绘制等任务。事件循环负责调度这些工作。
大致流程:
- 执行一个宏任务。
- 清空所有微任务。
- 判断是否需要渲染。
- 执行动画帧回调、样式计算、布局、绘制。
- 进入下一轮循环。
Promise 回调属于微任务,所以会比 setTimeout 更早执行。requestAnimationFrame 通常在浏览器下一次绘制前执行,适合做动画更新。
完整版教学
一、为什么需要事件循环
浏览器主线程一次只能做一件事。用户点击、定时器、网络回调、脚本执行、渲染更新都需要排队处理。
事件循环就是调度机制,它决定什么时候执行 JS,什么时候处理微任务,什么时候进行页面渲染。
如果 JS 长时间占用主线程,浏览器就无法及时响应用户输入,也无法刷新页面,用户看到的就是卡顿。
二、宏任务和微任务
宏任务可以理解为事件循环中的一轮主要任务,例如:
- 整体脚本执行。
setTimeout。setInterval。- 用户事件回调。
- 网络回调。
微任务是当前宏任务结束后立刻执行的任务,例如:
Promise.then。queueMicrotask。MutationObserver。
规则是:一个宏任务执行完后,会把当前微任务队列清空,再进入渲染判断或下一轮任务。
三、渲染为什么会被阻塞
渲染需要主线程参与。如果当前宏任务执行很久,或者微任务不断追加新的微任务,浏览器就没有机会进入渲染阶段。
例如大量 Promise 链式递归执行,虽然每个回调都很短,但微任务队列一直清不完,页面也会无法更新。
所以复杂计算应拆分任务,必要时使用 Web Worker;视觉更新可以使用 requestAnimationFrame,让浏览器在合适时机执行。
四、面试追问与工程落地
面试官常问:“Promise、setTimeout、requestAnimationFrame 谁先执行?”
同步代码结束后会先完成微任务检查点;requestAnimationFrame 在浏览器选择的下一次渲染机会前执行,setTimeout 则进入后续任务。rAF 与 timer 谁先不能脱离当前帧时机绝对化,但微任务会在进入下一个任务或渲染机会前完成。
工程中要避免长任务超过一帧预算。60fps 下每帧约 16.7ms,JS 执行太久就会掉帧。
五、渲染机会而非固定步骤
规范模型中,事件循环选择任务执行,随后执行微任务检查点,并在符合条件时更新渲染;渲染不是每执行一个任务后都必然发生。后台标签、屏幕刷新率、页面是否可见和浏览器调度都会改变帧机会,setTimeout(0) 与 rAF 的相对先后也不能脱离当前帧时机给出全局保证。
选择一个 task → 执行 JS → 清空 microtasks
↓
到了渲染机会?——否→ 下一任务
│是
rAF → style/layout → paint/composite
| 调度方式 | 队列/时机 | 主要用途 | 饥饿风险 |
|---|---|---|---|
| task | 事件循环任务队列 | 事件、定时器等 | 单个长任务阻塞帧 |
| microtask | 当前任务后检查点 | Promise 后续、状态收尾 | 递归追加阻塞渲染 |
| rAF | 下一次渲染前 | 视觉状态更新 | 回调过重仍掉帧 |
| Worker | 独立执行上下文 | CPU 计算 | 通信与复制有成本 |
六、帧预算、任务切分与布局时序
60Hz 屏幕一帧约 1000/60 ≈ 16.7ms,但浏览器还要留时间处理输入、样式、布局和绘制,JavaScript 可用预算通常小于 16.7ms。120Hz 下整帧仅约 8.3ms,所以“低于 16ms 就永远流畅”也不成立。
若一个计算需 80ms,它会连续错过约 5 个 60Hz 帧。可把非原子工作按任务切片并让出主线程,优先级调度能力可用时再采用;真正重计算放 Web Worker,但传输 20MB 数据也会产生结构化克隆或所有权转移成本。
rAF 回调适合集中写入下一帧视觉状态,但在回调中先写样式再读 offsetWidth 仍可能强制同步布局。常见策略是先批量读,再在 rAF 批量写;需要观察渲染后结果时选择合适 observer 或下一时机,不要靠连续 Promise 等待“下一帧”,因为 Promise 仍在同一微任务检查点。
调度心法:微任务保证“当前任务收尾”,rAF 对齐“下一次绘制”,task 用来“把主线程还给浏览器”,三者目的不同。
七、常见误区与追问
- 误区:浏览器每执行一个宏任务后都会渲染一次。 只是在合适渲染机会更新,多个任务可能位于两帧之间。
- 误区:Promise 一定按“微任务 → rAF → setTimeout”固定顺序执行。 微任务先于下一个任务成立,但 rAF 与 timer 取决于帧调度和注册时机。
- 误区:把长任务改成递归
queueMicrotask就能让页面渲染。 微任务队列会在渲染机会前持续清空,仍可能造成饥饿。 - 追问:rAF 为什么适合动画? 浏览器在绘制前调用它,可合并视觉更新并在后台页面节流,但回调仍要足够轻。
- 追问:60fps 的 16.7ms 能全部给 JS 吗? 不能,输入、样式、布局、绘制和合成都共享这一帧预算。
- 追问:任务切片和 Web Worker 如何选择? 需要访问 DOM 的少量工作可切片,独立重计算可进 Worker,并评估通信成本。
- 追问:微任务什么时候清空? 在脚本/回调退出且调用栈为空的微任务检查点处理,过程中新增微任务也会继续执行。
八、加强记忆
浏览器事件循环按“任务执行、微任务收尾、等待渲染机会”记,rAF 在绘制前对齐视觉更新。长 task 直接阻塞输入和帧,递归微任务同样能饿死渲染;60Hz 的 16.7ms 还要分给样式、布局与绘制。解决问题要在批量读写、任务切片和 Worker 之间按 DOM 依赖与通信成本选择。