JavaScript / 浏览器 / 前端原理
浏览器事件循环与渲染:从 task、微任务到下一帧
“Promise 一定比 setTimeout 先执行”只是事件循环最表层的结论。真正影响前端体验的问题通常是:DOM 明明改了,为什么页面还没显示?一个 await 能不能让浏览器先渲染?为什么把大任务拆成很多 Promise,页面仍然卡住?动画逻辑为什么应该放进 requestAnimationFrame?
要回答这些问题,需要把 JavaScript 队列和浏览器渲染放在同一条时间线上理解。
先建立一条可用的时间线
浏览器会持续运行事件循环。对页面前端来说,可以先使用下面这个简化模型:
- 从 task 队列中取出一个可运行任务并执行。
- 当前调用栈清空后,执行微任务检查点,持续清空 microtask 队列。
- 浏览器判断现在是否有渲染机会;如果有,执行渲染相关步骤。
- 在下一次重绘前调用符合条件的
requestAnimationFrame回调。 - 进入后续循环,处理新的 task。
真实 HTML 标准中的事件循环比这复杂,任务也有不同来源和调度规则。这个模型的价值不是替代规范,而是帮助我们解释常见页面行为。
一次脚本执行、点击事件回调、定时器回调,都可以形成 task。Promise reaction 和 queueMicrotask 回调属于 microtask。关键点在于:当前 task 结束后,浏览器会先清空微任务,再可能获得渲染机会。
一段代码的执行顺序
1 | console.log('script start'); |
常见输出是:
1 | script start |
整个脚本先作为当前 task 同步执行。Promise 回调与 queueMicrotask 按进入微任务队列的顺序运行。setTimeout(..., 0) 只是在计时条件满足后安排一个后续 task,并不代表“立刻执行”,也不保证精确延迟为 0ms。
如果一个微任务运行时继续加入微任务,浏览器会继续处理新加入的项目:
1 | function keepBusy() { |
这会造成微任务饥饿:后续 task 难以运行,渲染机会也迟迟不到。生产代码当然很少直接写出这个无限循环,但递归 Promise、无边界的数据消费和在微任务中不断批处理,可能制造相似问题。
DOM 更新了,不等于像素已经出现
看下面的例子:
1 | status.textContent = '正在计算…'; |
JavaScript 确实先把文本改成“正在计算…”,随后又改成“计算完成”。但同一个 task 一直占用主线程,浏览器没有机会在中间绘制。用户大概率只能看到最终状态,还会感到页面冻结了两秒。
加入 await Promise.resolve() 也不能可靠地让出绘制机会:
1 | status.textContent = '正在计算…'; |
await 后的延续通常作为微任务执行,仍处在下一次渲染机会之前。要把重活放到后续 task,可以显式让出当前 task:
1 | status.textContent = '正在计算…'; |
这给了浏览器一次可能的渲染机会,但“可能”两个字很重要。浏览器不会承诺每个 task 后都绘制一帧,设备刷新率、页面是否可见和内部调度都会影响结果。如果你的目标是与下一次视觉更新对齐,应该使用 requestAnimationFrame。
requestAnimationFrame 解决什么问题
MDN 对 requestAnimationFrame 的描述是:请求浏览器在下一次重绘之前调用指定回调。它适合把动画的状态更新与浏览器刷新节奏对齐:
1 | function animate(timestamp) { |
动画应使用回调提供的时间戳计算进度,而不是假设每帧固定 16.67ms。高刷新率屏幕、后台标签页和系统繁忙都会让帧间隔变化。按时间计算,动画即使掉帧也能保持总体时长正确。
requestAnimationFrame 不是通用后台任务队列。把 100ms 的复杂计算塞进回调,照样会错过帧。回调里应该只做下一帧需要的少量状态计算和 DOM 写入。
读取布局和写入样式要分批
浏览器通常会批量处理样式与布局,但读取某些几何属性时,为了返回当前准确结果,可能需要提前完成布局。交替读写会不断打断批处理:
1 | for (const item of items) { |
循环中每次读取 offsetWidth 都发生在样式写入附近。更合理的方式是先读后写:
1 | const containerWidth = container.offsetWidth; |
当然,三列布局本身更适合交给 CSS Grid:
1 | .container { |
理解事件循环的最终目的不是写出更多调度代码,而是判断哪些工作根本不该由 JavaScript 做。
如何拆分不会阻塞交互的计算
假设要在主线程处理 10,000 条记录。一次循环完成会形成长任务;用 Promise 把每条记录包装成微任务,也可能在一次微任务检查点中全部跑完。可以按批次安排后续 task:
1 | function processRecords(records, batchSize = 100) { |
每批之间,事件循环可以处理用户输入和潜在的渲染工作。批次仍然要通过性能记录调整;如果单条 transform 本身就很重,继续减小批次也治标不治本,应考虑算法优化或 Web Worker。
这里还要区分两种“异步”:网络、文件等 I/O 等待不会持续占用主线程;把同步计算放进 async 函数,却不会自动移到另一个线程。async/await 改变的是控制流,不是执行计算的线程。
常见 API 放在哪一层理解
可以用一张简化表帮助记忆:
| API 或来源 | 通常进入哪里 | 典型用途 |
|---|---|---|
<script>、事件回调 |
task | 执行脚本、处理输入 |
setTimeout |
后续 task | 延后工作、分批让出主线程 |
Promise.then、await 延续 |
microtask | 衔接异步结果、在当前循环末尾更新状态 |
queueMicrotask |
microtask | 安排轻量且必须先于后续 task 的工作 |
requestAnimationFrame |
下一次重绘前的回调 | 动画、与视觉帧对齐的 DOM 写入 |
这张表不能用来推导所有边界顺序。例如不同任务来源之间的选择、浏览器内部渲染条件、后台页面节流都更复杂。业务代码不应该依赖“两个不同来源的 task 谁一定先执行”这类脆弱假设。
调试时看什么
当页面出现卡顿或渲染时序错误时,建议按下面的方式调查:
- 在浏览器性能面板录制交互,不只看 Console 输出。
- 找到占用主线程的长 task,展开调用栈定位业务函数。
- 查看大量微任务是否连续出现,确认是否存在 Promise 链或递归调度。
- 检查样式重算、布局和绘制耗时,寻找交替读写 DOM 的代码。
- 对动画检查每帧工作量,使用时间戳而不是固定帧率推进。
- 对大计算尝试分批或 Worker,并在改造后重新录制对比。
真正实用的结论只有几条:同步 JavaScript 会一直占用当前 task;微任务优先于后续 task,并且会在渲染机会之前清空;await 不等于让浏览器先画一帧;视觉更新使用 requestAnimationFrame 对齐;大计算必须减少、分块或移出主线程。
掌握这些边界后,很多“偶现”的前端问题就不再神秘。它们只是某段工作被放进了错误的队列,或者在浏览器需要交还主线程时仍然没有结束。
参考资料
- WHATWG HTML:Event loops — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
- MDN:In depth - Microtasks and the JavaScript runtime environment — https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth
- MDN:Window.requestAnimationFrame() — https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame