js事件循环学习总结

May 1, 2025

在 JavaScript 等单线程编程语言中,事件循环(Event Loop)是实现异步编程的核心机制,它解决了单线程环境下“如何高效处理并发任务”的关键问题。无论是浏览器端的页面交互,还是 Node.js 环境的服务端处理,事件循环都在幕后调控着代码的执行节奏。本文将从概念定义、执行顺序分析、实战应用到深层原理,全面拆解事件循环的核心逻辑,并结合队列优先级算法展开延伸探讨。

事件循环的概念

要理解事件循环,首先需明确单线程模型的“双刃剑”特性:单线程意味着代码按顺序依次执行,避免了多线程的资源竞争与死锁问题,但也会因长时间同步任务阻塞线程,导致页面卡顿或服务无响应。事件循环正是为解决这一矛盾而生的“任务调度机制”。

事件循环的核心逻辑可概括为“三部分架构+循环调度”:

  1. 调用栈(Call Stack):用于存放当前正在执行的同步任务,遵循“后进先出”(LIFO)原则。当执行同步代码时,函数会被压入栈中,执行完毕后弹出栈;若遇到异步任务,会将其“注册”到对应队列后继续执行后续同步任务。

  2. 任务队列(Task Queue):用于存放异步任务完成后产生的“回调函数”,分为微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue),部分环境会进一步细分出动画队列、输入队列等专用队列。异步任务(如setTimeoutPromise)触发后,并不会立即执行回调,而是等待任务完成后将回调推入对应队列。

  3. 事件循环机制:不断重复“检查调用栈是否为空 → 若为空则处理任务队列 → 将队列中任务的回调压入调用栈执行”的循环流程。只有当调用栈完全清空时,才会从任务队列中提取任务,这是保证同步任务执行优先级的核心规则。

需要特别区分的是“异步任务的注册与执行”:异步任务的“触发”(如发起网络请求、设置定时器)是由浏览器或 Node.js 的底层线程(如网络线程、定时器线程)处理的,事件循环仅负责在合适的时机将回调函数推入调用栈执行。例如setTimeout(callback, 1000),并非 1 秒后直接执行callback,而是 1 秒后将callback推入宏任务队列,等待调用栈清空后才会执行。

如何通过事件循环分析代码执行顺序

分析事件循环下的代码执行顺序,需遵循“同步优先 → 微任务全清 → 宏任务逐个清”的核心原则,并结合任务队列的优先级规则。以下通过“基础案例 → 复杂案例”的递进方式,拆解分析步骤。

核心分析原则

  1. 同步代码优先执行:调用栈优先处理所有同步代码,遇到异步任务时仅注册回调,不阻塞执行。

  2. 微任务先于宏任务执行:当调用栈为空时,首先清空所有微任务队列中的任务(依次执行回调并压入调用栈),微任务执行过程中产生的新微任务会追加到队列尾部并一同执行。

  3. 宏任务逐个执行:微任务队列清空后,从宏任务队列中提取“一个”任务执行(回调压入调用栈),执行完毕后再次检查并清空微任务队列,如此循环。

  4. 特殊队列优先级:除基础的微任务、宏任务队列外,浏览器环境还存在动画队列(requestAnimationFrame)、用户输入队列等,其优先级需参考具体环境的调度规则(后续结合给定算法展开)。


console.log("1: 同步");
setTimeout(() => {
  console.log("2: 宏任务1");
  Promise.resolve().then(() => {
    console.log("3: 宏任务1内的微任务");
  });
}, 0);
Promise.resolve().then(() => {
  console.log("4: 微任务1");
  setTimeout(() => {
    console.log("5: 微任务1内的宏任务");
  }, 0);
});
console.log("6: 同步");

最终输出顺序:1 → 6 → 4 → 2 → 3 → 5。核心关键点:微任务执行过程中产生的宏任务会追加到宏任务队列,需等待下一轮事件循环;宏任务执行过程中产生的微任务会在当前宏任务执行完毕后立即执行(无需等待下一轮)。

异步代码顺序原理的实际应用(排查 bug 的例子)

事件循环的执行顺序规则,是排查“异步代码执行时机异常”类 bug 的核心依据。以下结合两个典型实战场景,说明如何利用事件循环原理定位问题。

案例 1:DOM 操作与异步回调的执行时机问题

问题现象

通过setTimeout修改 DOM 后,立即通过offsetHeight获取元素高度,结果始终为修改前的旧值。

const box = document.getElementById("box");
box.style.height = "200px"; // 同步修改DOM

setTimeout(() => {
  box.style.height = "400px";
  console.log("修改后高度:", box.offsetHeight); // 期望输出400,实际输出200?
}, 0);

console.log("同步获取高度:", box.offsetHeight); // 输出200
问题定位(基于事件循环)
  1. 同步代码中修改box.style.height = '200px'后,浏览器并不会立即重绘 DOM,而是将重绘任务加入“重绘队列”,待调用栈清空后执行。因此同步获取offsetHeight时,重绘未执行,输出旧值 200。

  2. setTimeout回调执行时,修改height为 400px,但此时重绘任务仍未执行(需等待当前调用栈清空),因此立即获取offsetHeight仍为旧值。

  3. 如果是微任务等待重绘执行就没有问题,微任务执行时机在“调用栈清空后、宏任务执行前”,且会触发浏览器的重绘检查。

案例 2:用户输入与异步任务的优先级冲突

问题现象

页面中存在一个按钮点击事件和一个长时间运行的宏任务,点击按钮后,响应严重延迟。

// 长时间运行的宏任务
setTimeout(() => {
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  console.log("宏任务执行完毕");
}, 0);

// 按钮点击事件(用户输入任务)
document.getElementById("btn").addEventListener("click", () => {
  console.log("按钮被点击");
});
问题定位(基于事件循环)
  1. setTimeout回调作为宏任务被推入队列后,因循环计算耗时过长,导致调用栈长时间被占用。

  2. 用户点击按钮时,点击事件的回调被推入“用户输入队列”,其优先级高于普通宏任务,但由于当前宏任务未执行完毕(调用栈非空),事件循环无法调度输入队列的任务,导致响应延迟。

解决方案
  1. 拆分长时间任务:将循环计算拆分为多个微任务,利用微任务执行间隙释放调用栈,让高优先级任务得以执行。

    function calculate(sum, start, end, step) {
      for (let i = start; i < end; i++) {
        sum += i;
      }
      // 未完成则继续拆分,通过微任务调度
      if (end < 1000000000) {
        Promise.resolve().then(() => {
          calculate(sum, end, Math.min(end + step, 1000000000), step);
        });
      } else {
        console.log("计算完毕", sum);
      }
    }
    
    setTimeout(() => {
      calculate(0, 0, 10000000, 10000000); // 每次计算1000万次,拆分执行
    }, 0);
    
  2. 利用requestIdleCallback:将非紧急任务放入空闲回调,仅在浏览器空闲时执行,不影响高优先级任务(如用户输入、动画)。

事件循环的深层原理

事件循环的表层逻辑是“任务队列调度”,而深层原理涉及“任务优先级排序”“浏览器/Node.js 环境差异”“线程模型协同”三个核心维度,结合以下的队列优先级算法,我们可以来进一步拆解其底层调度逻辑。

// 队列优先级排序算法
const QUEUE_PRIORITY = [
  "microtask", // Proise/MutationObserver
  "animation", // requestAnimationFrame
  "user input", // 点击/滚动事件
  "macro task", // setTimeout/setInterval
  "network", // fetch响应
  "idle", // requestIdleCallback
];

function getTaskPriority(task) {
  return QUEUE_PRIORITY.indexOf(task.type);
}

在 Node.js 中,process.nextTick的优先级高于Promise.then,这是与浏览器环境的关键差异点,需特别注意。

事件循环的高效运行依赖于“单线程+多辅助线程”的协同模型,以浏览器为例,其线程模型包括:

  1. 主线程:执行 JavaScript 代码、DOM 操作、事件循环调度,是核心线程。

  2. 辅助线程:包括网络线程(处理 fetch、XHR 请求)、定时器线程(处理 setTimeout、setInterval)、渲染线程(处理 DOM 重绘、重排)、工作线程(Web Worker,处理耗时计算)等。

协同流程举例(以fetch请求为例):

  1. 主线程执行fetch(url).then(callback),调用浏览器网络线程发起请求,同时将callback注册到微任务队列。

  2. 主线程继续执行其他同步代码,网络线程在后台等待响应。

  3. 网络线程收到响应后,将callback关联的任务标记为“可执行”,并根据任务类型(network)放入对应队列。

  4. 主线程调用栈清空后,事件循环根据优先级算法,先清空微任务队列,再执行 network 队列的任务,将callback压入调用栈执行。

这种“主线程调度+辅助线程执行异步操作”的模式,既保证了单线程的简洁性,又实现了异步并发处理。

promise 在循环中的地位

Promise 中运用了 queueMicrotask()使用微任务,作为核心载体,在循环结构中易出现“执行时机与预期不符”的问题,其核心矛盾在于:同步循环的迭代过程与 Promise 异步回调的调度逻辑脱节,需结合“闭包作用域”与“事件循环优先级”双重维度分析。

// 力扣题,手写实现promiseAll
var promiseAll = function (functions) {
  return new Promise((resolve, reject) => {
    const resultArr = [];
    let successCount = 0;
    functions.forEach((fn, index) => {
      fn()
        .then((res) => {
          resultArr[index] = res;
          if (++successCount === functions.length) {
            resolve(resultArr);
          }
        })
        .catch(reject);
    });
  });
};
结论

Promise 在循环中的执行地位可概括为:作为微任务,其回调执行优先级高于宏任务,但依赖于同步循环的执行节奏与变量作用域隔离。开发中需注意同步循环内批量注册的 Promise 会在循环结束后批量执行,而非逐次迭代后立即执行。

参考文章

JavaScript 事件循环机制深度解析:为何你的代码执行顺序和预期不同?根据 Chrome V8 团队的测试数据,Jav - 掘金

JavaScript 事件循环机制深度解析:从原理到实践-CSDN 博客

(8 封私信) 理解 JavaScript 事件循环机制:从同步到异步的深度解析 - 知乎