WinddSnow

JavaScript-Event-Loop-and-Asynchronous-Programming

字数统计: 2.8k阅读时长: 11 min
2026/07/30

第62课:事件循环与异步编程——调用栈、任务队列、宏任务/微任务、async/await

JavaScript 是单线程语言,同一时间只能执行一段代码。然而 Web 应用中充斥着耗时操作:网络请求、定时器、用户交互。如果这些操作以同步方式阻塞主线程,页面将完全冻结。JavaScript 通过事件循环(Event Loop)异步编程模型解决了这一矛盾:将耗时任务委托给浏览器或 Node.js 的底层线程处理,主线程继续执行后续代码,待异步操作完成后通过回调将结果重新注入主线程。理解事件循环的运转机制——调用栈、宏任务队列、微任务队列以及它们之间的优先级关系——是掌握 setTimeout 的精度问题、Promise 的执行时机、async/await 的本质以及避免常见异步陷阱的钥匙。


1. JavaScript 的单线程与调用栈

JavaScript 引擎(如 V8)维护一个调用栈(Call Stack),用于跟踪当前正在执行的函数。函数被调用时压入栈顶,执行完毕弹出。栈空时,主线程空闲。

1
2
3
4
5
6
7
8
9
10
11
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(5);

调用栈的推入/弹出顺序:printSquaresquaremultiplymultiply 返回弹出 → square 返回弹出 → console.log 压入弹出 → printSquare 弹出。

当调用栈中有任务时,浏览器无法处理用户输入或渲染页面。耗时同步操作(如大量循环、递归调用)会直接阻塞主线程,导致页面“卡死”。


2. 事件循环与任务队列

为了不阻塞主线程,异步操作(setTimeoutfetch、事件监听等)的回调不会立即执行,而是被推入任务队列(Task Queue) 中。当调用栈为空时,事件循环从任务队列中取出第一个任务压入调用栈执行。这一过程不断重复,构成事件循环。

2.1 宏任务(MacroTask)与微任务(MicroTask)

任务队列实际上分为两种,具有不同的优先级:

类型 常见来源 执行时机
宏任务 setTimeoutsetInterval、I/O 操作、UI 渲染、setImmediate(Node) 每次事件循环从宏任务队列中取出一个任务执行。
微任务 Promise.then/catch/finallyMutationObserverqueueMicrotaskawait 之后的代码 当前宏任务执行完毕后、下一个宏任务开始前,清空整个微任务队列。

执行模型

  1. 从宏任务队列取出一个任务执行(初始脚本整体视为一个宏任务)。
  2. 该宏任务执行过程中产生的微任务依次进入微任务队列。
  3. 宏任务执行完毕,清空微任务队列(包括执行过程中新产生的微任务)。
  4. 如果需要,浏览器进行 UI 渲染。
  5. 重复步骤 1。
1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('1'); // 同步代码,立即执行

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

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

console.log('4'); // 同步代码

// 输出顺序:1, 4, 3, 2

解释

  • 同步代码输出 14
  • setTimeout 回调进入宏任务队列,Promise.then 回调进入微任务队列。
  • 当前宏任务(整体脚本)结束,清空微任务队列:输出 3
  • 从宏任务队列取出 setTimeout 回调,输出 2

2.2 微任务的嵌套

在微任务执行过程中,如果又有新的微任务被添加(例如在 then 回调中再次调用 then),它们会在当前微任务队列清空之前被执行。这意味着微任务可能“无限”嵌套而导致宏任务被长时间阻塞。

1
2
3
4
5
Promise.resolve().then(function handler() {
console.log('微任务');
Promise.resolve().then(handler); // 递归添加微任务
});
// 此代码会无限输出 '微任务',永远不会执行后续宏任务

2.3 setTimeout 的延迟并非精确

setTimeout(fn, delay) 表示 fn 在至少 delay 毫秒后才会被推入宏任务队列。如果当前主线程或微任务队列繁忙,实际延迟会大于 delaydelay 为 0 时,并不是立即执行,而是尽快(至少 4ms 的浏览器嵌套限制)。

1
2
3
4
console.time('timeout');
setTimeout(() => {
console.timeEnd('timeout'); // 通常 > 4ms,即使指定 0
}, 0);

3. async/await 的执行顺序

async 函数本质上是返回 Promise 的语法糖。await 会暂停当前 async 函数的执行,等待右侧 Promise 状态变更。**await 之后的代码可视为被包裹在 .then() 中,进入微任务队列**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function foo() {
console.log('2');
await bar();
console.log('4'); // 相当于 bar().then(() => console.log('4'))
}

async function bar() {
console.log('3');
return 'result';
}

console.log('1');
foo();
console.log('5');

// 输出顺序:1, 2, 3, 5, 4

解释

  • console.log('1') 同步输出。
  • foo() 执行,同步输出 '2'
  • bar() 执行,同步输出 '3',返回 resolved Promise。
  • awaitconsole.log('4') 放入微任务队列。
  • 同步代码继续,输出 '5'
  • 当前宏任务结束,清空微任务队列,输出 '4'

如果 await 右侧是一个已经 resolved 的 Promise(或非 Promise 值),则 await 后面的代码仍然会作为微任务延迟执行,不会立即同步运行。


4. 综合案例分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}

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

console.log('script start');

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

async1();

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

console.log('script end');

输出顺序:

1
2
3
4
5
6
7
8
script start
async1 start
async2
promise executor
script end
async1 end
promise then
setTimeout

分析

  • 同步代码:script startasync1 startasync2promise executorscript end
  • 微任务队列(按添加顺序):async1 end(来自 await),promise then。执行顺序:async1 endpromise then
  • 宏任务队列:setTimeout 回调,最后执行。

5. Node.js 的事件循环阶段

Node.js 的事件循环基于 libuv,分为多个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks)。其中 process.nextTick 拥有比 Promise 微任务更高的优先级。在浏览器环境中仅需关注宏任务/微任务,Node.js 中则需留意 nextTick 的插入位置。


课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 以下关于微任务的说法,正确的是?
    A. setTimeout 的回调属于微任务。
    B. 微任务在当前宏任务执行完毕后、下一个宏任务之前清空。
    C. 微任务队列只能存放一个任务。
    D. UI 渲染发生在微任务之前。

  2. (单选) await 后面的代码会被如何调度?
    A. 作为同步代码立即执行。
    B. 作为宏任务执行。
    C. 作为微任务执行(相当于 .then() 中的回调)。
    D. 放入调用栈顶部优先执行。

  3. (填空) 宏任务队列中,每次事件循环只取出 ______ 个任务执行。

  4. (多选) 以下哪些属于微任务来源?
    A. Promise.resolve().then(callback)
    B. queueMicrotask(callback)
    C. MutationObserver 回调
    D. setInterval 回调

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要编写一个 JavaScript 函数 executeInOrder(tasks),它接收一个异步任务数组,每个任务是一个返回 Promise 的函数。要求按数组顺序串行执行这些任务,前一个完成后再执行下一个,最终返回一个包含所有任务结果的数组。实现时要求使用 async/await 和循环,不能使用 forEach(因为 forEach 会并行启动所有任务)。并且编写一段测试代码,使用 setTimeout 模拟异步延迟,验证执行顺序。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 JavaScript 代码。提示词中需明确指定串行执行逻辑、await 的使用、以及如何验证顺序。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 JavaScript 文件 serialTasks.js,实现一个函数 executeInOrder(tasks),它接收一个函数数组,每个函数返回 Promise。要求:

  • 使用 async function executeInOrder(tasks) {} 声明,返回一个 Promise,解析为所有任务结果的数组。
  • 内部使用 for...of 循环遍历 tasks,对每个任务使用 const result = await task() 等待完成,将结果推入数组。
  • 不能使用 Array.prototype.forEach,因为它不会等待异步任务完成。
  • 在文件末尾添加测试代码:创建三个模拟异步任务的函数,分别用 setTimeout 延时 100ms、50ms、200ms 后 resolve 字符串 'Task 1''Task 2''Task 3'。调用 executeInOrder 并打印结果,验证顺序。
  • 使用 ESM 语法(export 导出函数),文件添加 "use strict" 和 JSDoc 注释。
  • 代码可直接在 Node.js 环境运行。完成后输出完整文件内容。

四、面试真题与参考答案

题目(字节跳动前端面试题):

请详细解释浏览器事件循环中宏任务和微任务的区别及执行顺序。给出下列代码的输出顺序并说明原因。

1
2
3
4
5
6
7
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => {
console.log('3');
setTimeout(() => console.log('4'), 0);
});
console.log('5');

参考答案

  1. 宏任务与微任务的区别

    • 宏任务由宿主环境发起(setTimeout、I/O、UI 渲染),每次事件循环仅处理一个宏任务。
    • 微任务由 JavaScript 自身发起(Promise.thenMutationObserver),在当前宏任务执行完毕后,同步清空所有微任务,包括微任务中产生的新微任务。
  2. 输出顺序15324

    • 同步代码输出 15
    • setTimeout(2) 进入宏任务队列。
    • Promise.then 回调(输出 3)进入微任务队列。
    • 当前宏任务结束,清空微任务队列:输出 3。在微任务中,setTimeout(4) 被推入宏任务队列(排在已有的 2 之后)。
    • 开始下一轮事件循环:取出第一个宏任务输出 2,再下一轮取出 4 输出。

课后练习答案

一、概念自测答案

  1. B

    • 解析:A 错误,setTimeout 是宏任务;C 错误,微任务队列可存放多个任务;D 错误,UI 渲染通常发生在微任务之后。
  2. C

    • 解析:await 后续代码相当于 .then() 回调,被放入微任务队列。
  3. ****(或 1

    • 解析:每次事件循环从宏任务队列取出一个任务执行。
  4. A、B、C

    • 解析:Promise.thenqueueMicrotaskMutationObserver 均为微任务。setInterval 是宏任务。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请编写一个 JavaScript 函数 executeInOrder(tasks),串行执行异步任务数组。要求:

  • 使用 async/awaitfor...of 循环,等待每个任务完成后再执行下一个。
  • 返回一个 Promise,解析为所有任务结果的数组。
  • 任务函数不接受参数,返回 Promise。
  • 编写测试:创建三个函数,分别延时 100ms、50ms、200ms 后 resolve 字符串,验证执行顺序。
  • 使用 const 声明函数,添加 JSDoc 注释。输出完整代码。”
CATALOG
  1. 1. 第62课:事件循环与异步编程——调用栈、任务队列、宏任务/微任务、async/await
    1. 1.1. 1. JavaScript 的单线程与调用栈
    2. 1.2. 2. 事件循环与任务队列
      1. 1.2.1. 2.1 宏任务(MacroTask)与微任务(MicroTask)
      2. 1.2.2. 2.2 微任务的嵌套
      3. 1.2.3. 2.3 setTimeout 的延迟并非精确
    3. 1.3. 3. async/await 的执行顺序
    4. 1.4. 4. 综合案例分析
    5. 1.5. 5. Node.js 的事件循环阶段
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、Agent 模式下的提示词示例
      4. 1.6.4. 四、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)