一篇文章带你深入了解什么是事件循环、调用栈、任务队列

193 阅读6分钟

JavaScript 是一种单线程的编程语言,这意味着它一次只能执行一个任务。然而,现代的 JavaScript 运行环境支持异步操作,例如网络请求、定时器等,这些异步操作通过事件循环来管理和调度。了解事件循环的工作原理是掌握 JavaScript 异步编程的关键。本篇博客将详细讲解事件循环及其相关概念,帮助你全面了解这一重要机制。

1. JavaScript 的单线程模型

JavaScript 在浏览器和 Node.js 中都是单线程执行的。这意味着在任何给定的时间,只有一个任务在执行。然而,通过异步编程,我们可以在不阻塞主线程的情况下处理 I/O 操作、定时器和用户交互。 在 JavaScript 中,任务主要分为两类:同步任务和异步任务。异步任务又可以进一步分为宏任务(Macro Tasks)和微任务(Micro Tasks)。这些任务分类对事件循环的行为有着重要的影响。

2. 调用栈(Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用于跟踪函数的执行。当你调用一个函数时,该函数被压入调用栈。当函数执行完毕后,它会从调用栈中弹出。

示例:

function foo() {
    console.log('foo');
}

function bar() {
    foo();
    console.log('bar');
}

bar();
console.log('baz');

执行顺序:

  1. bar 被调用,压入调用栈。
  2. bar 调用 foofoo 压入调用栈。
  3. foo 执行完毕,弹出调用栈。
  4. bar 继续执行,执行完毕后弹出调用栈。
  5. 最后执行 console.log('baz')

3. 任务队列(Task Queue)

任务队列是一个用于存放待执行任务的先进先出(FIFO)数据结构。当调用栈为空时,事件循环从任务队列中取出任务执行。任务队列中的任务分为宏任务和微任务。

4. 宏任务(Macro Tasks)与微任务(Micro Tasks)

宏任务(Macro Tasks)

宏任务包括以下内容:

  • setTimeout
  • setInterval
  • setImmediate(Node.js)
  • I/O 操作
  • UI 渲染(浏览器)

这些任务被添加到宏任务队列中,等待调用栈为空时执行。

微任务(Micro Tasks)

微任务包括以下内容:

  • Promise.then.catch 回调
  • MutationObserver
  • queueMicrotask

微任务被添加到微任务队列中,具有比宏任务更高的优先级。

5. 事件循环(Event Loop)

事件循环是 JavaScript 的核心机制,用于协调调用栈、宏任务队列和微任务队列的执行。事件循环的每一个周期称为一个 tick。在每个 tick 中,事件循环会按以下步骤执行:

  1. 执行调用栈中的所有同步任务,直到调用栈为空。
  2. 执行微任务队列中的所有任务,直到微任务队列为空。
  3. 执行一个宏任务(从宏任务队列中取出第一个任务)。

6. 执行顺序详解

示例 1:

console.log('Start');

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

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

console.log('End');

执行顺序:

  1. console.log('Start')console.log('End') 是同步任务,立即执行。
  2. setTimeout 回调被添加到宏任务队列。
  3. Promise.then 回调被添加到微任务队列。
  4. 调用栈为空,事件循环执行微任务队列中的回调,输出 Promise
  5. 微任务队列为空,事件循环执行宏任务队列中的回调,输出 setTimeout

输出结果:

Start
End
Promise
setTimeout

示例 2:

console.log('Start');

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

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

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

console.log('End');

执行顺序:

  1. console.log('Start')console.log('End') 是同步任务,立即执行。

  2. 两个 setTimeout 回调被添加到宏任务队列。

  3. Promise1Promise2 的回调被添加到微任务队列。

  4. 调用栈为空,事件循环执行微任务队列中的回调:

    • 输出 Promise1
    • 输出 Promise2
  5. 微任务队列为空,事件循环执行宏任务队列中的回调:

    • 输出 setTimeout1
    • Promise inside setTimeout1 被添加到微任务队列
    • 输出 setTimeout2
  6. 调用栈为空,事件循环执行微任务队列中的回调:

    • 输出 Promise inside setTimeout1

输出结果:

Start
End
Promise1
Promise2
setTimeout1
setTimeout2
Promise inside setTimeout1

7. 示例分析

通过以上示例,我们可以看出事件循环是如何协调同步任务、宏任务和微任务的执行顺序的。了解这一点对于编写高效的异步代码至关重要。

8. 深入探讨:微任务队列的细节

微任务队列是一个优先级非常高的队列。在每个宏任务执行完毕后,事件循环会立即执行所有微任务队列中的任务,直到微任务队列为空。这意味着在某些情况下,微任务队列中的任务可能会一直阻塞后续的宏任务。

示例:

setTimeout(() => {
    console.log('Macro Task 1');
    Promise.resolve().then(() => {
        console.log('Micro Task 1');
    }).then(() => {
        console.log('Micro Task 2');
    }).then(() => {
        console.log('Micro Task 3');
    });
}, 0);

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

执行顺序:

  1. setTimeout 回调被添加到宏任务队列。

  2. 第一个宏任务执行,输出 Macro Task 1

  3. Promise 回调被添加到微任务队列。

  4. 执行微任务队列中的任务:

    • 输出 Micro Task 1
    • 输出 Micro Task 2
    • 输出 Micro Task 3
  5. 微任务队列为空,事件循环执行下一个宏任务,输出 Macro Task 2

输出结果:

Macro Task 1
Micro Task 1
Micro Task 2
Micro Task 3
Macro Task 2

9. 常见问题与解答

问:为什么微任务优先于宏任务? 答:微任务通常用于处理较小的任务,确保快速响应和高效执行。优先处理微任务有助于保持应用程序的高性能。

问:什么是 queueMicrotask 答:queueMicrotask 是一种将任务添加到微任务队列的方法,用于在当前执行上下文结束后立即执行短小的任务。

问:如何调试事件循环中的任务执行顺序? 答:可以使用 console.log 语句打印消息,结合浏览器的开发者工具(如 Chrome DevTools),查看任务的执行顺序。

10. 实践技巧与最佳实践

使用 async/await 简化异步代码

async/await 是基于 Promise 的语法糖,可以使异步代码看起来像同步代码,简化代码逻辑。

async function fetchData() {
    console.log('Start fetching');
    const data = await fetch('https://api.example.com/data');
    console.log('Data fetched:', data);
    console.log('End fetching');
}

fetchData();

避免长时间运行的同步任务

长时间运行的同步任务会阻塞主线程,导致用户界面卡顿。应将这些任务拆分为较小的任务,或使用 Web Workers 在后台线程中执行。

function heavyTask() {
    // 分块处理大任务
    for (let i = 0; i < 1e9; i++) {
        if (i % 1e7 === 0) {
            console.log(i);
        }
    }
}

// 使用 setTimeout 将任务分成多个小块
function chunkedHeavyTask() {
    let i = 0;

    function processChunk() {
        while (i < 1e9 && i % 1e7 !== 0) {
            i++;
        }

        if (i < 1e9) {
            i++;
            setTimeout(processChunk, 0);
        }
    }

    processChunk();
}

chunkedHeavyTask();

利用 requestAnimationFrame 优化动画

在执行动画相关的任务时,使用 requestAnimationFrame 可以确保在下一次浏览器重绘前执行,提高性能。

function animate() {
    // 更新动画状态
    console.log('Animating frame');
    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

11. 结论

JavaScript 的事件循环机制是理解异步编程的基础。通过本文,我们详细探讨了调用栈、宏任务、微任务及其执行顺序。掌握这些知识可以帮助我们编写高效、响应迅速的 JavaScript 代码。希望这篇博客能帮助你深入理解事件循环,并在实际编程中得心应手。