从单线程到事件循环:JS 异步编程的灵魂揭秘

146 阅读5分钟

一、JS 单线程:为什么 “一次只能做一件事”?

(一)单线程的设计初衷

JavaScript 的单线程模型源于浏览器环境的核心需求 —— 避免 DOM 操作的线程安全问题。试想,如果多个线程同时修改同一个 DOM 节点,浏览器该如何渲染?这种 “极简” 设计让 JS 在处理用户交互和 DOM 操作时无需考虑复杂的同步问题,但也带来了异步编程的挑战。

(二)单线程的 “双刃剑”

  • 优势:代码执行顺序清晰,无需处理锁竞争,新手也能快速上手。
  • 劣势:遇到耗时任务(如大数组排序)容易阻塞主线程,导致页面卡顿。这时候,事件循环机制就成了 “救星”。

二、事件循环:JS 的 “幕后调度大师”

(一)什么是事件循环?

简单来说,事件循环是 JS 引擎协调异步任务执行的 “循环系统”。它就像一个永不停止的管家,不断检查主线程是否空闲:如果同步任务执行完毕,就从 “任务队列” 中取出异步任务交给主线程处理,周而复始。

(二)任务队列的 “双重世界”

异步任务分为两类,分别住在不同的 “队列公寓” 里:

  • 宏任务(Macro Task) :每次事件循环的 “主角”,包括整体 script 代码、setTimeoutsetInterval、I/O 操作、UI 渲染等。可以理解为 “需要分阶段处理的大任务”。
  • 微任务(Micro Task) :优先级更高的 “紧急任务”,在当前宏任务执行完毕后立即执行,包括Promise.then()MutationObserverqueueMicrotask(浏览器)、process.nextTick(Node.js)等。就像主线程的 “待办便签”,必须在下次宏任务开始前处理完。

三、宏任务 VS 微任务:谁先执行?

(一)执行顺序的 “黄金法则”

  1. 同步任务优先:先执行完当前宏任务中的所有同步代码(包括函数调用栈中的内容)。
  2. 微任务插队:同步任务结束后,立即执行所有微任务,直到微任务队列为空。
  3. 宏任务接力:微任务清空后,再从宏任务队列中取下一个任务,重复上述过程。

(二)常见任务类型对照表

任务类型浏览器环境Node.js 环境特点
宏任务setTimeoutsetInterval、script、事件监听、fetchsetTimeoutsetIntervalsetImmediate每次事件循环执行一个,可触发 UI 渲染
微任务Promise.then()MutationObserverqueueMicrotaskPromise.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'); // 同步任务

执行顺序解析

  1. 同步任务执行:输出script start → script end
  2. 微任务队列执行:先promise1,再promise2(微任务按添加顺序执行)。
  3. 宏任务队列执行: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 异步编程的核心机制,理解它能让你:

  1. 精准预测代码执行顺序,避免 “玄学” 问题;

  2. 写出更高效的代码,避免主线程阻塞;

  3. 理解框架底层逻辑(如 Vue 的 nextTick、React 的任务调度)。

记住口诀:同步先走,微任务插队,宏任务接力,循环不止。下次遇到异步代码,不妨在心里画一个事件循环图,你会发现一切都有章可循~