浏览器事件循环
Category(分类): Browser, JavaScript Status: 已更新
相关问题
- 什么是浏览器事件循环?
- 为什么 JavaScript 看起来可以异步执行?
- 宏任务、微任务和渲染机会是什么关系?
setTimeout、Promise、requestAnimationFrame的顺序如何判断?- 浏览器事件循环和 Node.js 事件循环有什么区别?
一、先给出一个准确的简化模型
浏览器需要同时处理脚本、用户输入、网络回调、定时器、页面渲染和 Worker 通信。对一个 Window 事件循环,可以先用下面的模型理解:
- 取出一个可运行的 task(任务);
- 执行任务,直到当前 JavaScript 执行上下文栈清空;
- 执行 microtask checkpoint,持续清空微任务队列;
- 浏览器根据需要安排样式计算、布局、绘制和合成;
- 进入下一轮,或者等待新的任务。

这不是一个“每轮固定执行宏任务、微任务、渲染线程”的机械流程:
- 浏览器可能在某一轮没有渲染机会;
- 渲染调度由浏览器、页面可见性、刷新率和当前负载决定;
- 任务来源可能有不同调度策略,规范并没有给开发者一个可依赖的“所有宏任务统一优先级表”;
- 事件循环可以共享给多个相关 Window,也可以由 Worker 单独拥有。
二、事件循环解决了什么问题
JavaScript 在同一个执行代理(agent)中一次运行一个执行上下文。代码不会在任意一行被另一个 JavaScript 回调抢占,这种 run-to-completion(运行至完成) 特性让普通代码不需要显式加锁。
如果网络、计时器或用户操作都同步阻塞主线程,页面就会在等待期间无法处理输入和渲染。因此浏览器把异步操作的“完成通知”安排成后续任务:
发起异步操作 -> 浏览器/系统处理等待 -> 完成后排队回调任务
主线程继续处理其他任务
异步并不等于 JavaScript 自己开了一条线程。fetch() 的网络工作由浏览器实现处理,回调仍然会回到对应的 JavaScript agent 中执行;要把计算真正移出页面主线程,需要 Dedicated Worker、SharedWorker、Service Worker 或 Worklet 等机制。

三、任务(Task)
任务是由宿主环境安排的一次可运行工作。常见来源包括:
- 初始脚本或模块脚本执行;
- 用户点击、键盘、指针和输入事件派发;
setTimeout、setInterval到期后的回调;- 网络、文件、IndexedDB 等异步操作完成后的事件;
postMessage、MessageChannel、BroadcastChannel 消息;- Service Worker 或 Worker 发来的消息。
任务中的同步代码必须全部执行完,其他任务不能在中间抢占:
console.log('task start')
for (let i = 0; i < 1e9; i += 1) {
// 长时间占用主线程
}
console.log('task end')
这段循环会阻塞输入、样式、布局和绘制。浏览器多进程、网络线程或 GPU 线程不能让当前页面的这段主线程 JavaScript 自动变快。
四、微任务(Microtask)
微任务用于安排“当前任务完成后、下一任务开始前”的工作。常见来源:
- Promise reaction(
then、catch、finally); queueMicrotask();MutationObserver回调;- 部分 Web API 内部使用的 Promise 任务。
console.log('script start')
queueMicrotask(() => {
console.log('microtask')
})
Promise.resolve().then(() => {
console.log('promise reaction')
})
setTimeout(() => {
console.log('timer task')
}, 0)
console.log('script end')
常见输出:
script start
script end
microtask
promise reaction
timer task
微任务检查点会一直清空队列,包括执行过程中新增的微任务:
let count = 0
function schedule() {
queueMicrotask(() => {
count += 1
console.log(count)
if (count < 3) schedule()
})
}
schedule()
这也是微任务可能造成饥饿的原因:如果不断向微任务队列添加新任务,浏览器可能迟迟不能进入下一任务和渲染机会。
function starve() {
queueMicrotask(starve)
}
// 不要运行:会持续占用微任务检查点
// starve()
微任务不是“最高优先级线程”。它只是需要在当前任务结束时清空的队列;过多微任务一样会卡住页面。
五、经典顺序题
Promise.resolve().then(() => {
console.log('Promise1')
setTimeout(() => {
console.log('setTimeout2')
}, 0)
})
setTimeout(() => {
console.log('setTimeout1')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)
在通常浏览器环境中输出:
Promise1
setTimeout1
Promise2
setTimeout2
推导:
- 初始脚本是一个 task,安排一个 Promise 微任务和一个 timer task;
- 初始 task 结束,微任务
Promise1先运行,并安排setTimeout2; - 微任务队列清空后,浏览器在之后的机会运行
setTimeout1; setTimeout1运行时安排Promise2;- 这个 timer task 结束,微任务检查点运行
Promise2; - 后续再运行
setTimeout2。
不要把 setTimeout(fn, 0) 理解为“立刻执行”,它只是允许在达到最小延迟且主线程有机会时,把回调排到之后的任务中。
六、渲染机会、requestAnimationFrame 和 requestIdleCallback
1. 渲染不是每个任务后必然发生
原文把流程写成“每个宏任务后渲染线程接管”,这过于绝对。浏览器会在适合的渲染机会执行样式、布局、绘制和合成;如果页面不可见、没有视觉变化、主线程繁忙或浏览器正在节流,也可能跳过或延后绘制。
一个较实用的概念模型是:
task -> microtasks -> 可能的渲染更新 -> requestAnimationFrame -> 绘制
具体执行顺序受规范和浏览器实现影响,不能用它替代性能面板的实际记录。
2. requestAnimationFrame
requestAnimationFrame() 请求在浏览器下一次绘制前运行回调,适合视觉动画:
const box = document.querySelector('.box')
let start
function animate(timestamp) {
if (start === undefined) start = timestamp
const elapsed = timestamp - start
const progress = Math.min(elapsed / 500, 1)
box.style.transform = `translateX(${progress * 200}px)`
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
注意:
- 回调不是固定 60 次/秒,显示器可能是 60Hz、90Hz、120Hz,页面也可能被节流;
- 页面隐藏时通常会暂停或降低调用频率;
- 回调中的长任务仍会导致掉帧;
- 使用回调传入的时间戳计算动画,不要假定每帧恒定 16.67ms;
rAF只适合安排视觉更新,不是通用异步延时器。
3. requestIdleCallback
requestIdleCallback() 可以安排低优先级工作,但不适合关键渲染和必须按时执行的逻辑:
requestIdleCallback(deadline => {
while (deadline.timeRemaining() > 0 && pendingWork.length) {
processSmallPiece(pendingWork.shift())
}
}, { timeout: 2000 })
它的回调可能被延迟,生产代码要设置 timeout 或提供普通任务回退,并验证浏览器支持。
七、定时器为什么不精确
setTimeout(() => {
console.log('至少尝试等待 100ms')
}, 100)
setTimeout 的 delay 是“允许回调进入队列前的最小等待倾向”,不是精确执行时间:
- 当前任务或微任务未完成时不能执行;
- 主线程繁忙时会延后;
- 后台标签页和隐藏页面会被浏览器节流;
- 电量、系统负载、浏览器策略和页面加载会影响计时;
- 延迟为 0 也要等到后续任务机会;
- 嵌套计时器存在规范规定的最小延迟,实际还可能受后台节流影响。
对于连续动画使用 requestAnimationFrame,对于节奏要求高的音频和媒体使用相应的媒体时钟,不要用 setInterval 代替精确时钟。
八、异步 API 和线程的正确理解


fetch
fetch('/api/user')
.then(response => response.json())
.then(user => {
renderUser(user)
})
.catch(reportError)
网络等待期间主线程可以处理其他任务,但 Promise 回调仍然在页面的 JavaScript agent 中运行。解析很大的 JSON、计算排序和图片处理仍可能阻塞主线程。
Worker
const worker = new Worker('/workers/parse.js')
worker.onmessage = event => {
renderResult(event.data)
}
worker.postMessage(largeData)
Worker 有自己的全局对象、执行栈和事件循环。消息默认通过结构化克隆传递,数据量很大时要考虑复制成本;可以使用 Transferable 对象转移所有权。Worker 不是“让所有 DOM 操作并行”,普通 Worker 不能直接访问页面 DOM。
MessageChannel 和 Service Worker
它们也是通过消息任务和结构化克隆通信,消息处理代码最终仍要在相应 agent 中运行。Service Worker 可以被终止和重新启动,不能只把内存变量当作持久状态。
九、浏览器事件循环与渲染进程
旧文章常把浏览器描述为“GUI 渲染线程和 JS 引擎线程互斥”或“浏览器只有一个渲染线程”。更准确的说法是:
- 页面 JavaScript、DOM、样式计算和许多布局任务通常由渲染进程的主线程处理;
- 合成、滚动、栅格化、图像解码和 GPU 工作可能在其他线程或进程进行;
- 主线程被长 JavaScript 占用时,许多页面更新和事件处理会延迟;
- 某些 transform/opacity 动画和滚动在满足条件时可以由合成器继续处理,但不能保证所有视觉效果都脱离主线程;
- Worker 有独立执行环境,但浏览器是否对应一个独立操作系统线程或进程属于实现细节。

十、任务调度的优先级不要绝对化
原文把 Chrome 内部描述成“延时队列、交互队列、微任务队列”的固定优先级,并给宏任务列出统一顺序。现代浏览器确实会按任务来源、输入响应、渲染和页面状态做调度优化,但这些内部队列不是开发者可以依赖的公开 API。
开发者可以控制的是:
- 不在一个 task 中执行过长同步代码;
- 用
queueMicrotask安排当前任务结束前的必要收尾; - 用
setTimeout/MessageChannel/scheduler.postTask(支持时)让工作让出主线程; - 用
requestAnimationFrame安排视觉更新; - 用 Worker 移出适合并行的计算;
- 对输入事件、滚动和 resize 做合理的节流,但不要盲目延迟用户反馈。
Scheduler.postTask 如果目标浏览器支持,可以表达优先级:
if ('scheduler' in window) {
scheduler.postTask(() => {
updateNonCriticalPanel()
}, { priority: 'background' })
}
它仍不能把长任务变成免费任务,也需要特性检测和回退。
十一、Node.js 事件循环
Node.js 使用 libuv 管理事件循环,和浏览器不是同一套宿主模型。Node 的经典阶段包括:
- timers:处理到期的
setTimeout/setInterval; - pending callbacks:处理延迟到下一轮的 I/O 回调;
- idle/prepare:内部阶段;
- poll:获取和执行 I/O 回调;
- check:执行
setImmediate; - close callbacks:执行关闭事件回调。
Node 还需要区分:
process.nextTick队列;- Promise 微任务队列;
- libuv 各阶段中的回调。
Node 版本变化会影响某些 setTimeout、setImmediate 和微任务交错细节,不要把 Node 的 setImmediate、I/O 阶段套到浏览器里。浏览器没有标准的 setImmediate。
一个常见 Node 示例:
setTimeout(() => console.log('timer'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
在主模块直接运行时,timer 和 immediate 的先后可能受调度影响;nextTick 通常先于 Promise 微任务执行。进入 I/O 回调后,setImmediate 通常更容易先于定时器执行。生产代码不要依赖不必要的偶然顺序。
十二、页面卡顿案例
const button = document.querySelector('button')
const title = document.querySelector('h1')
button.addEventListener('click', () => {
title.textContent = 'hello event loop'
const end = performance.now() + 3000
while (performance.now() < end) {
// 模拟长任务
}
})
虽然 DOM 文本已经被修改,但浏览器通常要等当前事件任务结束、微任务清空,并获得渲染机会后才把结果绘制到屏幕。因此用户可能先看到页面卡住,3 秒后标题才出现。
改进思路:
- 拆分计算,让出主线程;
- 使用 Worker 处理纯计算;
- 先更新视觉状态,再通过后续任务处理非关键计算;
- 避免在事件回调中同步解析超大 JSON 或遍历大量 DOM;
- 使用 Performance 面板查看 Long Task 和帧时间。
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0))
}
async function processInChunks(items) {
for (let index = 0; index < items.length; index += 100) {
processChunk(items.slice(index, index + 100))
await yieldToMain()
}
}
让出主线程会带来调度开销,分块大小应通过实际测量调整。
十三、如何判断输出顺序
遇到事件循环题,可以按下面步骤:
- 先执行当前 task 的同步代码;
- 记录 Promise/
queueMicrotask等微任务; - 记录 timer、事件、消息等后续 task;
- 当前 task 结束后清空微任务,过程中新增的微任务也要处理;
- 再考虑后续 task;
- 只有题目明确涉及渲染、
rAF、页面可见性或宿主差异时,才加入渲染机会分析。
例如:
function test() {
console.log(1)
Promise.resolve().then(() => console.log(2))
}
setTimeout(() => {
console.log(3)
Promise.resolve().then(test)
}, 0)
Promise.resolve().then(() => {
console.log(4)
setTimeout(() => console.log(5), 0)
})
console.log(6)
通常输出:
6
4
3
1
2
5
推导:初始脚本打印 6;微任务打印 4 并安排 timer 5;第一个 timer 打印 3 并安排微任务 test;该微任务打印 1,再安排并执行微任务 2;最后运行 timer 5。
十四、原文中需要修正的说法
- “每个客户端拥有完全独立的事件循环”过于绝对,同源 Window 可能共享 agent/事件循环;
- “宏任务优先级固定、微任务最高”不是可依赖的浏览器内部队列表;
setImmediate是 Node API,不是浏览器标准 API;- 定时器不是精确时钟,0ms 也不会立即执行,后台页面还会节流;
- 异步 API 不等于一定开启一个 JS 线程,回调仍会回到相应 agent;
- 浏览器不会保证每个任务后都进行渲染;
requestAnimationFrame不是固定 60Hz,也不能修复长任务;- 浏览器可以使用合成线程、Worker、GPU 等协作线程,但页面主线程仍可能成为瓶颈;
- Node.js 的事件循环阶段和浏览器 task/microtask 模型不能直接混用。
总结
- 一个事件循环通常执行一个 task,再清空微任务,然后在合适时机进行渲染;
- Promise、
queueMicrotask和 MutationObserver 属于微任务来源; - 微任务会持续清空,递归微任务可能阻塞下一任务和渲染;
setTimeout只是把回调安排到未来任务,不保证精确时间;requestAnimationFrame适合视觉更新,Worker 适合移出主线程的计算;- 浏览器内部任务调度和渲染时机不能用一张固定优先级表完全描述;
- Node.js 有自己的 libuv 阶段、
process.nextTick和setImmediate; - 解决卡顿的关键是缩短主线程任务、分块、让出主线程并持续测量。