为什么我们需要理解事件循环?
JavaScript 是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,我们在日常开发中却经常写出 setTimeout、Promise、requestAnimationFrame 等看似“并行”的代码。浏览器究竟是如何在单线程下协调这些任务的?答案就是事件循环(Event Loop)。
理解事件循环不仅是面试中的高频考点,更是写出高性能、可预测的前端代码的基础。本文将从浏览器进程模型出发,逐步拆解宏任务与微任务的执行机制,并通过代码示例帮助你彻底掌握这一核心概念。
浏览器中的进程与线程
在深入事件循环之前,有必要先了解浏览器是多进程架构。每一个标签页通常对应一个渲染进程,而渲染进程中又包含多个线程:
- 主线程(Main Thread):负责执行 JavaScript、解析 HTML/CSS、计算布局与绘制。
- 合成线程(Compositor Thread):负责将图层合成并交给 GPU 渲染。
- 定时器线程(Timer Thread):负责计时,时间到了将回调推入任务队列。
- 网络线程(Network Thread):负责处理网络请求。
事件循环就运行在主线程中。它的核心职责是:不断地从任务队列中取出任务并执行,同时在适当的时候进行渲染更新。
事件循环的基本模型
事件循环可以简化为一个无限循环,每一轮循环(tick)大致包含以下步骤:
- 从宏任务队列中取出一个任务并执行。
- 执行完毕后,清空微任务队列中的所有任务。
- 判断是否需要进行渲染(如页面有变化),如果需要则执行渲染流程。
- 进入下一轮循环。
这里有两个关键队列:宏任务队列和微任务队列。它们的执行优先级和时机不同,正是理解事件循环的关键所在。
宏任务(MacroTask)
宏任务是由宿主环境(浏览器)提供的任务,常见的宏任务包括:
script(整体代码)setTimeout/setIntervalsetImmediate(Node.js 环境)I/O操作UI renderingMessageChannelpostMessage
每次事件循环的第一件事就是执行一个宏任务。注意,页面首次加载时,整个 <script> 代码本身就是一个宏任务。
微任务(MicroTask)
微任务是由 JavaScript 引擎自身发起的任务,常见的微任务包括:
Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(Node.js 环境,优先级高于 Promise)
微任务的执行时机是:在当前宏任务执行结束后、下一个宏任务开始之前,清空整个微任务队列。 如果在微任务执行过程中又产生了新的微任务,这些新微任务也会在本次循环中被一并执行,直到微任务队列为空。
一个经典的代码示例
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('promise1');
})
.then(() => {
console.log('promise2');
});
console.log('script end');
输出结果:
script start
script end
promise1
promise2
setTimeout
执行过程分析:
- 整体
script作为第一个宏任务开始执行,输出script start。 - 遇到
setTimeout,将其回调交给定时器线程,时间到后推入宏任务队列。 - 遇到
Promise.resolve().then,将回调推入微任务队列。 - 输出
script end,宏任务执行完毕。 - 清空微任务队列:依次输出
promise1、promise2。 - 微任务清空后,进行渲染(如果有需要)。
- 下一轮事件循环,取出宏任务队列中的
setTimeout回调并执行,输出setTimeout。
微任务与渲染的关系
微任务的清空发生在渲染之前。这意味着如果你在微任务中不断产生新的微任务,页面将永远没有机会渲染,造成界面卡死。例如:
function loop() {
Promise.resolve().then(loop);
}
loop();
这段代码会无限循环执行微任务,浏览器无法进行渲染,页面会完全冻结。相比之下,使用 setTimeout 递归则不会阻塞渲染,因为每次宏任务之间都会给渲染留出机会。
宏任务与微任务的常见误区
误区一:setTimeout(fn, 0) 会立即执行。
实际上,setTimeout 的最小延迟通常为 4ms(HTML 规范规定),而且它必须等待当前宏任务和所有微任务执行完毕,还要等待渲染完成后才会执行。因此 setTimeout(fn, 0) 并不代表立即执行。
误区二:微任务比宏任务优先级高。
更准确的说法是:微任务在每一个宏任务结束后立即执行,而宏任务之间需要等待下一轮事件循环。 它们不在同一个层级上比较优先级,而是执行时机不同。
误区三:Promise 的回调是异步的,所以和 setTimeout 一样。
Promise.then 的回调是微任务,而 setTimeout 的回调是宏任务。在同一个宏任务中产生的微任务,会在该宏任务结束后立刻执行,远早于下一个宏任务。
实际开发中的建议
- 避免在微任务中无限递归,否则会导致页面无法渲染。
- 需要尽快执行但又不想阻塞渲染时,可以使用
requestAnimationFrame或setTimeout。 - 需要批量更新 DOM 时,可以利用微任务在渲染前统一处理,例如 Vue 的
nextTick就是基于微任务实现的。 - 理解
async/await的本质:await后面的代码相当于放在Promise.then中执行,属于微任务。
总结
浏览器事件循环是 JavaScript 异步编程的基石。它的核心规则可以概括为:
- 每轮事件循环执行一个宏任务。
- 宏任务结束后,清空所有微任务。
- 微任务清空后,进行渲染。
- 然后进入下一轮循环。
掌握宏任务与微任务的执行顺序,不仅能帮助你准确预测代码的输出结果,还能让你在性能优化和架构设计中做出更明智的决策。希望本文能为你深入理解浏览器运行机制提供清晰的指引。
未经允许不得转载:任鹏个人博客 » 深入理解浏览器事件循环:从宏任务到微任务


朋友圈点赞图在线生成源码