96SEO 2026-07-24 13:36 9
最近重新梳理浏览器事件循环。发现很多文章最终都会落到输出题:
宏任务、微任务、requestAnimationFrame、渲染,到底谁先?老实说,
输出题它适合检查基础概念。但如果只把事件循环背成“宏任务队列 + 微任务队列”,遇到 requestAnimationFrame 和渲染时机就很容易卡住。

我的思路这方面,先看哪些输出是稳定的。再看 timer 和 rAF 为什么不一定谁先,最终结合 Chromium 源码解释背后的原因。
这篇文章参考源码版本固定为:
Chromium: .commit: d096af1c9e98c45c3596e59620622b1a049bfecb
async function run { console.log;await null,console.log;}console.log;setTimeout => { console.log;},),Promise.resolve.n => { console.log;}),queueMicrotask => { console.log;}),requestAnimationFrame => { console.log;}),run;console.log,
稳定能确定的输出是:
script startasync startscript endpromisequeueMicrotaskasync end
那后面的 timer 和 rAF 呢?
这就是这道题真正有意思的地方:timer`和``rAF`不一定谁先。
说白了完整输出可能是情况一:
script startasync startscript endpromisequeueMicrotaskasync endrAFtimer
也可能是情况二:
script startasync startscript endpromisequeueMicrotaskasync endtimerrAF
前六行是稳定的,因为它们只涉及当前 script task 和 microtask checkpoint。后两行不稳定,因为它们涉及 timer task 和下一次渲染机会。而浏览器不是每轮事件循环都会渲染。
先把结论放前面:
| output situation | common trigger condition | how to understand it | |
|---|
请改成简洁版本:
整体 script 本身就是一个 task。这个 task 里的同步代码会一直执行到结束,所以先输出:
text
script start async start script end
中间遇到几个异步 API:
* `setTimeout` 注册一个后续 timer task。* `Promise.n` 注册一个微任务。* `queueMicrotask` 注册一个微任务。* `await null` 后面的代码会作为后续微任务执行。* `requestAnimationFrame` 注册下一次渲染前回调。等当前 script task 结束,浏览器会执行微任务检查点,于是依次输出:
text
promise queueMicrotask async end
注意这方面。`timer` 和 `rAF` 谁先,不应该用一句“rAF 一定在 timer 前”来记。`timer` 是后续 timer task,`rAF` 是下一次视觉更新前回调。它们的相对顺序和帧时机、页面状态、调度策略有关。
Task一段普通异步工作。比如整体脚本、定时器回调、事件回调或解析器的一次继续解析。
MicroTask当前 Task 完成后的收尾,比如 Promise reaction、queueMicroTask 或 await 的后续。
Rendering Opportunity一次浏览器有机会更新页面的时机,并非每个 Task 后都一定发生。
有了这张图,就能理解为什么 rAF 和 timer 不应该放到同一个简单队列里比较。Timer 等待的是“下一个可运行 Task”,Raf 等待的是“下一次渲染步骤”。
很多时候页面卡住不是因为浏览器“不想渲染”,而是主线程还没空出来。例如这方面,
javascript
box.textContent = 'loading...';while {
// 长时间同步计算
}
这里即使修改了 DOM。也不会立刻看到效果,因为当前 JS Task 没结束——浏览器没有机会进入微任务检查点,也没有机会进入渲染更新。
在 Chromium Blink 的调度文档里也能看到这个实现思路。在 Blink 的 Scheduler 文档里可以看到:
cpp
// At moment Blink Scheduler treats tasks as an atomic unit...
可以把它理解成 Task 一旦开始,调度器不会从中间把它切开;其实,只有等当前 Task 结束后才会选下一个可运行 Task。
平时写代码时要注意长时间运行的同步操作,例如:
javascript
button.addEventListener => {
heavyWork;updateDOM,});按理说,
如果 heavyWork 很久。
则使用者输入、动画和渲染都会被拖住。解决思路通常不是换成 Promise,而是把重活拆成多段或放到 Worker。
再看 MicroTask。例如的观点是,
javascript
console.log;setTimeout => {
console.log;},),Promise.resolve.n => {
console.log;}),queueMicrotask => {
console.log;}),说起来,console.log;
至于输出为,
text
startendpromisemicrotasktimer
说到原因。
start,end: 同步代码。
Promise.n / queueMicroTask 入微任务队列。
setTimeout 入后续 Timer Queue。
当前 Task 完毕后立即清空 MicroTasks。其实,
微任务清完后才轮到下个 Timer 或者 Rendering Opportunity。
再看易错例子的观点是,
javascript
Promise.resolve.n => {
console.log;queueMicrotask => {
console.log;}),});Promise.resolve.n => {
console.log;}),setTimeout => {
console.log;},),怎么说呢,
text
promise1promise2microtask in promisetimer
此处说明 promise 内追加 MicroTask 会排到当期 Queue 尾部。但仍在本轮 Checkpoint 内完成。不过,若不停追加 MicroTasks 如下面例子。就会阻塞 Rendering:
javascript
box.textContent = 'loading...';function loop { queueMicrotask;}
loop,按理说,
虽然不是同步死循环。但由于 MicroTask checkpoint 永远无法清空,所以主线程永远不能进入新的 Rendering 或其它 Task。
下面简单例子展示 await 的排队顺序:
javascript
async function foo {
console.log;await,console.log;按理说,}
Promise.resolve.n => {console.log;}),foo;按理说,Promise.resolve.n=>{console.log;}),
text
foo start promise1 foo end promise2
至于关键点,
Promise1 入 MicroTask Queue;
调用 foo 输出 'foo start';
当走到 await 时将 'foo end' 入 MicroTask Queue;
再加入 Promise2;
结果顺序为 promise1 → foo end → promise2。
从举例来看,
javascript
box.textContent = 'loading...';box.style.width = '200px';
这里同步修改了 DOM/CSSOM 状态,但并不代表着屏幕已画出。其实,当下 JS Task 未结束,没有进入 MicroTasks 清理。也未达到 Visual Update 时机之前,都无法触发 Render 步骤。下面代码不会在显示 loading 后再做计算:
javascript jsx=
box.textContent = 'loading...';let i =,while {}
若想让页面及时刷新,可以将重活推迟至下一轮。如 setTimeout 或 requestAnimationFrame:
javascript jsx=
box.textContent = 'loading...';setTimeout=>{ heavyWork;},),requestAnimationFrame=>{ box.style.transform = 'translateX';}),其实,
Blink 源码入口为 dom_timer.cc 简化流程如下:
cpp
int DOMTimer::setTimeout { auto* action = MakeGarbageCollected<ScheduledAction>;return MakeGarbageCollected<DOMTimer>(context,action。base::Milliseconds,true)->timeoutid;} ... if { // ... ... MoveToNewTaskRunner);... } DOMTimer::Fired executes action->Execute;setTimeout 并非马上执行,而投递了一个未来 Timer Task。
入口在 document.cc 与 scriptedanimationcontroller.cc:
Document::RequestAnimationFrame registers callback in ScriptedAnimationController;Schedule animation if needed. PageAnimator::ServiceScriptedAnimations runs resize/scroll/media query/raf callbacks. This explains *** sometimes raf precedes timers and vice versa.
HTML Parser 在 Blink 的 htmldocumentparser.cc 中工作;主要函数 PumpTokenizerIfPossible/PumpTokenizer 按预算解析 HTML。并按需 yield 给其他 high‑priority work,如脚本或样式加载;之后通过 SchedulePumpTokenizer 投递剩余解析至新的 Task。
Local Frame View UpdateAllLifecyclePhases 推进 DocumentLifecycle 到 PaintClean;其内部调用 RunStyleAndLayoutLifecyclePhases / RunCompositingInputsLifecyclePhase / RunPrePaintLifecyclePhase / RunPaintLifecyclePhase。在 PageAnimator::ServiceScriptedAnimations 调用中完成 Resize/Scroll/Media Query/raf callbacks,接下来进入样式/layout/pre‑paint/paint/compositing 阶段。
从规则如下来看,
requestAnimationFrame is a pre‑render callback—not a normal timed macro‑event like setTimeout.
So from opening question’s viewpoint:
The stable part of output is always:
作为专业的SEO优化服务提供商,我们致力于通过科学、系统的搜索引擎优化策略,帮助企业在百度、Google等搜索引擎中获得更高的排名和流量。我们的服务涵盖网站结构优化、内容优化、技术SEO和链接建设等多个维度。
| 服务项目 | 基础套餐 | 标准套餐 | 高级定制 |
|---|---|---|---|
| 关键词优化数量 | 10-20个核心词 | 30-50个核心词+长尾词 | 80-150个全方位覆盖 |
| 内容优化 | 基础页面优化 | 全站内容优化+每月5篇原创 | 个性化内容策略+每月15篇原创 |
| 技术SEO | 基本技术检查 | 全面技术优化+移动适配 | 深度技术重构+性能优化 |
| 外链建设 | 每月5-10条 | 每月20-30条高质量外链 | 每月50+条多渠道外链 |
| 数据报告 | 月度基础报告 | 双周详细报告+分析 | 每周深度报告+策略调整 |
| 效果保障 | 3-6个月见效 | 2-4个月见效 | 1-3个月快速见效 |
我们的SEO优化服务遵循科学严谨的流程,确保每一步都基于数据分析和行业最佳实践:
全面检测网站技术问题、内容质量、竞争对手情况,制定个性化优化方案。
基于用户搜索意图和商业目标,制定全面的关键词矩阵和布局策略。
解决网站技术问题,优化网站结构,提升页面速度和移动端体验。
创作高质量原创内容,优化现有页面,建立内容更新机制。
获取高质量外部链接,建立品牌在线影响力,提升网站权威度。
持续监控排名、流量和转化数据,根据效果调整优化策略。
基于我们服务的客户数据统计,平均优化效果如下:
我们坚信,真正的SEO优化不仅仅是追求排名,而是通过提供优质内容、优化用户体验、建立网站权威,最终实现可持续的业务增长。我们的目标是与客户建立长期合作关系,共同成长。
Demand feedback