你有没有遇到过这种情况:明明写了setTimeout(fn, 0),却发现函数没有立刻执行;或者Promise.then里的代码,总是比setTimeout先跑?这背后都是 JS 的 “事件循环” 在捣鬼。
事件循环是 JS 的执行引擎,决定了代码的运行顺序。它不像表面看起来那么简单 —— 单线程的 JS 如何同时处理点击事件、网络请求和定时器?为什么有的异步代码先执行,有的后执行?今天用最直白的例子,讲透这个让无数开发者头疼的执行机制。
为什么需要事件循环?JS 单线程的 “无奈之举”
JavaScript 是单线程的 —— 同一时间只能执行一段代码。这不是设计缺陷,而是由 JS 的主要用途决定的:处理 DOM 交互。如果 JS 是多线程,同时有两个线程修改 DOM,会导致渲染冲突(比如一个线程删除节点,另一个线程修改该节点内容)。
单线程带来了一个问题:如果有耗时操作(比如网络请求、定时器),会阻塞后续代码执行,导致页面卡死。例如:
// 耗时操作会阻塞后续代码
function blocking() {
let start = Date.now();
while (Date.now() - start < 3000) {
// 模拟3秒耗时计算
}
}
console.log('开始');
blocking(); // 阻塞3秒
console.log('结束'); // 3秒后才执行
为了解决这个问题,JS 将任务分为 “同步任务” 和 “异步任务”,并通过事件循环机制协调执行,既保证单线程的安全性,又能处理异步操作。
任务分类:同步、异步(微任务与宏任务)
事件循环的核心是 “区分任务类型,按优先级执行”。JS 中的任务分为三类:
(1)同步任务
指无需等待,立即执行的任务。比如:
- console.log () 等直接执行的代码;
- 变量声明、函数定义;
- 同步的条件判断、循环等。
同步任务会直接进入 “JS 调用栈”,按顺序执行,前一个任务完成后才执行下一个。
(2)异步任务:微任务(Microtasks)
指需要异步执行,但优先级高于宏任务的任务。常见类型:
- Promise.then ()、Promise.catch ()、Promise.finally ()(注意:Promise 构造函数内的代码是同步的);
- MutationObserver(监听 DOM 变化的 API);
- queueMicrotask ()(专门创建微任务的 API);
- Node 环境中的 process.nextTick(优先级高于其他微任务)。
微任务的特点:同步任务执行完后,会立即清空所有微任务队列,再执行宏任务。
(3)异步任务:宏任务(Macrotasks)
指需要异步执行,优先级低于微任务的任务。常见类型:
- setTimeout/setInterval(定时器);
- setImmediate(Node 环境,浏览器不支持);
- DOM 事件(如 click、scroll 的回调);
- 网络请求(fetch、XMLHttpRequest 的回调);
- script 脚本(整个 script 标签的代码是第一个宏任务)。
宏任务的特点:微任务队列清空后,才会从宏任务队列中取一个任务执行,执行完后再重复检查微任务队列。
事件循环执行流程:按步骤走,永远不会乱
事件循环的执行流程可以总结为 “同步任务先执行→清空微任务→执行一个宏任务→重复检查”,具体步骤如下:
- 执行同步任务:从 JS 调用栈中取出同步任务,依次执行,直到调用栈清空。
- 清空微任务队列:检查微任务队列,按顺序执行所有微任务(执行过程中新增的微任务也会在本轮一并执行),直到微任务队列为空。
- 执行一个宏任务:从宏任务队列中取出第一个任务执行(只执行一个)。
- 重复步骤 2 和 3:执行完一个宏任务后,再次清空微任务队列,然后取下一个宏任务,循环往复。
console.log('同步Start');
// 宏任务:setTimeout
setTimeout(() => {
console.log('宏任务:setTimeout');
}, 0);
// 微任务:Promise.then
Promise.resolve().then(() => {
console.log('微任务:Promise.then');
});
console.log('同步End');
微任务详解:优先级高于宏任务的 “紧急任务”
微任务的设计目的是 “处理同步任务执行完后需要立即完成的操作”(如 Promise 的回调、DOM 更新后的批量处理)。常见微任务的用法和特点如下:
(1)Promise.then:最常用的微任务
注意:Promise构造函数内的代码是同步任务,只有then/catch/finally才是微任务。
console.log('同步Start');
const p = new Promise((resolve) => {
console.log('Promise构造函数(同步)'); // 同步执行
resolve();
});
p.then(() => {
console.log('Promise.then(微任务)'); // 微任务
});
console.log('同步End');
(2)MutationObserver:监听 DOM 变化的微任务
MutationObserver用于监听 DOM 节点的变化(如属性修改、子元素增减),其回调是微任务,会在 DOM 更新后、页面渲染前执行(适合批量处理 DOM 变化)。
// 创建一个DOM节点
const target = document.createElement('div');
document.body.appendChild(target);
// 实例化MutationObserver,回调是微任务
const observer = new MutationObserver(() => {
console.log('微任务:MutationObserver(DOM变化后执行)');
});
// 监听target的属性和子元素变化
observer.observe(target, { attributes: true, childList: true });
// 修改DOM(同步操作)
target.setAttribute('data-id', '1'); // 触发监听
target.appendChild(document.createElement('span')); // 再次触发监听
console.log('同步任务执行完');
多次 DOM 修改是同步任务,执行完后,
MutationObserver的回调作为微任务一次性执行(而非每次修改都触发),减少渲染次数,优化性能。
(3)queueMicrotask:手动创建微任务
queueMicrotask是专门用于创建微任务的 API,作用与Promise.then类似,适合批量处理需要在渲染前完成的操作(如 DOM 计算)。
console.log('同步Start');
queueMicrotask(() => {
console.log('微任务:queueMicrotask 1');
});
queueMicrotask(() => {
console.log('微任务:queueMicrotask 2');
});
console.log('同步End');
宏任务详解:优先级低于微任务的 “延迟任务”
宏任务的执行时机晚于微任务,适合处理 “不需要立即执行的耗时操作”。常见宏任务的特点如下:
(1)setTimeout/setInterval:定时器的 “延迟” 不是精确的
setTimeout的延迟时间(如setTimeout(fn, 0))表示 “至少等待多少毫秒后加入宏任务队列”,而非 “精确延迟多少毫秒执行”—— 因为它需要等待同步任务和微任务执行完。
console.log('同步Start');
// 延迟0毫秒的setTimeout
setTimeout(() => {
console.log('宏任务:setTimeout(延迟0ms)');
}, 0);
// 同步阻塞100ms
const start = Date.now();
while (Date.now() - start < 100) {} // 阻塞100ms
console.log('同步End');
setTimeout的回调需要等待 100ms 的同步阻塞和同步任务执行完,才会进入宏任务队列,因此实际延迟大于 100ms。
(2)script 脚本:第一个宏任务
整个<script>标签中的代码是 “第一个宏任务”—— 事件循环从执行 script 脚本开始,脚本内的同步任务、微任务、宏任务按规则执行。
// 这是整个script脚本(第一个宏任务)
console.log('script开始(宏任务)');
// 微任务
Promise.resolve().then(() => {
console.log('微任务:script内的then');
});
// 宏任务
setTimeout(() => {
console.log('宏任务:setTimeout(script外)');
}, 0);
console.log('script结束(宏任务)');
执行流程:
- 执行 script 宏任务中的同步代码:输出
script开始和script结束。 - 清空微任务队列:执行
Promise.then,输出微任务:script内的then。 - 执行下一个宏任务:
setTimeout的回调,输出宏任务:setTimeout。
输出顺序符合事件循环流程,验证了 “script 是第一个宏任务” 的规则。
浏览器与 Node 环境的事件循环差异
事件循环在浏览器和 Node 环境的核心逻辑一致(微任务优先于宏任务),但在宏任务类型和执行细节上有差异
(1)宏任务类型差异
- 浏览器环境宏任务:
setTimeout、setInterval、DOM 事件、requestAnimationFrame等。 - Node 环境宏任务:
setTimeout、setInterval、setImmediate、I/O操作等(setImmediate是 Node 特有的,优先级低于setTimeout)。
(2)Node 环境特有的微任务:process.nextTick
Node 环境中,process.nextTick是 “优先级最高的微任务”—— 它会在所有其他微任务(如Promise.then)之前执行。
// Node环境代码
console.log('同步Start');
// 普通微任务
Promise.resolve().then(() => {
console.log('微任务:Promise.then');
});
// Node特有的微任务
process.nextTick(() => {
console.log('微任务:process.nextTick');
});
console.log('同步End');
(3)复杂场景:嵌套任务的执行顺序
在嵌套场景中,浏览器和 Node 的执行顺序一致 ——每次执行完一个宏任务,必须先清空所有微任务。
console.log('同步Start');
// 第一个宏任务
setTimeout(() => {
console.log('宏任务1:setTimeout外层');
// 宏任务1内的微任务
Promise.resolve().then(() => {
console.log('宏任务1内的微任务');
});
// 宏任务1内的宏任务
setTimeout(() => {
console.log('宏任务2:setTimeout内层');
}, 0);
}, 0);
console.log('同步End');
总结
-
单线程与事件循环:JS 单线程导致需要区分同步 / 异步任务,事件循环是协调执行的机制。
-
任务分类:
- 同步任务:立即执行,进入调用栈。
- 微任务:
Promise.then、MutationObserver、queueMicrotask,优先级高于宏任务。 - 宏任务:
setTimeout、script、DOM 事件,微任务清空后才执行。
-
执行流程:同步任务→清空微任务→执行一个宏任务→重复。
-
环境差异:Node 有
process.nextTick(微任务优先级最高)、setImmediate(宏任务)。 -
常见问题:
setTimeout延迟不精确:因为需要等待同步任务和微任务。- 微任务优先的原因:处理同步任务的后续操作(如 Promise 回调),避免多次渲染。