深入理解 JavaScript 的 Event Loop 事件循环机制

535 阅读7分钟

理解 JavaScript 的事件循环机制

前言

JavaScript 是一种单线程的编程语言,这意味着它一次只能执行一个任务。然而,现代 Web 应用程序需要处理大量的异步操作,如网络请求、定时器和用户交互。为了高效地管理这些异步操作,JavaScript 引入了事件循环机制。本文将深入探讨 JavaScript 的事件循环机制,并通过多个案例帮助你更好地理解这一概念。

JavaScript 线程模型

单线程模型

JavaScript 是单线程的,这意味着它一次只能执行一个任务。单线程模型的优点是避免了多线程编程中的复杂性,如竞争条件和死锁。然而,这也意味着 JavaScript 需要一种机制来处理异步操作,而不会阻塞主线程的执行。

console.log('任务1');
console.log('任务2');
console.log('任务3');

在单线程模型中,任务会按顺序执行,依次输出 任务1任务2任务3

执行顺序

JavaScript 的执行顺序主要由以下几个部分组成:

  1. 调用栈(Call Stack):用于存储函数调用。
  2. 任务队列(Task Queue):用于存储待执行的任务。
  3. 事件循环(Event Loop):负责协调调用栈和任务队列之间的执行顺序。
function first() {
  console.log('first');
}

function second() {
  first();
  console.log('second');
}

second();
console.log('third');

执行顺序为 second 调用 first,然后输出 first,接着输出 secondthird

调用栈和堆

调用栈是一个 LIFO(后进先出)结构,用于存储函数调用。当一个函数被调用时,它会被添加到调用栈顶部,当函数执行完毕后,它会从调用栈顶部移除。

堆是用于动态分配内存的区域,用于存储对象和函数。

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

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

bar();

在上述代码中,调用栈的执行顺序为 bar 调用 foo,然后输出 foobar

const obj = { name: '堆内存' };

function foo() {
  const bar = '栈内存';
  console.log(bar);
}

foo();

对象 obj 存储在堆内存中,变量 bar 存储在栈内存中。

任务队列

任务队列是一个 FIFO(先进先出)结构,用于存储异步任务的回调函数。任务队列分为宏任务队列和微任务队列。

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

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

console.log('同步任务');

执行顺序为 同步任务,然后是 微任务,最后是 宏任务

宏任务和微任务

  • 宏任务(Macro Task):包括 setTimeoutsetIntervalsetImmediate、I/O 操作、UI 渲染等。
  • 微任务(Micro Task):包括 Promise.thenMutationObserverprocess.nextTick(Node.js)等。
setTimeout(() => {
  console.log('宏任务');
}, 0);

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

console.log('同步任务');

执行顺序为 同步任务,然后是 微任务,最后是 宏任务

同步任务和异步任务

  • 同步任务:立即执行的任务,会阻塞后续任务的执行。
  • 异步任务:不会立即执行的任务,会在将来的某个时间点执行,不会阻塞后续任务的执行。
console.log('同步任务');

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

执行顺序为 同步任务,然后是 异步任务

事件循环的工作流程

事件循环的工作流程如下:

  1. 执行调用栈中的任务:事件循环首先会检查调用栈,如果调用栈不为空,则执行调用栈中的任务,直到调用栈为空。
  2. 执行微任务队列中的任务:当调用栈为空时,事件循环会检查微任务队列,并依次执行微任务队列中的所有任务,直到微任务队列为空。
  3. 执行宏任务队列中的任务:当微任务队列为空时,事件循环会从宏任务队列中取出一个任务,并将其添加到调用栈中执行。
  4. 重复上述步骤:事件循环会不断重复上述步骤,直到所有任务都被执行完毕。

Snipaste_2025-02-13_14-59-35.png

console.log('script start');

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

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

console.log('script end');

执行顺序为 script startscript endpromise1promise2setTimeout

案例分析

案例一:基本事件循环

console.log('script start');

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

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

console.log('script end');

执行顺序

  1. console.log('script start') 被添加到调用栈并执行,输出 script start
  2. setTimeout 被添加到宏任务队列。
  3. Promise.resolve().then 被添加到微任务队列。
  4. console.log('script end') 被添加到调用栈并执行,输出 script end
  5. 调用栈为空,检查微任务队列,执行 promise1,输出 promise1
  6. promise2 被添加到微任务队列。
  7. 执行 promise2,输出 promise2
  8. 微任务队列为空,检查宏任务队列,执行 setTimeout 回调,输出 setTimeout

最终输出顺序为:

script start
script end
promise1
promise2
setTimeout

案例二:asyncawait

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

async function async2() {
  console.log('async2');
}

console.log('script start');

async1();

console.log('script end');

执行顺序

  1. console.log('script start') 被添加到调用栈并执行,输出 script start
  2. async1 被调用,输出 async1 start
  3. await async2() 被执行,async2 被调用,输出 async2
  4. await 使得 async1 暂停执行,async1 end 被添加到微任务队列。
  5. console.log('script end') 被添加到调用栈并执行,输出 script end
  6. 调用栈为空,检查微任务队列,执行 async1 end,输出 async1 end

最终输出顺序为:

script start
async1 start
async2
script end
async1 end

案例三:PromisesetTimeout

console.log('script start');

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

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

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

console.log('script end');

执行顺序

  1. console.log('script start') 被添加到调用栈并执行,输出 script start
  2. 第一个 setTimeout 被添加到宏任务队列。
  3. Promise.resolve().then 被添加到微任务队列。
  4. 第二个 setTimeout 被添加到宏任务队列。
  5. console.log('script end') 被添加到调用栈并执行,输出 script end
  6. 调用栈为空,检查微任务队列,执行 promise1,输出 promise1
  7. promise2 被添加到微任务队列。
  8. 执行 promise2,输出 promise2
  9. 微任务队列为空,检查宏任务队列,执行第一个 setTimeout 回调,输出 setTimeout
  10. 执行第二个 setTimeout 回调,输出 setTimeout2

最终输出顺序为:

script start
script end
promise1
promise2
setTimeout
setTimeout2

案例四:综合案例

console.log('script start');

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

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

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

async function async2() {
  console.log('async2');
}

async1();

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

console.log('script end');

执行顺序

  1. console.log('script start') 被添加到调用栈并执行,输出 script start
  2. 第一个 setTimeout 被添加到宏任务队列。
  3. Promise.resolve().then 被添加到微任务队列。
  4. async1 被调用,输出 async1 start
  5. await async2() 被执行,async2 被调用,输出 async2
  6. await 使得 async1 暂停执行,async1 end 被添加到微任务队列。
  7. 第二个 setTimeout 被添加到宏任务队列。
  8. console.log('script end') 被添加到调用栈并执行,输出 script end
  9. 调用栈为空,检查微任务队列,执行 promise1,输出 promise1
  10. promise2 被添加到微任务队列。
  11. 执行 promise2,输出 promise2
  12. 执行 async1 end,输出 async1 end
  13. 微任务队列为空,检查宏任务队列,执行第一个 setTimeout 回调,输出 setTimeout1
  14. 执行第二个 setTimeout 回调,输出 setTimeout2

最终输出顺序为:

script start
async1 start
async2
script end
promise1
promise2
async1 end
setTimeout1
setTimeout2

通过这个综合案例,我们可以看到事件循环如何协调同步任务、微任务和宏任务的执行顺序。理解这个案例将帮助你完全掌握 JavaScript 的事件循环机制。

结论

通过理解 JavaScript 的事件循环机制,我们可以更好地掌握异步编程的执行顺序和行为。事件循环协调了调用栈和任务队列之间的执行顺序,确保异步任务能够在适当的时间点执行。希望本文通过详细的解释和案例分析,能够帮助你更好地理解 JavaScript 的事件循环机制。


希望这篇文章对你有所帮助!如果有任何问题或建议,欢迎留言讨论。

找到具有 2 个许可证类型的类似代码