JS 事件循环那点事儿:从任务分类到执行流程全解析

202 阅读9分钟

一、单线程特性:JavaScript 的运行基石

相信很多人在学习前端的过程中都有过这样的困惑:为什么 JavaScript 在处理复杂异步操作时会出现 "非预期" 的执行顺序?要解答这个问题,必须从 JavaScript 的底层运行机制说起。
作为一门单线程语言,JavaScript 在设计上遵循 同一时间只能执行一个任务 的原则,这种特性使得语言本身避免了复杂的线程同步问题,但也带来了如何处理耗时操作的挑战。

试想一下,如果在主线程中执行一个无限循环,整个程序就会陷入卡死状态。为了应对这种情况,JavaScript 引擎引入了异步处理机制,通过事件循环(Event Loop)实现了单线程环境下的非阻塞编程。接下来,我们将从任务分类开始,逐步揭开事件循环的神秘面纱。

二、任务分类:从宏观到微观的三层体系

同步任务 vs 异步任务

在宏观层面,JavaScript 任务可以分为两大阵营:同步任务与异步任务,二者的核心区别在于是否会阻塞主线程的执行。

同步任务

  • 执行特性:立即执行,阻塞主线程,必须等待当前任务完成才能执行后续代码

  • 典型场景

    • 函数调用(包括构造函数调用)
    • 表达式求值
    • 主线程同步代码

构造函数通过 new 调用时会立即执行,属于典型的同步任务。这是因为 new 操作符的执行流程完全在主线程完成:创建空对象→绑定原型→执行构造函数→返回实例,整个过程不可中断。

function Person(name) {
    console.log('开始执行构造函数');  
    this.name = name;
    console.log(`实例创建完成`);  
  }
  
  console.log('准备创建实例');  
  const person = new Person('张三');  
  console.log('实例创建后代码继续执行');  

输出顺序:

准备创建实例 → 开始执行构造函数 → 实例创建完成 → 实例创建后代码继续执行

构造函数的 new 调用是同步任务,会阻塞主线程。只有构造函数完全执行完毕(包括内部所有同步操作),后续代码才会继续执行。

异步任务

  • 执行特性:不阻塞主线程,通过回调机制通知主线程任务完成

  • 核心机制:当异步任务启动后,JavaScript 引擎会将其交给宿主环境(浏览器 / Node.js)处理,主线程继续执行后续代码,待任务完成后将回调函数加入任务队列

  • 典型场景

console.log('同步任务执行'); 
// 定时器任务(异步注册回调)
setTimeout(() => {
  console.log('定时器回调执行');
}, 0);

// 网络请求(异步加载数据)
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log('数据加载完成'));

输出结果

同步任务执行 → 数据加载完成 → 定时器回调执行 

宏任务 vs 微任务

为了更精确地控制任务执行顺序,异步任务被进一步划分为宏任务(Macro Task)和微任务(Micro Task),二者在调度机制和执行优先级上存在明显差异。

宏任务

  • 调度者:由宿主环境(浏览器 / Node.js)负责调度

  • 任务队列:每个宏任务有独立的任务队列

  • 常见类型

    • setTimeout 和 setInterval:定时器回调。
    • I/O 操作:文件读写、网络请求的回调(如 fetchXMLHttpRequest)。
    • DOM 事件回调:如点击事件、滚动事件。
    • requestAnimationFrame(浏览器特有):与渲染相关的回调,严格来说它不属于宏任务或微任务,但执行时机接近宏任务。
    • 脚本执行:主线程同步代码的初始执行。
    • setImmediate(Node.js 特有):类似于 setTimeout(fn, 0)

微任务

  • 调度者:由 JavaScript 引擎自身控制

  • 任务队列:全局唯一的微任务队列

  • 关键特性:在当前宏任务执行完毕后立即执行,且会在渲染操作之前完成,执行完成之后清空微任务队列

  • 常见类型

    • Promise.then() 和 Promise.catch() :Promise 的异步回调。
    • MutationObserver(浏览器特有):监听 DOM 变化。
    • process.nextTick(Node.js 特有):优先级最高的微任务,在 Node.js 中先于其他微任务执行。
    • queueMicrotask() :显式添加微任务的 API。
// Promise回调(微任务)
Promise.resolve().then(result => {
  console.log('Promise微任务');
});

// MutationObserver(浏览器DOM变化监听,微任务)
const observer = new MutationObserver(() => {
  console.log('DOM变化监听回调');
});
observer.observe(document.body, { attributes: true });

// Node.js特殊微任务(优先级高于普通微任务)
process.nextTick(() => { 
  console.log('Node.js nextTick');
});

四层任务体系对比表

特性/任务类型同步任务宏任务(异步)微任务 (异步)
阻塞主线程
调度主体JavaScript 引擎宿主环境(浏览器/Node)JavaScript 引擎
队列机制无(直接执行)多队列(定时器/I/O/渲染等)单队列(先进先出)
执行时机立即阻塞执行事件循环队列轮询当前宏任务结束后立即执行
执行优先级最高(主线程独占)低(按队列顺序)高(优先所有宏任务)
典型APIconsole.logfor循环setTimeoutfetchPromise.thenqueueMicrotask
触发方式代码顺序执行宿主API调用JS引擎异步解析
内存泄漏风险循环阻塞导致页面冻结未清理的定时器/事件监听递归微任务导致死循环

三、事件循环:单线程的异步调度核心

浏览器环境的事件循环流程

事件循环就像一个永不停歇的调度员,按照特定规则循环处理任务队列,其核心流程可以拆解为五个关键步骤:

  1. 执行同步任务(初始宏任务) :主线程首先执行同步代码,这是整个流程的起点。这些代码作为第一个宏任务被执行,形成初始调用栈。
  2. 收集微任务:在同步任务执行过程中,每当遇到微任务(如 Promise.then 回调),会将其添加到微任务队列中。
  3. 执行微任务队列:当当前宏任务执行完毕后,立即进入微任务处理阶段:依次执行微任务队列中的所有任务,直到队列为空。这个过程中途不会插入新的宏任务。
  4. 渲染页面(浏览器特有) :微任务全部执行完毕后,浏览器会进行页面渲染操作,包括布局计算、样式重绘等。需要注意的是,requestAnimationFrame 的回调会在渲染前执行,因此常被用于动画优化。
  5. 处理下一个宏任务:渲染完成后,事件循环会从宏任务队列中取出下一个任务(如定时器回调、I/O 事件等),重复上述流程。

image.png

经典案例:任务执行顺序解析

让我们通过一个包含构造函数的复杂案例验证事件循环机制,代码如下:

// 同步任务:构造函数调用
function Demo() {
    console.log('3. 构造函数内部执行');  
  }
  
  console.log('1. 同步任务开始');  
  
  // 异步宏任务:定时器
  setTimeout(() => {
    console.log('6. 定时器宏任务执行');  
    // 宏任务内部生成微任务
    Promise.resolve().then(() => {
      console.log('7. 定时器内的微任务');  
    });
  }, 0);
  
  // 微任务:Promise回调
  Promise.resolve().then(() => {
    console.log('5. 第一个Promise微任务'); 
    // 微任务内部注册宏任务
    setTimeout(() => {
      console.log('8. 微任务内的定时器宏任务'); 
    }, 0);
  });
  
  // 同步任务:构造函数调用
  console.log('2. 准备创建Demo实例');  
  const demo = new Demo();  
  
  console.log('4. 同步任务结束');

执行顺序解析

  1. 同步任务执行阶段(初始宏任务):
    按照代码顺序执行同步任务:
    1. 同步任务开始 → 2. 准备创建Demo实例 → 3. 构造函数内部执行 → 4. 同步任务结束
  2. 微任务队列执行阶段
    同步任务完成后,执行微任务队列中的所有任务:
    5. 第一个Promise微任务(微任务队列此时只有这一个任务)。
  3. 第一个宏任务(定时器)执行
    微任务队列清空后,执行宏任务队列中的第一个任务(定时器回调):
    6. 定时器宏任务执行
  4. 再次执行微任务队列
    定时器宏任务执行过程中生成了新的微任务(Promise.then),因此需要再次清空微任务队列:
    7. 定时器内的微任务
  5. 第二个宏任务(微任务内的定时器)执行
    最后执行微任务内部注册的宏任务:
    8. 微任务内的定时器宏任务

最终输出顺序:

1. 同步任务开始 → 2. 准备创建Demo实例 → 3. 构造函数内部执行 → 4. 同步任务结束 → 5. 第一个Promise微任务 → 6. 定时器宏任务执行 → 7. 定时器内的微任务 → 8. 微任务内的定时器宏任务

Node.js 环境的差异点

Node.js 的事件循环机制与浏览器类似,但在任务分类和执行顺序上存在细微差别:

  1. 独特的阶段划分:Node.js 将宏任务分为六个阶段,包括 timers(定时器)、I/O callbacks(I/O 回调)、idle/prepare(内部使用)、poll(轮询)、check(setImmediate 阶段)、close callbacks(关闭事件回调)。
  2. process.nextTick 的特殊性:这是 Node.js 特有的微任务,其回调会在当前操作完成后立即执行,优先级高于普通微任务(包括 Promise.then)。例如:
// Node.js环境
Promise.resolve().then(() => {
  console.log('Promise微任务');  // 输出2
});

process.nextTick(() => {
  console.log('nextTick任务');  // 输出1(优先级更高)
});
  1. setImmediate 与 setTimeout 的区别:在 Node.js 中,setImmediate 用于在 poll 阶段之后执行,而 setTimeout 属于 timers 阶段,二者执行顺序取决于调用时机:
// 在I/O操作回调中
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => { 
    console.log('setTimeout');  // timers阶段
  }, 0);
  
  setImmediate(() => { 
    console.log('setImmediate');  // check阶段
  });
});
// 输出顺序不确定,取决于事件循环当前阶段

四、 总结

牢记两个核心点

  • javascript是一门单线程语言
  • 事件循环是javascript的执行机制

理解 JavaScript 的任务分类与事件循环机制,本质上是理解单线程环境下异步编程的底层逻辑。这些知识不仅能帮助我们解释 "为什么代码会这样执行",更能指导我们写出高性能、可维护的异步代码。