浏览器事件循环:从原理到最佳实践

284 阅读4分钟

概述

要真正理解事件循环,我们需要先了解浏览器的多进程架构:

  • 浏览器主进程:负责界面显示、用户交互

  • GPU进程:处理图形渲染

  • 网络进程:处理网络请求

  • 渲染进程(核心):每个标签页一个渲染进程,包含:

    • 主线程:执行JS、解析HTML/CSS、布局、绘制(就是我们常说的JS线程)
    • 合成线程:负责图层分割
    • 光栅线程:将图层转换为像素

🔍 关键点:JS引擎(如V8)只是渲染进程的一部分,JS的"单线程"指的是主线程的单线程,浏览器整体是多线程的。

1. 事件循环的完整运行机制

1.1 核心组件详解

(1) 调用栈(Call Stack)

  • 本质:记录函数调用的数据结构(LIFO栈)

  • 特点:

    • 每次函数调用都会创建新的栈帧(包含参数、局部变量等)
    • 栈溢出:当递归深度超过最大调用栈大小(Chrome约1万层)
// 栈溢出示例
function stackOverflow() {
  stackOverflow()
}
stackOverflow() // Uncaught RangeError

(2) 堆内存(Heap)

  • 存储引用类型(对象、数组等)的内存区域

  • 与栈的区别:

    • 栈:自动分配固定大小内存(基础类型、指针)
    • 堆:动态分配内存,需要垃圾回收

(3) 任务队列系统

队列类型触发方式优先级示例
微任务队列JS引擎直接管理Promise.then, queueMicrotask
宏任务队列由浏览器宿主环境管理setTimeout, 事件回调
动画回调队列requestAnimationFrame特殊动画更新
空闲回调队列requestIdleCallback最低非关键任务

💡 重要细节:微任务会在每个宏任务执行完后立即清空,包括渲染前和事件循环的每个阶段之间。

1.2 完整事件循环流程

image.png

关键阶段说明:

  1. 微任务检查点

    • 在每个宏任务结束后
    • 在每次事件循环迭代开始时
  2. 渲染时机

    • 约60fps(每16.6ms)
    • 受垂直同步信号影响
  3. 任务优先级

    用户交互 > 微任务 > 宏任务 > 空闲任务
    

2. 深度解析异步任务

2.1 宏任务(MacroTask)详解

  • 本质:由浏览器环境(而非JS引擎)管理的任务

  • 完整分类

    • DOM事件(click等)
    • 网络回调(XHR/Fetch)
    • IndexedDB操作
    • History API
    • setTimeout/setInterval
    • postMessage
    • MessageChannel
// 宏任务执行顺序测试
setTimeout(() => console.log('timeout1'), 0)
const channel = new MessageChannel()
channel.port1.postMessage(null)
channel.port2.onmessage = () => console.log('message channel')
setTimeout(() => console.log('timeout2'), 0)
// 输出顺序:message channel → timeout1 → timeout2

2.2 微任务(MicroTask)深度解析

  • 运行时机

    • 在每个宏任务之后
    • 在每次事件循环开始前
  • 特殊行为

    • 微任务中可以递归添加微任务
    • 微任务队列必须完全清空才会继续事件循环
// 微任务递归示例
function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('微任务执行')
    recursiveMicrotask()
  })
}
// 会导致页面卡死,因为微任务不断产生

2.3 requestAnimationFrame的特殊性

  • 执行时机:在样式计算和布局之后,绘制之前

  • 与事件循环的关系:

image.png

3. 浏览器渲染机制与事件循环

3.1 渲染管线关键阶段

  1. JavaScript:改变DOM/CSS
  2. 样式计算:计算最终CSS
  3. 布局:计算元素几何信息
  4. 绘制:生成绘制指令
  5. 合成:图层合并

3.2 事件循环与渲染的协同

function testRender() {
  box.style.width = '100px' // 触发重排
  requestAnimationFrame(() => {
    console.log('RAF:', box.offsetWidth) // 读取最新布局
  })
  box.style.height = '200px' // 再次重排
}
// 现代浏览器会合并样式修改

4. 实战中的性能优化

4.1 避免布局抖动

// 反模式:强制同步布局
function layoutThrashing() {
  for(let i = 0; i < 100; i++) {
    el.style.width = el.offsetWidth + 1 + 'px'
  }
}

// 优化方案:批量读取和修改
function optimizedLayout() {
  const width = el.offsetWidth
  for(let i = 0; i < 100; i++) {
    width += 1
  }
  el.style.width = width + 'px'
}

4.2 合理使用任务拆分

function processLargeTask() {
  const chunkSize = 1000
  let i = 0
  
  function processChunk() {
    const end = Math.min(i + chunkSize, data.length)
    for (; i < end; i++) {
      // 处理数据
    }
    if (i < data.length) {
      // 使用宏任务让出主线程
      setTimeout(processChunk, 0)
      // 或者使用更现代的API
      // scheduler.postTask(() => processChunk(), {priority: 'background'})
    }
  }
  
  processChunk()
}

5. Node.js与浏览器事件循环差异

特性浏览器Node.js
微任务执行时机每个宏任务之后每个事件循环阶段之间
setImmediate不存在专门阶段
优先级微任务 > 宏任务微任务 > setImmediate
渲染时机每帧检查不适用

6. 最新规范变化

  • isInputPending API:检测是否有挂起的用户输入
  • scheduler API:更细粒度的任务调度
  • 优先级API:可以指定任务优先级

总结与思考题

关键结论

  1. 微任务会在当前宏任务结束后立即执行
  2. 渲染发生在微任务执行后、下一个宏任务前
  3. RAF回调在渲染前执行
  4. 长时间运行的微任务会阻塞渲染

思考题

console.log('script start')

setTimeout(() => {
  console.log('timeout1')
  Promise.resolve().then(() => console.log('promise1'))
}, 0)

requestAnimationFrame(() => {
  console.log('rAF')
  Promise.resolve().then(() => console.log('promise2'))
})

Promise.resolve().then(() => {
  console.log('promise3')
  setTimeout(() => console.log('timeout2'), 0)
})

console.log('script end')

请分析输出顺序,并说明每一部分的执行时机,可以尝试判断输出结果


如果觉得本文有帮助,给个点赞收藏+关注  🚀