一、JS 单线程:为什么 “一次只能做一件事”?
(一)单线程的设计初衷
JavaScript 的单线程模型源于浏览器环境的核心需求 —— 避免 DOM 操作的线程安全问题。试想,如果多个线程同时修改同一个 DOM 节点,浏览器该如何渲染?这种 “极简” 设计让 JS 在处理用户交互和 DOM 操作时无需考虑复杂的同步问题,但也带来了异步编程的挑战。
(二)单线程的 “双刃剑”
- 优势:代码执行顺序清晰,无需处理锁竞争,新手也能快速上手。
- 劣势:遇到耗时任务(如大数组排序)容易阻塞主线程,导致页面卡顿。这时候,事件循环机制就成了 “救星”。
二、事件循环:JS 的 “幕后调度大师”
(一)什么是事件循环?
简单来说,事件循环是 JS 引擎协调异步任务执行的 “循环系统”。它就像一个永不停止的管家,不断检查主线程是否空闲:如果同步任务执行完毕,就从 “任务队列” 中取出异步任务交给主线程处理,周而复始。
(二)任务队列的 “双重世界”
异步任务分为两类,分别住在不同的 “队列公寓” 里:
- 宏任务(Macro Task) :每次事件循环的 “主角”,包括整体 script 代码、
setTimeout、setInterval、I/O 操作、UI 渲染等。可以理解为 “需要分阶段处理的大任务”。 - 微任务(Micro Task) :优先级更高的 “紧急任务”,在当前宏任务执行完毕后立即执行,包括
Promise.then()、MutationObserver、queueMicrotask(浏览器)、process.nextTick(Node.js)等。就像主线程的 “待办便签”,必须在下次宏任务开始前处理完。
三、宏任务 VS 微任务:谁先执行?
(一)执行顺序的 “黄金法则”
- 同步任务优先:先执行完当前宏任务中的所有同步代码(包括函数调用栈中的内容)。
- 微任务插队:同步任务结束后,立即执行所有微任务,直到微任务队列为空。
- 宏任务接力:微任务清空后,再从宏任务队列中取下一个任务,重复上述过程。
(二)常见任务类型对照表
| 任务类型 | 浏览器环境 | Node.js 环境 | 特点 |
|---|---|---|---|
| 宏任务 | setTimeout、setInterval、script、事件监听、fetch | setTimeout、setInterval、setImmediate | 每次事件循环执行一个,可触发 UI 渲染 |
| 微任务 | Promise.then()、MutationObserver、queueMicrotask | Promise.then()、process.nextTick | 优先级高于宏任务,在当前宏任务结束后立即执行 |
(三)经典示例:代码执行顺序解析
console.log('script start'); // 同步任务
// 宏任务:setTimeout
setTimeout(() => {
console.log('setTimeout'); // 宏任务回调
Promise.resolve().then(() => console.log('setTimeout内的微任务')); // 微任务(属于当前宏任务的子任务)
}, 0);
// 微任务:Promise.then()
Promise.resolve().then(() => {
console.log('promise1');
Promise.resolve().then(() => console.log('promise2')); // 链式微任务
});
console.log('script end'); // 同步任务
执行顺序解析:
- 同步任务执行:输出
script start→script end。 - 微任务队列执行:先
promise1,再promise2(微任务按添加顺序执行)。 - 宏任务队列执行:
setTimeout回调执行,输出setTimeout,其内部的微任务再次进入微任务队列,立即执行setTimeout内的微任务。
四、实战场景:用事件循环优化代码
(一)避免主线程阻塞:分块处理大数据
// 错误示范:直接处理100万条数据(阻塞主线程)
function processData(data) {
data.forEach(item => { /* 复杂计算 */ });
}
// 正确示范:用setTimeout分块处理(释放事件循环)
function processDataInChunks(data, chunkSize = 1000) {
while (data.length > 0) {
// 每次处理1000条,剩余数据放入下一个宏任务
setTimeout(() => {
const chunk = data.splice(0, chunkSize);
chunk.forEach(item => { /* 处理数据 */ });
}, 0);
}
}
(二)DOM 操作优化:利用微任务批量获取布局信息
console.log('同步代码开始');
const div = document.createElement('div');
document.body.appendChild(div);
// 直接获取布局信息会触发强制重排(耗性能)
// console.log(div.offsetHeight);
// 正确做法:用queueMicrotask在DOM更新后获取(微任务在渲染前执行)
queueMicrotask(() => {
console.log('微任务中获取布局:', div.offsetHeight); // 此时DOM已更新,但尚未渲染
});
div.style.height = '100px'; // 同步设置样式(放入微任务前)
console.log('同步代码结束');
(三)Node.js 特殊场景:process.nextTick 的 “极速” 特性
在 Node 环境中,process.nextTick的回调会在所有微任务之前执行,是 Node 特有的 “超优先级” 任务:
// Node.js代码
console.log('同步开始');
Promise.resolve().then(() => console.log('Promise微任务'));
process.nextTick(() => console.log('nextTick任务'));
console.log('同步结束');
// 输出顺序:同步开始 → 同步结束 → nextTick任务 → Promise微任务
五、常见误区与避坑指南
(一)“setTimeout (0) 就是立即执行”?错!
setTimeout的最小延迟并非 0ms(浏览器通常有 4ms 的最小延迟),且它属于宏任务,必须等待当前同步任务和微任务执行完毕才会触发。实际延迟 = 设定时间 + 前序任务执行时间。
(二)“微任务一定比宏任务快”?不完全!
微任务的优先级高,但如果在微任务中执行大量计算,反而会阻塞后续宏任务(如 UI 渲染)。建议将耗时操作放入宏任务或 Web Worker。
(三)“MutationObserver 能实时监听 DOM 变化”?时机很重要!
MutationObserver的回调是微任务,会在 DOM 变化但尚未渲染时触发,适合批量处理 DOM 更新后的逻辑(如数据同步),但无法实时获取渲染后的样式(需等下一次事件循环)。
六、总结:掌握事件循环,驾驭异步编程
事件循环是 JS 异步编程的核心机制,理解它能让你:
-
精准预测代码执行顺序,避免 “玄学” 问题;
-
写出更高效的代码,避免主线程阻塞;
-
理解框架底层逻辑(如 Vue 的 nextTick、React 的任务调度)。
记住口诀:同步先走,微任务插队,宏任务接力,循环不止。下次遇到异步代码,不妨在心里画一个事件循环图,你会发现一切都有章可循~