浏览器的事件循环机制
浏览器的每个页面都有一个单线程的 JavaScript 引擎(可参考我的另一篇文章:浏览器是单线程的?),这意味着它只有一个主线程来处理所有任务。这个主线程身兼数职,既要渲染页面、执行 JavaScript 代码,又要处理用户交互。
如果一个任务需要很长时间才能完成,比如网络请求、文件读写或者数据库查询,它就会阻塞主线程,导致程序在这段时间内没有任何响应,用户界面也会卡住。
事件循环机制就是为了解决这个问题而设计的。它将耗时的 I/O 操作变成了异步的,避免了因等待耗时操作而导致的“卡顿”和无响应,从而大大提高了程序的性能和用户体验。
事件循环的核心组成
事件循环主要由三个核心部分构成:
1. 调用栈(Call Stack)
这是一个后进先出的数据结构,用来追踪正在执行的函数。每当一个函数被调用时,它就会被推入栈中;当函数执行完毕,它就会被弹出。
2. Web APIs
浏览器提供的一系列非主线程的异步功能,比如:
setTimeout、setIntervalAJAX请求DOM事件监听requestAnimationFrame
当主线程遇到这些异步任务时,它会将它们交给 Web APIs 处理,然后立即继续执行栈中的下一个任务,而不会等待异步任务完成。
3. 任务队列(Task Queue)
当 Web APIs 中的异步任务执行完成后,比如一个定时器时间到了,或者一个网络请求返回了数据,它会把对应的回调函数放入任务队列中等待。
任务队列的分类
宏任务队列
代表一个相对完整的、独立的代码块
包括:
script(整体代码块)setTimeout、setIntervalI/O操作(如文件读写、网络请求)UI渲染、DOM事件requestAnimationFrame(浏览器环境)MessageChannel(React实现时间切片)
微任务队列
代表优先级更高任务
包括:
Promise.then()、.catch()、.finally()MutationObserverqueueMicrotask()
事件循环的执行规则
核心规则:执行完一个宏任务后,立即清空所有微任务,然后再进行 UI 渲染(如有必要),最后再开启下一个宏任务。
详细执行流程:
- 首先,整个
<script>标签里的代码作为一个宏任务开始执行 - 在执行过程中,同步代码直接在调用栈上运行
- 如果遇到一个宏任务(如
setTimeout),就把它交给对应的Web API,并在指定时间后将其回调函数放入宏任务队列 - 如果遇到一个微任务(如
Promise.then),就将其回调函数放入微任务队列 - 当前这个宏任务(即主脚本)执行完毕,调用栈变空
- 关键时刻来了!事件循环会立即检查微任务队列,将里面所有的微任务依次取出并执行,直到微任务队列被清空
- 微任务清空后,浏览器会进行一次
UI渲染(如果页面有变化的话) - 渲染完成后,会继续执行下一个宏任务
- 重复以上步骤,直到任务队列中没有宏任务为止
简单来说,宏任务是“大块”的任务,每次循环只执行一个;而微任务是“小而急”的任务,会在当前宏任务执行完后被立即“插队”全部执行完,然后再开始下一个宏任务。
实际应用示例
1 | // 示例:事件循环执行顺序 |
这个示例展示了事件循环的执行顺序:
- 先执行同步代码(1, 4)
- 然后执行所有微任务(3)
- 最后执行宏任务(2)
复杂示例
1 | console.log('start'); |
扩展
React 时间切片为什么用宏任务实现而不用微任务实现
首先时间切片的目标不是尽快完成任务,而是在不阻塞主线程的前提下,将一个大的计算任务分解成小的、可中断的单元。
如果 React 使用微任务来实现时间切片,它会把所有待处理的更新任务都放入微任务队列。由于微任务会在当前宏任务执行完后全部执行,React 就会一次性霸占主线程,直到所有更新计算完毕。这和“时间切片”的初衷——让出主线程——完全背道而驰。UI 依然会因为长时间的计算而卡死,用户无法进行任何交互。
使用宏任务,React 可以在执行完一小部分更新后,主动暂停,将剩余的工作放入一个新的宏任务(通过 MessageChannel 或 setTimeout)。这样,事件循环就可以在两次宏任务之间运行,浏览器就能在中间插入执行 UI 渲染任务和响应用户的点击、键盘输入。这种“暂停-恢复”的机制正是时间切片的核心所在。