深入浅出JavaScript事件循环(event loop):宏任务与微任务的奇幻之旅

深入浅出JavaScript事件循环(event loop):宏任务与微任务的奇幻之旅

在JavaScript的世界里,事件循环就像一个神奇的魔法师,它能让单线程的语言处理各种异步任务。今天,让我们一起揭开这个魔法师的神秘面纱!

🌟 前言:单线程的挑战

JavaScript是单线程语言,这意味着它一次只能做一件事。想象一下,如果咖啡师只能一次服务一位顾客,那么当遇到需要长时间等待的任务(比如手冲咖啡)时,后面的顾客就会一直等待,整个队伍就停滞不前了。

javascript 复制代码
console.log('开始点单'); // 同步任务立即执行

setTimeout(() => {
  console.log('手冲咖啡完成'); // 耗时任务
}, 2000);

console.log('准备其他饮品'); // 同步任务立即执行

为了解决这个问题,JavaScript引入了事件循环(Event Loop机制,它就像一位聪明的咖啡店经理,能够合理安排任务的执行顺序。

🎢 事件循环的魔法舞台

事件循环的核心在于任务队列执行顺序。整个机制可以简化为以下步骤:

  1. 执行同步任务(调用栈中的任务)
  2. 执行所有微任务(直到微任务队列清空)
  3. 渲染页面(如果需要)
  4. 执行一个宏任务
  5. 重复上述过程

一个script就是一个宏任务的开始,我们的代码从一个宏任务开始执行,放到调用栈里面执行

📦 宏任务:大块头的工作

宏任务(MacroTask)是事件循环中的主要任务单位,包括:

  • <script>标签内的整体代码
  • setTimeoutsetInterval
  • I/O操作(文件读写、网络请求等)
  • UI渲染
  • 事件监听器(click、scroll等)
javascript 复制代码
console.log('script start'); // 宏任务开始

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

console.log('script end'); // 宏任务结束

每个事件循环周期只执行一个宏任务,然后就去检查微任务队列。

🧩 微任务:小而精的紧急任务

微任务(MicroTask)是在当前宏任务结束后立即执行的任务,它们具有"插队"特权:

  • Promise.then / catch / finally
  • MutationObserver
  • queueMicrotask
  • process.nextTick(Node.js环境)

Promise小贴士

  • Promise 本身是同步的:创建 Promise 实例和执行 executor 函数都是同步操作。
  • Promise 的回调是异步的then/catch/finally 会被放入微任务队列,在当前同步代码执行完毕后执行。
  • Promise 是类:它提供了一种优雅的方式来处理异步操作,避免回调地狱,同时保持代码的同步风格。
javascript 复制代码
console.log('同步Start'); //宏任务开始,同步任务开始

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

queueMicrotask(() => {
  console.log('queueMicrotask微任务');//微任务
});

console.log('同步End');//宏任务结束

⚖️ 为什么要有宏任务和微任务?

这种设计是为了平衡效率和响应速度

  1. 微任务处理紧急、高优先级任务(如Promise回调)
  2. 宏任务处理非紧急任务(如定时器)
  3. 微任务在当前宏任务结束后立即执行,确保高优先级任务快速响应
  4. 宏任务保证浏览器有时间进行渲染

🧪 实战分析:执行顺序之谜

html 复制代码
<script>
  console.log('script start'); // 宏任务1-1
  
  setTimeout(() => {
    console.log('setTimeout'); // 宏任务2
    Promise.resolve().then(() => {
      console.log('promise in setTimeout'); // 宏任务2的微任务
    });
  }, 0);
  
  Promise.resolve().then(() => {
    console.log('promise1'); // 微任务1-1
  }).then(() => {
    console.log('promise2'); // 微任务1-2
  });
  
  console.log('script end'); // 宏任务1-2
</script>

执行顺序解析:

  1. 执行宏任务(整个script)
    • 输出 script start
    • 设置定时器(宏任务)
    • 注册Promise微任务
    • 输出 script end
  2. 执行所有微任务
    • 输出 promise1
    • 注册新的微任务
    • 输出 promise2
  3. 执行下一个宏任务(定时器)
    • 输出 setTimeout
    • 注册新的微任务
  4. 执行定时器产生的微任务
    • 输出 promise in setTimeout

多个宏任务内部和微任务内部的顺序是怎么排列的呢

宏任务

JavaScript 的 Event Loop 在处理多个宏任务时,执行顺序受任务类型添加时机运行环境(浏览器 / Node.js)的影响。以下是详细解析:

宏任务队列的基本规则
  1. 先进先出(FIFO) :同一类型的宏任务按添加顺序执行。
  2. 优先级差异:不同类型的宏任务可能有不同的执行阶段(Node.js 中更明显)。
  3. 单次循环执行一个 :Event Loop 每次只从宏任务队列中取出一个任务执行,执行后立即处理微任务队列,再处理下一个宏任务。
浏览器环境下的宏任务执行

在浏览器中,常见的宏任务(如 setTimeoutsetInterval、UI 渲染)会被放入同一个宏任务队列(或按类型分组但顺序固定),执行规则如下:

  • 示例 1:同类型宏任务的顺序
javascript 复制代码
setTimeout(() => console.log('timer1'), 100);
setTimeout(() => console.log('timer2'), 0);
setTimeout(() => console.log('timer3'), 0);

执行顺序

  1. timer2(虽设定延迟 0ms,但实际至少延迟 4ms,且先加入队列)
  2. timer3(与 timer2 延迟相同,但后加入队列)
  3. timer1(延迟 100ms,最后触发)
  • 示例 2:宏任务与微任务的穿插
javascript 复制代码
setTimeout(() => {
  console.log('timer1'); // 宏任务1
  Promise.resolve().then(() => console.log('promise1')); // 微任务1
});

setTimeout(() => {
  console.log('timer2'); // 宏任务2
  Promise.resolve().then(() => console.log('promise2')); // 微任务2
});

执行顺序

  1. 执行 timer1(宏任务)

  2. 清空微任务队列:执行 promise1

  3. 执行 timer2(宏任务)

  4. 清空微任务队列:执行 promise2

关键点:每个宏任务执行后,微任务队列会被立即清空。

Node.js 环境下的宏任务执行

Node.js 的 Event Loop 分为 6 个阶段,每个阶段对应一个宏任务队列:

  1. timers :执行 setTimeoutsetInterval 的回调。
  2. I/O callbacks:处理网络、流、TCP 等错误回调。
  3. idle, prepare:仅内部使用。
  4. poll:检索新的 I/O 事件,执行 I/O 相关回调。
  5. check :执行 setImmediate 的回调。
  6. close callbacks :执行关闭事件的回调(如 socket.on('close'))。
  • 示例 3:Node.js 中的宏任务阶段差异
javascript 复制代码
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));

可能的输出结果

  • 结果 1timer → immediate

    若代码在主模块中执行,且事件循环启动时间超过 1ms,setTimeout 会先触发。

  • 结果 2immediate → timer

    若代码在 I/O 回调中执行,setImmediate 会在 check 阶段优先执行。

差异原因

  • setTimeouttimers 阶段执行,需等待至少 1ms。
  • setImmediatecheck 阶段执行,若事件循环已进入 check 阶段,则优先执行。
浏览器与 Node.js 的核心差异
特性 浏览器 Node.js
宏任务队列 通常合并为一个队列(先进先出) 分为 6 个阶段队列,按阶段顺序执行
setTimeoutsetImmediate setTimeout 可能先执行(取决于延迟时间) setImmediate 可能先执行(在 I/O 回调中)
微任务执行时机 每个宏任务后立即执行 每个阶段结束后执行
总结

在浏览器环境中,宏任务按添加顺序(先进先出)执行,且每个宏任务执行后会立即清空微任务队列;Node.js 环境下,宏任务按timersI/O callbackspollcheckclose callbacks的阶段顺序执行,setImmediatesetTimeout的执行顺序受代码位置和事件循环状态影响;通用规则是微任务优先级高于宏任务,每次宏任务执行后都会清空微任务队列,且应避免依赖宏任务的绝对执行顺序,优先用 Promise 和 async/await 处理异步逻辑。

微任务

微任务内部执行流程

  1. 每次执行栈为空时Event Loop 会从宏任务队列中选择最前面的任务执行。这通常是一个完整的调用栈,比如一个 setTimeout 回调或一次完整的脚本执行。
  2. 当这个宏任务执行完毕后 ,JavaScript 引擎会执行当前所有已注册的微任务。这意味着所有的微任务都会在这个阶段被依次执行完,不会中途插入新的宏任务。
  3. 执行完所有微任务后,浏览器可能会进行一些必要的 UI 更新(这取决于具体实现)。然后,Event Loop 将继续处理下一个宏任务。
  4. 如果在此期间有新的微任务被加入到微任务队列 中,这些新添加的微任务也会在这个周期内被执行

微任务的执行顺序

微任务队列遵循 FIFO(先进先出)原则。这意味着最先被推入微任务队列的任务将首先被执行。例如:

js 复制代码
console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise1');
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出结果将是:

arduino 复制代码
script start
script end
promise1
promise2
setTimeout

解释如下:

  1. 脚本开始执行,遇到 setTimeout,将其回调加入宏任务队列。
  2. 遇到第一个 Promise.resolve(),立即执行,并将 .then() 回调加入微任务队列。
  3. 脚本结束,控制台打印 script end
  4. 当前宏任务完成后,开始执行微任务队列中的所有任务。因此,先执行第一个 .then() 回调,打印 promise1;由于这个 .then() 返回的也是一个 Promise,所以它的 .then() 回调也被加入到了微任务队列尾部,在接下来的循环中执行,打印 promise2
  5. 最后,执行宏任务队列中的 setTimeout 回调,打印 setTimeout

关键点总结

  • 在每个宏任务执行结束后,JavaScript 引擎会清空所有的微任务队列,按顺序执行每一个微任务。
  • 新产生的微任务会在同一个事件循环周期内被执行,而不是等到下一个宏任务开始时。
  • 这种设计保证了微任务能够在第一时间响应异步操作的结果,而不需要等待下一次事件循环迭代。

🔍 特殊微任务详解

1. MutationObserver:DOM变化的守护者

MutationObserver被设计为微任务,是为了在页面渲染前 捕获DOM变化:MutationObserver 是浏览器提供的 API,用于监听 DOM 元素的变化(如属性修改、子节点增删等),其回调函数会在所有同步代码执行完毕后,以微任务形式触发。避免频繁触发回调影响性能,适合需要响应 DOM 动态变化的场景(如监听元素尺寸变化、内容更新等)。

html 复制代码
<div id="target"></div>

<script>
  const target = document.getElementById('target');
  
  const observer = new MutationObserver(() => {
    console.log('DOM变化啦!'); // 微任务
  });
  
  observer.observe(target, {
    attributes: true,
    childList: true
  });
  
  target.setAttribute('data-change', 'true'); // 触发变化
  console.log('同步代码结束');
</script>

输出顺序:

  1. 同步代码结束
  2. DOM变化啦!

2. queueMicrotask:微任务队列控制器

queueMicrotask作用:

用于将函数加入微任务队列,在当前同步代码执行后、渲染前高优先级异步执行,适用于 DOM 操作优化(如批量更新后获取布局信息)、异步错误处理及实现响应式系统等需在渲染前完成的关键任务。

queueMicrotask 的核心价值在于:

允许开发者直接向 JavaScript 引擎的微任务队列添加任务,利用微任务 "在当前宏任务执行完毕后、页面渲染前执行" 的特性,实现更高效的任务调度 ,可将任务放入微任务队列,在当前宏任务后、渲染前执行。用于页面渲染前批量下载时 ,能避免被渲染打断减少交叉阻塞 ,提升效率;处理DOM更新后,可在渲染前获取准确位置,此时浏览器已批量处理DOM变更,重排准备就绪,既能得到最新信息,又不会触发额外强制重排,降低性能消耗,优化操作体验。(如果直接在同步代码中获取 offsetHeight、scrollTop 等属性,浏览器会被迫 "立即执行重排" 以返回准确值(称为 "强制同步重排"),打破批量优化,增加性能开销。)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"> 
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>微任务</title>
</head>
<body>
    <script>
        console.log('同步')//宏任务
        //批量更新
        //DOM ,cssom,layout 树 ,图层的合并全部完成
        queueMicrotask(()=>{//微任务队列,用来观察微任务DOM的api
            //DOM更新了,但不是渲染完了,在渲染之前
            //一个元素的高度 offsetHeight scrollTop getBoundingClientRect
            //立即重绘重排,为了得到确切的位置 耗性能
            //本来要等渲染完之后才能得到准确的位置,但是通过queueMicrotask 可以在批量更新-》渲染之前得到准确的位置
            console.log('微任务:queueMicrotask')
        })
        console.log('同步结束')//宏任务

    </script>
</body>
</html>

3.nextTick微任务:Node.js中的特殊成员

在Node.js环境中,事件循环稍有不同:

javascript 复制代码
console.log('start');

process.nextTick(() => {
  console.log('nextTick微任务');
});

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

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

console.log('end');

执行顺序:

  1. start
  2. end
  3. nextTick微任务(Node.js特有)
  4. Promise微任务
  5. setTimeout宏任务
为什么nextTick微任务是 Node.js 特有的?
  1. 执行时机优先级更高
    在 Node.js 的事件循环中,process.nextTick() 的回调会在每个阶段结束后立即执行 ,甚至先于其他微任务(如 Promise.then)。这使得它成为 Node.js 中执行优先级最高的异步操作。
  2. 与 Node.js 底层机制强关联
    Node.js 的事件循环基于 libuv 库实现,process.nextTick() 用于处理 Node.js 内部的异步操作(如模块加载、I/O 回调),是 Node.js 运行时的核心组成部分。
核心作用
  1. 异步 API 实现:确保回调在当前函数返回后执行,避免同步调用阻塞。
  2. 防栈溢出:将递归转换为异步执行(每次递归放入微任务队列)。
  3. 阶段间执行 :在 setTimeout 等宏任务回调后立即执行,先于其他微任务。
  4. 同步转异步:对大文件读取等耗时操作,通过异步处理防止阻塞事件循环。
与浏览器微任务的区别
特性 process.nextTick() (Node.js) Promise.then (浏览器 / Node.js)
执行时机 每个事件循环阶段结束后立即执行 微任务队列的末尾,晚于 nextTick
优先级 最高 低于 nextTick
适用场景 Node.js 内部机制、异步 API 实现 通用异步操作链

🚀 性能优化实战技巧

  1. 长任务拆分:使用宏任务分解耗时操作

    javascript 复制代码
    function processChunk(data) {
      // 处理数据块...
    }
    
    function processLargeData(data) {
      let index = 0;
      
      function nextChunk() {
        const chunk = data.slice(index, index + 100);
        index += 100;
        
        processChunk(chunk);
        
        if (index < data.length) {
          setTimeout(nextChunk, 0); // 使用宏任务拆分
        }
      }
      
      nextChunk();
    }
  2. 优先使用微任务:当需要立即更新状态时

    javascript 复制代码
    let state = {};
    
    function updateState(newState) {
      Promise.resolve().then(() => {
        // 确保在下一个渲染周期前更新
        Object.assign(state, newState);
        render();
      });
    }

💡 总结与思考

特性 宏任务 微任务
执行时机 每个事件循环一次 当前宏任务结束后立即全部执行
常见API setTimeout, setInterval, I/O Promise.then, MutationObserver
优先级
是否阻塞渲染 否(执行在渲染前)

理解事件循环机制能帮助我们:

  1. 避免常见的异步陷阱
  2. 优化代码执行效率
  3. 写出更可预测的代码
  4. 解决页面卡顿问题

JavaScript的事件循环就像一场精心编排的交响乐,宏任务是稳健的节拍,微任务是灵动的音符,二者共同奏响了Web应用的华美乐章。当你真正理解了它们的协作方式,你就能编写出如音乐般流畅的代码!🎵

相关推荐
2分钟速写快排16 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰2 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
Interview Aid1122 小时前
Walmart Global Tech SDE 三轮面经|基础、并发、压力面
面试·职场和发展
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen4 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu5 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss