概述
要真正理解事件循环,我们需要先了解浏览器的多进程架构:
-
浏览器主进程:负责界面显示、用户交互
-
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 完整事件循环流程
关键阶段说明:
-
微任务检查点:
- 在每个宏任务结束后
- 在每次事件循环迭代开始时
-
渲染时机:
- 约60fps(每16.6ms)
- 受垂直同步信号影响
-
任务优先级:
用户交互 > 微任务 > 宏任务 > 空闲任务
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的特殊性
-
执行时机:在样式计算和布局之后,绘制之前
-
与事件循环的关系:
3. 浏览器渲染机制与事件循环
3.1 渲染管线关键阶段
- JavaScript:改变DOM/CSS
- 样式计算:计算最终CSS
- 布局:计算元素几何信息
- 绘制:生成绘制指令
- 合成:图层合并
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:可以指定任务优先级
总结与思考题
关键结论:
- 微任务会在当前宏任务结束后立即执行
- 渲染发生在微任务执行后、下一个宏任务前
- RAF回调在渲染前执行
- 长时间运行的微任务会阻塞渲染
思考题:
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')
请分析输出顺序,并说明每一部分的执行时机,可以尝试判断输出结果
如果觉得本文有帮助,给个点赞收藏+关注 🚀