欢迎光临
我们一直在努力

JavaScript事件循环(上) - 单线程的异步魔法

JavaScript事件循环(上) – 单线程的异步魔法

  • 前言:一道经典的面试题
  • JavaScript的单线程模型
    • 为什么是单线程?
      • 单线程的优势
      • 单线程的挑战:
    • 异步编程的解决方案
  • 事件循环
    • 调用栈(Call Stack)
      • 调用栈的工作原理
      • 何时进栈?何时出栈?
        • 规则1:函数调用时进栈,返回时出栈
        • 规则2:同步代码立即执行,立即出栈
        • 规则3:栈溢出
      • 哪些代码进调用栈执行?
        • 1. 函数声明和调用
        • 2. 立即执行的代码
        • 3. 循环和条件语句
        • 4. 表达式计算
        • 5. 对象和数组的创建
        • 6. 异常处理
    • 消息队列(Task Queue / MacroTask Queue)
      • 为什么需要消息队列?
        • 如果没有消息队列
        • 消息队列的异步处理
      • 哪些代码进入消息队列?
        • 1. setTimeout / setInterval
        • 2. I/O操作
        • 3. 浏览器事件监听
        • 4. 浏览器UI渲染任务
        • 5. postMessage
      • 消息队列何时被执行?
    • 微任务队列(MicroTask Queue)
      • 微任务队列 vs 消息队列
      • 哪些代码进入微任务队列?
        • 1. Promise.then / .catch / .finally
        • 2. async/await(基于Promise)
        • 3. queueMicrotask API
        • 4. MutationObserver
        • 5. process.nextTick
      • 微任务的特点:
      • 微任务队列的执行时机
        • 规则1:在每个宏任务结束后执行
        • 规则2:微任务可以嵌套微任务
  • 从调用栈到事件循环的完整执行流程
    • 执行顺序的黄金法则
  • 经典面试题解析
    • 基础题:理解执行顺序
    • 进阶题:嵌套任务
    • 难题:async/await的转换
  • 思考题:以下代码的输出顺序是什么?
  • 结语

为什么 JavaScript 是单线程却能处理高并发?为什么 setTimeout(fn, 0) 不会立即执行?本篇文章将深入探索JavaScript事件循环的奥秘。

前言:一道经典的面试题

console.log('Start');

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

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

console.log('End');

要理解这个结果,我们需要先明白 JavaScript 是如何在单线程中实现异步操作的。

JavaScript的单线程模型

为什么是单线程?

JavaScript 设计之初主要用于浏览器脚本,需要操作 DOM。如果多线程同时操作 DOM,会带来复杂的同步问题。

// 假设JavaScript是多线程的(伪代码)
// 线程A
document.getElementById('myDiv').style.color = 'red';

// 线程B同时执行
document.getElementById('myDiv').style.color = 'blue';

假设JavaScript是多线程的,上述代码结果是什么?红色?蓝色?还是其他?这个结果是无法确定的,多线程操作DOM会导致竞态条件。

单线程的优势

  • 避免复杂的线程同步问题
  • 简化语言设计
  • 降低内存消耗

单线程的挑战:

  • 如何避免阻塞?
  • 如何处理I/O操作?
  • 如何实现高并发?

异步编程的解决方案

为了解决上述问题,JavaScript 引入异步编程的解决方案,即:同步代码会阻塞,异步代码不会阻塞:

// 同步代码会阻塞
function syncTask() {
console.log('开始同步任务');
const start = Date.now();

// 模拟耗时操作
while (Date.now() start < 3000) {
// 空循环3秒
}

console.log('同步任务完成');
}

console.log('开始');
syncTask(); // 这里会阻塞3秒
console.log('结束'); // 3秒后才会执行

// 异步代码不会阻塞
function asyncTask() {
console.log('开始异步任务');

setTimeout(() => {
console.log('异步任务完成');
}, 3000);
}

console.log('开始');
asyncTask(); // 立即返回
console.log('结束'); // 立即执行

事件循环

调用栈(Call Stack)

调用栈是 JavaScript 引擎用来跟踪函数调用的数据结构。它是一个后进先出(LIFO)的栈结构。

调用栈的工作原理

function greet(name) {
console.log(`Hello, ${name}!`);
return `Greeted ${name}`;
}

function welcome() {
const message = greet('zhangsan');
console.log(message);
return 'Welcome complete';
}

function main() {
console.log('Program started');
const result = welcome();
console.log(result);
console.log('Program ended');
}

main();

以上代码的调用栈的完整变化过程(假设从全局执行上下文开始):

  • 首先调用 main() ,进栈
  • mian() 中执行 console.log('Program started'),并立即出栈
  • main() 中调用 welcome() ,welcome() 进栈
  • welcome() 中调用 greet('zhangsan'),greet('zhangsan') 进栈
  • greet() 中执行 console.log(),并立即出栈
  • greet() 返回,出栈;welcome() 恢复执行
  • 依次出栈,直到栈空
  • 何时进栈?何时出栈?

    规则1:函数调用时进栈,返回时出栈

    function a() {
    console.log('a 开始'); // 此时调用栈:[global, a]
    b(); // b进栈
    console.log('a 结束'); // b已出栈,a继续执行
    }
    function b() {
    console.log('b 开始'); // 此时调用栈:[global, a, b]
    console.log('b 结束'); // c已出栈,b继续执行
    }

    规则2:同步代码立即执行,立即出栈

    console.log('直接执行'); // 进栈 → 执行 → 出栈

    规则3:栈溢出

    function recursive() {
    recursive(); // 无限递归,调用栈不断增长
    }
    // recursive(); // 最终报错:Maximum call stack size exceeded

    哪些代码进调用栈执行?

    只要是同步代码,都在当前调用栈中立即执行:

    1. 函数声明和调用

    function normalFunction() {
    // 这个函数体在调用时进入调用栈
    }

    2. 立即执行的代码

    (function IIFE() {
    console.log('IIFE立即执行');
    })();

    3. 循环和条件语句

    for (let i = 0; i < 3; i++) {
    console.log(i); // 每次循环都在调用栈中执行
    }

    4. 表达式计算

    const result = 1 + 2 * 3; // 计算在调用栈中进行

    5. 对象和数组的创建

    const obj = { name: 'test' };
    const arr = [1, 2, 3];

    6. 异常处理

    try {
    throw new Error('test');
    } catch (error) {
    console.log(error); // catch块在调用栈中执行
    }

    消息队列(Task Queue / MacroTask Queue)

    为什么需要消息队列?

    JavaScript 是单线程的,如果所有代码都在调用栈中执行,遇到耗时操作(如网络请求、定时器)就会阻塞整个线程。消息队列就是为了解决这个问题而存在的。

    如果没有消息队列

    function blockingNetworkRequest() {
    // 假设这是同步的网络请求(实际JS中很少见)
    const response = fakeSyncRequest('https://api.example.com'); // 阻塞3秒

    console.log('请求完成:', response);
    // 在这3秒内,用户界面完全卡住!
    // 不能点击,不能滚动,不能做任何事情
    }

    消息队列的异步处理

    function asyncNetworkRequest() {
    console.log('开始请求');

    // 异步请求:立即返回,回调进入消息队列
    fakeAsyncRequest('https://api.example.com', (response) => {
    console.log('请求完成:', response);
    // 这个回调会在将来的某个事件循环中执行
    });

    console.log('请求已发起,继续执行其他代码');
    // 用户界面不会卡住!
    }

    哪些代码进入消息队列?

    1. setTimeout / setInterval

    setTimeout(() => {
    console.log('setTimeout回调'); // 进入消息队列
    }, 1000);

    2. I/O操作

    fs.readFile('file.txt', (err, data) => { });

    3. 浏览器事件监听

    document.addEventListener('click', () => {
    console.log('点击事件'); // 进入消息队列
    });

    4. 浏览器UI渲染任务

    requestAnimationFrame(() => {
    console.log('重绘前执行'); // 进入消息队列
    });

    5. postMessage

    window.postMessage('message', '*');

    消息队列何时被执行?

    console.log('1');

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

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

    console.log('5');

    以上代码的完整执行流程:

  • 调用栈:首先 console.log(‘1’) 执行并出栈,然后 console.log(‘5’) 执行并出栈
  • 调用栈清空,检查消息队列
  • 消息队列:[第一个setTimeout回调, 第二个setTimeout回调]
  • 执行第一个回调:console.log(‘2’),添加新的setTimeout到消息队列
  • 消息队列变为:[第二个setTimeout回调, 第三个setTimeout回调]
  • 执行第二个回调:console.log(‘4’)
  • 执行第三个回调:console.log(‘3’)
  • 微任务队列(MicroTask Queue)

    微任务队列 vs 消息队列

    • 所有的微任务会进入微任务队列
    • 所有的宏任务会进入消息队列
    • 微任务队列有更高的优先级

    console.log('脚本开始');

    // 宏任务(进入消息队列)
    setTimeout(() => {
    console.log('setTimeout – 宏任务');
    }, 0);

    // 微任务(进入微任务队列)
    Promise.resolve().then(() => {
    console.log('Promise – 微任务');
    });

    console.log('脚本结束');

    上述代码的执行过程如下:

  • 执行所有同步代码(调用栈):

    • console.log(‘脚本开始’)
    • 设置setTimeout(回调进入消息队列)
    • 设置Promise.then(回调进入微任务队列)
    • console.log(‘脚本结束’)
  • 调用栈为空,检查微任务队列:

    • 微任务队列:[Promise回调]
    • 执行Promise回调:console.log(‘Promise – 微任务’)
  • 微任务队列为空,检查消息队列:

    • 消息队列:[setTimeout回调]
    • 执行setTimeout回调:console.log(‘setTimeout – 宏任务’)
  • 消息队列为空,所有代码执行完成

  • 哪些代码进入微任务队列?

    1. Promise.then / .catch / .finally

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

    2. async/await(基于Promise)

    async function asyncFunc() {
    await Promise.resolve();
    console.log('async/await后的代码'); // 相当于微任务
    }

    3. queueMicrotask API

    queueMicrotask(() => {
    console.log('queueMicrotask');
    });

    4. MutationObserver

    const observer = new MutationObserver(() => {
    console.log('DOM变化');
    });

    5. process.nextTick

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

    注:process.nextTick 的优先级比 Promise 还高!

    微任务的特点:

  • 在当前任务结束后、下一个任务开始前执行
  • 如果微任务中添加了新的微任务,会继续执行直到队列为空
  • 微任务队列的优先级高于消息队列
  • 微任务队列的执行时机

    规则1:在每个宏任务结束后执行

    setTimeout(() => {
    console.log('宏任务1开始');

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

    console.log('宏任务1结束');
    }, 0);

    setTimeout(() => {
    console.log('宏任务2开始');

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

    console.log('宏任务2结束');
    }, 0);

    规则2:微任务可以嵌套微任务

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

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

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

    从调用栈到事件循环的完整执行流程

    让我们通过一个复杂例子理解完整流程:

    console.log('【1】同步代码 – 调用栈');

    setTimeout(() => {
    console.log('【4】setTimeout回调 – 消息队列');

    Promise.resolve().then(() => {
    console.log('【5】setTimeout中的Promise – 微任务队列');
    });
    }, 0);

    Promise.resolve().then(() => {
    console.log('【3】Promise.then – 微任务队列');

    setTimeout(() => {
    console.log('【6】Promise中的setTimeout – 消息队列');
    }, 0);
    });

    queueMicrotask(() => {
    console.log('【2】queueMicrotask – 微任务队列');
    });

    console.log('【0】同步代码结束 – 调用栈');

    上述代码的完整执行流程解析如下:

  • 初始执行(调用栈):

    • console.log(‘【1】同步代码 – 调用栈’) – 立即执行
    • setTimeout设置,回调函数加入消息队列
    • Promise.then设置,回调函数加入微任务队列
    • queueMicrotask设置,回调函数加入微任务队列
    • console.log(‘【0】同步代码结束 – 调用栈’) – 立即执行
  • 调用栈清空,微任务队列执行:

    • 微任务队列当前:[ Promise.then回调, queueMicrotask回调 ]
    • queueMicrotask回调执行:console.log(‘【2】queueMicrotask – 微任务队列’)
    • Promise.then回调执行:console.log(‘【3】Promise.then – 微任务队列’)
    • 执行过程中,Promise内部又嵌套了新的setTimeout,该回调加入消息队列
  • 微任务队列清空,消息队列执行

    • 消息队列当前:[ 第一个setTimeout回调, Promise中设置的setTimeout回调 ]
    • 执行第一个setTimeout回调:console.log(‘【4】setTimeout回调 – 消息队列’)
    • 第一个setTimeout中设置了新的Promise.then,加入微任务队列,当前宏任务结束
  • 再次检查微任务队列

    • 微任务队列当前:[setTimeout中的Promise回调]
    • 行这个微任务:console.log(‘【5】setTimeout中的Promise – 微任务队列’)
  • 微任务队列清空,继续执行消息队列

    • 消息队列当前:[Promise中设置的setTimeout回调]
    • 执行这个回调:console.log(‘【6】Promise中的setTimeout – 消息队列’)
  • 代码执行完成

  • 执行顺序的黄金法则

  • 规则1:同步代码立即执行
  • 规则2:微任务在同步代码之后、下一个宏任务之前执行
  • 规则3:宏任务在微任务之后执行
  • 规则4:每个宏任务结束后执行其所有微任务
  • 规则5:微任务可以添加新的微任务
  • 经典面试题解析

    基础题:理解执行顺序

    console.log('A');

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

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

    console.log('D');

    上述代码比较简单,其输出结果是: A D C B 。

    进阶题:嵌套任务

    console.log('start');

    setTimeout(() => {
    console.log('timeout1');

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

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

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

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

    console.log('end');

    解析:

  • 同步阶段:

    • console.log(‘start’); 进入调用栈执行,执行完成后马上出栈;
    • setTimeout 回调,加入消息队列:[timeout1回调];
    • Promise.then 回调,加入微任务队列:[promise2回调];
    • console.log(‘end’); 进入调用栈执行,执行完成后马上出栈。
    • 调用栈空!
  • 微任务阶段:

    • 执行promise2回调:
      • console.log(‘promise2’);
      • setTimeout回调,加入消息队列:[timeout1回调, timeout2回调];
      • Promise.then回调,加入微任务队列:[promise3回调]。
    • 执行promise3回调:
      • console.log(‘promise3’);
    • 微任务队列空!
  • 宏任务阶段:

    • 执行timeout1回调:
      • console.log(‘timeout1’)
      • Promise.then回调,加入微任务队列:[promise1回调]
    • timeout1宏任务结束,检查微任务队列:
      • 执行promise1回调:console.log(‘promise1’)
      • 执行timeout2回调:console.log(‘timeout2’)
  • 代码执行完成,最终输出结果:start end promise2 promise3 timeout1 promise1 timeout2 。

  • 难题:async/await的转换

    async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
    }

    async function async2() {
    console.log('async2');
    await Promise.resolve();
    console.log('async2 end');
    }

    console.log('script start');

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

    async1();

    new Promise((resolve) => {
    console.log('promise1');
    resolve();
    }).then(() => {
    console.log('promise2');
    });

    console.log('script end');

    解析:

  • script start
  • setTimeout回调加入消息队列
  • 执行async1:
    • async1 start
    • 执行async2:
      • async2
      • async2的await转换为Promise.then,回调加入微任务队列
      • async2返回Promise
    • async1的await转换为Promise.then,回调加入微任务队列
  • 执行Promise构造函数:promise1
  • Promise.then回调加入微任务队列
  • script end
  • 微任务队列:[async2的then, async1的then, promise2的then]
  • 按顺序执行:async2 end → async1 end → promise2
  • 执行宏任务:setTimeout
  • 代码执行完成,最终输出结果:script start –> async1 start –> async2 –> promise1 –> script end –> async2 end –> async1 end –> promise2 –> setTimeout 。
  • 思考题:以下代码的输出顺序是什么?

    console.log('Main start');

    setTimeout(() => {
    console.log('Timeout 1');
    Promise.resolve().then(() => console.log('Promise in Timeout 1'));
    }, 0);

    Promise.resolve().then(() => {
    console.log('Promise 1');
    setTimeout(() => {
    console.log('Timeout in Promise 1');
    queueMicrotask(() => console.log('Micro in Timeout in Promise'));
    }, 0);
    });

    queueMicrotask(() => {
    console.log('Microtask 1');
    Promise.resolve().then(() => console.log('Promise in Microtask'));
    });

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

    console.log('Main end');

    结语

    JavaScript 虽然是单线程,但通过 调用栈 + 消息队列 + 微任务队列 + 事件循环 的巧妙设计,实现了高效的异步处理能力,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

    赞(0)
    未经允许不得转载:171主机测评 » JavaScript事件循环(上) - 单线程的异步魔法
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址