技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBR

浏览器事件循环

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/27-浏览器事件循环
本文目录17 个章节
  1. 相关问题
  2. 一、先给出一个准确的简化模型
  3. 二、事件循环解决了什么问题
  4. 三、任务(Task)
  5. 四、微任务(Microtask)
  6. 五、经典顺序题
  7. 六、渲染机会、requestAnimationFrame 和 requestIdleCallback
  8. 七、定时器为什么不精确
  9. 八、异步 API 和线程的正确理解
  10. 九、浏览器事件循环与渲染进程
  11. 十、任务调度的优先级不要绝对化
  12. 十一、Node.js 事件循环
  13. 十二、页面卡顿案例
  14. 十三、如何判断输出顺序
  15. 十四、原文中需要修正的说法
  16. 总结
  17. 参考资料

浏览器事件循环

Category(分类): Browser, JavaScript Status: 已更新

相关问题

  • 什么是浏览器事件循环?
  • 为什么 JavaScript 看起来可以异步执行?
  • 宏任务、微任务和渲染机会是什么关系?
  • setTimeout、Promise、requestAnimationFrame 的顺序如何判断?
  • 浏览器事件循环和 Node.js 事件循环有什么区别?

一、先给出一个准确的简化模型

浏览器需要同时处理脚本、用户输入、网络回调、定时器、页面渲染和 Worker 通信。对一个 Window 事件循环,可以先用下面的模型理解:

  1. 取出一个可运行的 task(任务)
  2. 执行任务,直到当前 JavaScript 执行上下文栈清空;
  3. 执行 microtask checkpoint,持续清空微任务队列;
  4. 浏览器根据需要安排样式计算、布局、绘制和合成;
  5. 进入下一轮,或者等待新的任务。

任务、微任务和渲染机会的简化流程

这不是一个“每轮固定执行宏任务、微任务、渲染线程”的机械流程:

  • 浏览器可能在某一轮没有渲染机会;
  • 渲染调度由浏览器、页面可见性、刷新率和当前负载决定;
  • 任务来源可能有不同调度策略,规范并没有给开发者一个可依赖的“所有宏任务统一优先级表”;
  • 事件循环可以共享给多个相关 Window,也可以由 Worker 单独拥有。

二、事件循环解决了什么问题

JavaScript 在同一个执行代理(agent)中一次运行一个执行上下文。代码不会在任意一行被另一个 JavaScript 回调抢占,这种 run-to-completion(运行至完成) 特性让普通代码不需要显式加锁。

如果网络、计时器或用户操作都同步阻塞主线程,页面就会在等待期间无法处理输入和渲染。因此浏览器把异步操作的“完成通知”安排成后续任务:

发起异步操作 -> 浏览器/系统处理等待 -> 完成后排队回调任务
                         主线程继续处理其他任务

异步并不等于 JavaScript 自己开了一条线程。fetch() 的网络工作由浏览器实现处理,回调仍然会回到对应的 JavaScript agent 中执行;要把计算真正移出页面主线程,需要 Dedicated Worker、SharedWorker、Service Worker 或 Worklet 等机制。

浏览器主线程和消息队列示意

三、任务(Task)

任务是由宿主环境安排的一次可运行工作。常见来源包括:

  • 初始脚本或模块脚本执行;
  • 用户点击、键盘、指针和输入事件派发;
  • setTimeoutsetInterval 到期后的回调;
  • 网络、文件、IndexedDB 等异步操作完成后的事件;
  • postMessageMessageChannel、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(thencatchfinally);
  • 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

推导:

  1. 初始脚本是一个 task,安排一个 Promise 微任务和一个 timer task;
  2. 初始 task 结束,微任务 Promise1 先运行,并安排 setTimeout2
  3. 微任务队列清空后,浏览器在之后的机会运行 setTimeout1
  4. setTimeout1 运行时安排 Promise2
  5. 这个 timer task 结束,微任务检查点运行 Promise2
  6. 后续再运行 setTimeout2

不要把 setTimeout(fn, 0) 理解为“立刻执行”,它只是允许在达到最小延迟且主线程有机会时,把回调排到之后的任务中。

六、渲染机会、requestAnimationFramerequestIdleCallback

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 的经典阶段包括:

  1. timers:处理到期的 setTimeout/setInterval
  2. pending callbacks:处理延迟到下一轮的 I/O 回调;
  3. idle/prepare:内部阶段;
  4. poll:获取和执行 I/O 回调;
  5. check:执行 setImmediate
  6. close callbacks:执行关闭事件回调。

Node 还需要区分:

  • process.nextTick 队列;
  • Promise 微任务队列;
  • libuv 各阶段中的回调。

Node 版本变化会影响某些 setTimeoutsetImmediate 和微任务交错细节,不要把 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'))

在主模块直接运行时,timerimmediate 的先后可能受调度影响;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()
  }
}

让出主线程会带来调度开销,分块大小应通过实际测量调整。

十三、如何判断输出顺序

遇到事件循环题,可以按下面步骤:

  1. 先执行当前 task 的同步代码;
  2. 记录 Promise/queueMicrotask 等微任务;
  3. 记录 timer、事件、消息等后续 task;
  4. 当前 task 结束后清空微任务,过程中新增的微任务也要处理;
  5. 再考虑后续 task;
  6. 只有题目明确涉及渲染、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 模型不能直接混用。

总结

  1. 一个事件循环通常执行一个 task,再清空微任务,然后在合适时机进行渲染;
  2. Promise、queueMicrotask 和 MutationObserver 属于微任务来源;
  3. 微任务会持续清空,递归微任务可能阻塞下一任务和渲染;
  4. setTimeout 只是把回调安排到未来任务,不保证精确时间;
  5. requestAnimationFrame 适合视觉更新,Worker 适合移出主线程的计算;
  6. 浏览器内部任务调度和渲染时机不能用一张固定优先级表完全描述;
  7. Node.js 有自己的 libuv 阶段、process.nextTicksetImmediate
  8. 解决卡顿的关键是缩短主线程任务、分块、让出主线程并持续测量。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS