深入浅出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应用的华美乐章。当你真正理解了它们的协作方式,你就能编写出如音乐般流畅的代码!🎵

相关推荐
Trust yourself2434 分钟前
easyui碰到想要去除顶部栏按钮边框
前端·javascript·easyui
一洽客服系统15 分钟前
网页嵌入与接入功能说明
开发语言·前端·javascript
DoraBigHead28 分钟前
this 的前世今生:谁在叫我,我听谁的
前端·javascript·面试
蓝婷儿1 小时前
每天一个前端小知识 Day 28 - Web Workers / 多线程模型在前端中的应用实践
前端
琹箐1 小时前
Ant ASpin自定义 indicator 报错
前端·javascript·typescript
小小小小小惠1 小时前
Responsetype blob会把接口接收的二进制文件转换成blob格式
前端·javascript
爱电摇的小码农1 小时前
【深度探究系列(5)】:前端开发打怪升级指南:从踩坑到封神的解决方案手册
前端·javascript·css·vue.js·node.js·html5·xss
测试开发技术2 小时前
如何在 Pytest 中调用其他用例返回的接口参数?
面试·自动化·pytest·接口·接口测试·api测试
kymjs张涛2 小时前
零一开源|前沿技术周报 #7
android·前端·ios
爱编程的喵2 小时前
React入门实战:从静态渲染到动态状态管理
前端·javascript