为什么 setTimeout 总是 “不准时”?揭秘 JS 事件循环的执行逻辑

156 阅读8分钟

你有没有遇到过这种情况:明明写了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 标签的代码是第一个宏任务)。

宏任务的特点:微任务队列清空后,才会从宏任务队列中取一个任务执行,执行完后再重复检查微任务队列。

事件循环执行流程:按步骤走,永远不会乱

事件循环的执行流程可以总结为 “同步任务先执行→清空微任务→执行一个宏任务→重复检查”,具体步骤如下:

  1. 执行同步任务:从 JS 调用栈中取出同步任务,依次执行,直到调用栈清空。
  2. 清空微任务队列:检查微任务队列,按顺序执行所有微任务(执行过程中新增的微任务也会在本轮一并执行),直到微任务队列为空。
  3. 执行一个宏任务:从宏任务队列中取出第一个任务执行(只执行一个)。
  4. 重复步骤 2 和 3:执行完一个宏任务后,再次清空微任务队列,然后取下一个宏任务,循环往复。
console.log('同步Start');

// 宏任务:setTimeout
setTimeout(() => {
  console.log('宏任务:setTimeout');
}, 0);

// 微任务:Promise.then
Promise.resolve().then(() => {
  console.log('微任务:Promise.then');
});

console.log('同步End');

image.png

微任务详解:优先级高于宏任务的 “紧急任务”

微任务的设计目的是 “处理同步任务执行完后需要立即完成的操作”(如 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');

image.png

(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('同步任务执行完');

image.png

image.png 多次 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');

image.png

宏任务详解:优先级低于微任务的 “延迟任务”

宏任务的执行时机晚于微任务,适合处理 “不需要立即执行的耗时操作”。常见宏任务的特点如下:

(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');

image.png 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结束(宏任务)');

执行流程

  1. 执行 script 宏任务中的同步代码:输出script开始script结束
  2. 清空微任务队列:执行Promise.then,输出微任务:script内的then
  3. 执行下一个宏任务:setTimeout的回调,输出宏任务:setTimeout

输出顺序符合事件循环流程,验证了 “script 是第一个宏任务” 的规则。

浏览器与 Node 环境的事件循环差异

事件循环在浏览器和 Node 环境的核心逻辑一致(微任务优先于宏任务),但在宏任务类型和执行细节上有差异

(1)宏任务类型差异

  • 浏览器环境宏任务:setTimeoutsetInterval、DOM 事件、requestAnimationFrame等。
  • Node 环境宏任务:setTimeoutsetIntervalsetImmediateI/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');

屏幕截图 2025-07-11 184630.png

(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');

image.png

总结

  1. 单线程与事件循环:JS 单线程导致需要区分同步 / 异步任务,事件循环是协调执行的机制。

  2. 任务分类

    • 同步任务:立即执行,进入调用栈。
    • 微任务:Promise.thenMutationObserverqueueMicrotask,优先级高于宏任务。
    • 宏任务:setTimeoutscript、DOM 事件,微任务清空后才执行。
  3. 执行流程:同步任务→清空微任务→执行一个宏任务→重复。

  4. 环境差异:Node 有process.nextTick(微任务优先级最高)、setImmediate(宏任务)。

  5. 常见问题

    • setTimeout延迟不精确:因为需要等待同步任务和微任务。
    • 微任务优先的原因:处理同步任务的后续操作(如 Promise 回调),避免多次渲染。