深入理解JavaScript事件循环机制:从同步到异步的完整解析

前言

在日常开发中,你是否遇到过这样的困惑:为什么有些代码的执行顺序和预期不一致?为什么Promise会比setTimeout先执行?这些问题的答案都指向JavaScript的核心机制------事件循环(Event Loop)。

事件循环是JavaScript执行机制的心脏,也是理解异步编程的关键。今天,我们通过实际代码案例,深入剖析这个看似复杂但实则优雅的机制。

JavaScript单线程的本质

JavaScript作为单线程语言,同一时刻只能处理一件事。这意味着:

  • 同步任务需要尽快执行完成
  • 页面渲染(重绘重排)必须及时响应
  • 用户交互要优先处理
  • 耗时性任务不能阻塞主线程

那么,耗时性任务如何处理呢?这就涉及到异步任务的概念。

宏任务与微任务的区别

宏任务(Macro Task)

每个script脚本就是一个宏任务的开始。常见的宏任务包括:

  • setTimeout/setInterval
  • fetch/ajax请求
  • DOM事件监听器
  • script标签执行

微任务(Micro Task)

微任务是紧急的、优先的,是同步任务执行完后的一个补充。包括:

  • Promise.then()、Promise.catch()、Promise.finally()
  • MutationObserver(DOM变化监听)
  • queueMicrotask
  • process.nextTick()(Node.js环境)

⚠️ 重要提醒:Promise本身不是微任务!

  • new Promise(executor) 中的executor函数是同步执行的
  • 只有Promise的 .then().catch().finally() 回调才是微任务
  • Promise.resolve()Promise.reject() 创建的是已resolved/rejected的Promise,但仍需要 .then() 才产生微任务

经典案例分析

案例一:基础执行顺序

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

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

Promise.resolve().then(() => {
    console.log('promise');
});

console.log('script end');

执行结果:

arduino 复制代码
script start
script end
promise
setTimeout

详细执行步骤解析:

第1步:同步任务执行

  • 执行 console.log('script start'),输出:script start
  • 遇到 setTimeout(),这是一个宏任务,将回调函数放入宏任务队列等待
  • 遇到 Promise.resolve(),创建一个已resolved的Promise对象(这本身是同步的)
  • 调用 .then(),将then回调放入微任务队列(这里才产生微任务)
  • 执行 console.log('script end'),输出:script end

第2步:微任务队列检查

  • 同步任务执行完毕,检查微任务队列
  • 发现Promise.then()回调,立即执行,输出:promise
  • 微任务队列清空

第3步:宏任务队列执行

  • 微任务队列为空,从宏任务队列取出setTimeout回调执行
  • 输出:setTimeout

关键理解:

  • 同步任务 > 微任务 > 宏任务
  • 微任务队列必须完全清空后才会执行宏任务
  • Promise.resolve()是同步的,只有.then()才产生微任务

案例二:复杂的嵌套异步

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

Promise.resolve().then(() => {
    console.log('promise3');
    setTimeout(() => {
        console.log('timeout3');
    }, 0);
});

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(() => {
        console.log('promise1');
    });
}, 0);

setTimeout(() => {
    console.log('timeout2');
    Promise.resolve().then(() => {
        console.log('promise2');
    });
}, 0);

console.log('end');

执行结果:

sql 复制代码
start
end
promise3
timeout1
promise1
timeout2
promise2
timeout3

详细执行步骤解析:

第1步:同步任务执行

  • 执行 console.log('start'),输出:start
  • 遇到 Promise.resolve().then(),将回调放入微任务队列
  • 遇到第一个 setTimeout(),将回调放入宏任务队列(记为timeout1)
  • 遇到第二个 setTimeout(),将回调放入宏任务队列(记为timeout2)
  • 执行 console.log('end'),输出:end

第2步:微任务队列执行

  • 同步任务完成,检查微任务队列
  • 执行Promise回调,输出:promise3
  • 在Promise回调中遇到 setTimeout(),将新的回调放入宏任务队列(记为timeout3)
  • 微任务队列清空

第3步:第一个宏任务执行

  • 从宏任务队列取出timeout1执行
  • 输出:timeout1
  • 在timeout1中遇到 Promise.resolve().then(),将回调放入微任务队列
  • 检查微任务队列,执行Promise回调,输出:promise1

第4步:第二个宏任务执行

  • 微任务队列为空,取出timeout2执行
  • 输出:timeout2
  • 在timeout2中遇到 Promise.resolve().then(),将回调放入微任务队列
  • 检查微任务队列,执行Promise回调,输出:promise2

第5步:第三个宏任务执行

  • 微任务队列为空,取出timeout3执行
  • 输出:timeout3

关键理解:

  • 每个宏任务执行完后,都会清空微任务队列
  • 新产生的宏任务会按顺序排队
  • 宏任务中产生的微任务会在下一个宏任务执行前完成

案例三:Node.js环境的特殊性

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

process.nextTick(() => {
    console.log('Process Next Tick');
});

Promise.resolve().then(() => {
    console.log('Promise Resolved');
});

setTimeout(() => {
    console.log('六百六十六');
    Promise.resolve().then(() => {
        console.log('inner Promise');
    });
}, 0);

console.log('end');

执行结果:

sql 复制代码
Start
end
Process Next Tick
Promise Resolved
六百六十六
inner Promise

详细执行步骤解析:

第1步:同步任务执行

  • 执行 console.log('Start'),输出:Start
  • 遇到 process.nextTick(),将回调放入Node.js特有的nextTick队列
  • 遇到 Promise.resolve().then(),将回调放入微任务队列
  • 遇到 setTimeout(),将回调放入宏任务队列
  • 执行 console.log('end'),输出:end

第2步:Node.js微任务执行

  • 同步任务完成,先检查nextTick队列(优先级最高)
  • 执行process.nextTick回调,输出:Process Next Tick
  • 然后检查Promise微任务队列
  • 执行Promise回调,输出:Promise Resolved
  • 所有微任务清空

第3步:宏任务执行

  • 从宏任务队列取出setTimeout回调执行
  • 输出:六百六十六
  • 在setTimeout中遇到 Promise.resolve().then(),将回调放入微任务队列
  • 检查微任务队列,执行Promise回调,输出:inner Promise

Node.js特殊性:

  • process.nextTick() 优先级高于 Promise.then()
  • nextTick队列会在Promise微任务队列之前执行
  • 这是Node.js特有的微任务处理机制

DOM操作中的微任务

MutationObserver的应用

javascript 复制代码
// HTML文档第一个BFC(块级格式化上下文)
const target = document.createElement('div');
document.body.appendChild(target);

const observer = new MutationObserver(() => {
    console.log('微任务: MutationObserver');
});

// 监听target节点的变化
observer.observe(target, {
    childList: true,
    attributes: true,
});

target.setAttribute('data-set', '123');
target.appendChild(document.createElement('span'));
target.setAttribute('style', 'background-color: green;');

执行结果:

makefile 复制代码
微任务: MutationObserver

详细执行步骤解析:

第1步:DOM操作执行

  • 创建div元素并添加到body
  • 创建MutationObserver实例并开始监听
  • 执行 target.setAttribute('data-set', '123')
  • 执行 target.appendChild(document.createElement('span'))
  • 执行 target.setAttribute('style', 'background-color: green;')

第2步:微任务队列处理

  • 同步DOM操作完成后,MutationObserver检测到变化
  • 将观察者回调放入微任务队列
  • 执行微任务,输出:微任务: MutationObserver

关键理解:

  • MutationObserver能在DOM改变后、页面渲染前捕获变化
  • 多次DOM操作会被批量处理,只触发一次回调
  • 这对于性能优化非常重要

queueMicrotask的实际应用

javascript 复制代码
console.log("同步");

// 批量更新DOM树、CSSOM、layout树和图层合并
queueMicrotask(() => {
    // DOM更新了,但不是DOM渲染完了
    // 获取元素高度 offsetHeight、scrollTop、getBoundingClientRect()
    // 会立即触发重绘重排,影响性能
    console.log("微任务: queueMicrotask");
});

console.log("同步结束");

执行结果:

makefile 复制代码
同步
同步结束
微任务: queueMicrotask

详细执行步骤解析:

第1步:同步任务执行

  • 执行 console.log("同步"),输出:同步
  • 遇到 queueMicrotask(),将回调放入微任务队列
  • 执行 console.log("同步结束"),输出:同步结束

第2步:微任务执行

  • 同步任务完成,检查微任务队列
  • 执行queueMicrotask回调,输出:微任务: queueMicrotask

应用场景:

  • 在DOM更新后、渲染前执行某些操作
  • 避免在微任务中频繁获取DOM几何信息(会触发强制重排)

Promise执行机制深度解析

为了让大家更好地理解Promise与微任务的关系,我们来看一个详细的示例:

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

// Promise构造函数是同步执行的
const promise1 = new Promise((resolve) => {
    console.log('2'); // 同步执行
    resolve('resolved');
});

// Promise.resolve()是同步的,创建已resolved的Promise
const promise2 = Promise.resolve('immediate');

console.log('3');

// 只有.then()才产生微任务
promise1.then((value) => {
    console.log('4: ' + value);
});

// 只有.then()才产生微任务
promise2.then((value) => {
    console.log('5: ' + value);
});

console.log('6');

执行结果:

makefile 复制代码
1
2
3
6
4: resolved
5: immediate

关键理解:

  • new Promise(executor) 中的executor函数立即同步执行
  • Promise.resolve() 同步创建已resolved的Promise对象
  • 只有调用 .then() 方法时,才会将回调函数放入微任务队列
  • 这就是为什么输出顺序是 1→2→3→6→4→5

实战中的Promise处理

多个Promise的执行顺序

javascript 复制代码
console.log('同步Start');

const promise1 = Promise.resolve('First Promise');
const promise2 = Promise.resolve('Second Promise');
const promise3 = new Promise(resolve => {
    console.log('Promise3');
    resolve('Third Promise');
});

promise1.then(res => {
    console.log(res);
});

promise2.then(res => {
    console.log(res);
});

promise3.then(res => {
    console.log(res);
});

setTimeout(() => {
    console.log('六百六十六');
    const p4 = Promise.resolve('Promise4');
    p4.then(res => {
        console.log(res);
    });
});

setTimeout(() => {
    console.log('演都不演了');
});

console.log('同步end');

执行结果:

sql 复制代码
同步Start
Promise3
同步end
First Promise
Second Promise
Third Promise
六百六十六
Promise4
演都不演了

详细执行步骤解析:

第1步:同步任务执行

  • 执行 console.log('同步Start'),输出:同步Start
  • 创建 promise1(Promise.resolve()创建已resolved的Promise,这是同步的)
  • 创建 promise2(Promise.resolve()创建已resolved的Promise,这是同步的)
  • 创建 promise3执行构造函数中的同步代码 ,输出:Promise3
  • 遇到 promise1.then()调用.then()才产生微任务,将回调放入微任务队列
  • 遇到 promise2.then()调用.then()才产生微任务,将回调放入微任务队列
  • 遇到 promise3.then()调用.then()才产生微任务,将回调放入微任务队列
  • 遇到第一个 setTimeout(),将回调放入宏任务队列
  • 遇到第二个 setTimeout(),将回调放入宏任务队列
  • 执行 console.log('同步end'),输出:同步end

第2步:微任务队列执行

  • 同步任务完成,按顺序执行微任务队列中的回调
  • 执行promise1.then()回调,输出:First Promise
  • 执行promise2.then()回调,输出:Second Promise
  • 执行promise3.then()回调,输出:Third Promise
  • 微任务队列清空

第3步:第一个宏任务执行

  • 从宏任务队列取出第一个setTimeout回调执行
  • 输出:六百六十六
  • 在回调中创建 p4 并调用then(),将回调放入微任务队列
  • 检查微任务队列,执行p4.then()回调,输出:Promise4

第4步:第二个宏任务执行

  • 微任务队列为空,取出第二个setTimeout回调执行
  • 输出:演都不演了

关键理解:

  • Promise构造函数中的代码是同步执行的,不是微任务
  • Promise.resolve()、Promise.reject()创建Promise对象是同步的
  • 只有.then()、.catch()、.finally()的回调才是微任务
  • 已resolved的Promise的then回调会立即进入微任务队列
  • 微任务按照进入队列的顺序执行
  • 宏任务中产生的微任务会在下一个宏任务前执行

事件循环的完整流程

基于以上所有案例分析,我们可以总结出事件循环的完整执行流程:

执行顺序优先级(从高到低):

  1. 同步任务 - 直接在调用栈中执行
  2. 微任务 - 包括Promise.then()、MutationObserver、queueMicrotask等
  3. 宏任务 - 包括setTimeout、setInterval、DOM事件等

详细执行步骤:

步骤1:执行同步任务

  • 执行调用栈中的所有同步代码
  • 遇到异步任务时,将回调函数分别放入对应的任务队列

步骤2:检查并执行微任务队列

  • 同步任务执行完毕后,立即检查微任务队列
  • 依次执行微任务队列中的所有任务
  • 如果微任务中又产生新的微任务,继续执行直到队列为空

步骤3:执行一个宏任务

  • 微任务队列清空后,从宏任务队列中取出一个任务执行
  • 执行完这个宏任务后,不会立即执行下一个宏任务

步骤4:重复步骤2-3

  • 再次检查微任务队列,执行所有微任务
  • 然后执行下一个宏任务
  • 循环往复,直到所有任务完成

关键记忆点:

  • 一次事件循环 = 执行一个宏任务 + 清空微任务队列
  • 微任务具有"插队"特权,总是在下一个宏任务前执行
  • 微任务队列必须完全清空,才会执行下一个宏任务

性能优化建议

  1. 合理使用微任务:避免在微任务中执行耗时操作
  2. DOM操作优化:利用MutationObserver批量处理DOM变化
  3. 避免强制同步布局:在微任务中获取DOM几何信息会触发重排
  4. Promise链优化:避免过深的Promise嵌套

总结

事件循环机制是JavaScript异步编程的基础,理解它有助于:

  • 写出更高效的异步代码
  • 避免常见的执行顺序陷阱
  • 优化页面性能和用户体验
  • 更好地调试异步相关问题

核心要点回顾:

  1. Promise本身不是微任务

    • new Promise(executor) 中的executor是同步执行的
    • Promise.resolve() 创建Promise对象是同步的
    • 只有 .then().catch().finally() 才产生微任务
  2. 执行顺序优先级

    • 同步任务 > 微任务 > 宏任务
    • 微任务队列必须完全清空后才执行宏任务
  3. 事件循环流程

    • 一次事件循环 = 执行一个宏任务 + 清空微任务队列
    • 微任务具有"插队"特权

记住这个口诀:同步优先,微任务次之,宏任务最后。每次宏任务执行完毕,都要清空微任务队列,这就是事件循环的核心逻辑。

在实际开发中,多观察、多实践,你会发现事件循环不仅是理论知识,更是提升代码质量的有力工具。

相关推荐
好困好想睡1 分钟前
认识Promise
javascript
杨进军1 分钟前
实现 React 类组件渲染
前端·react.js·前端框架
小山不高2 分钟前
react封装横向滚动组件
前端
前端_ID林2 分钟前
谈谈JavaScript的异步函数发展历程
javascript
拾光拾趣录4 分钟前
油猴插件开发学习:从零编写你的第一个浏览器增强脚本
前端·浏览器
国家不保护废物4 分钟前
深入浅出JavaScript事件循环(event loop):宏任务与微任务的奇幻之旅
前端·javascript·面试
FogLetter5 分钟前
React组件开发之Todos基础:从零打造一个优雅的待办事项应用
前端·javascript·react.js
刘羡阳6 分钟前
使用d3js实现了一个组织架构树形图(拖拽,展开收起)
前端
风铃喵游10 分钟前
Vue渲染器:打通开发编译渲染的最后一步
前端·架构
拾光拾趣录10 分钟前
虚拟DOM超详细流程
前端·vue.js·dom