第62课:事件循环与异步编程——调用栈、任务队列、宏任务/微任务、async/await
JavaScript 是单线程语言,同一时间只能执行一段代码。然而 Web 应用中充斥着耗时操作:网络请求、定时器、用户交互。如果这些操作以同步方式阻塞主线程,页面将完全冻结。JavaScript 通过事件循环(Event Loop) 和异步编程模型解决了这一矛盾:将耗时任务委托给浏览器或 Node.js 的底层线程处理,主线程继续执行后续代码,待异步操作完成后通过回调将结果重新注入主线程。理解事件循环的运转机制——调用栈、宏任务队列、微任务队列以及它们之间的优先级关系——是掌握 setTimeout 的精度问题、Promise 的执行时机、async/await 的本质以及避免常见异步陷阱的钥匙。
1. JavaScript 的单线程与调用栈
JavaScript 引擎(如 V8)维护一个调用栈(Call Stack),用于跟踪当前正在执行的函数。函数被调用时压入栈顶,执行完毕弹出。栈空时,主线程空闲。
1 | function multiply(a, b) { |
调用栈的推入/弹出顺序:printSquare → square → multiply → multiply 返回弹出 → square 返回弹出 → console.log 压入弹出 → printSquare 弹出。
当调用栈中有任务时,浏览器无法处理用户输入或渲染页面。耗时同步操作(如大量循环、递归调用)会直接阻塞主线程,导致页面“卡死”。
2. 事件循环与任务队列
为了不阻塞主线程,异步操作(setTimeout、fetch、事件监听等)的回调不会立即执行,而是被推入任务队列(Task Queue) 中。当调用栈为空时,事件循环从任务队列中取出第一个任务压入调用栈执行。这一过程不断重复,构成事件循环。
2.1 宏任务(MacroTask)与微任务(MicroTask)
任务队列实际上分为两种,具有不同的优先级:
| 类型 | 常见来源 | 执行时机 |
|---|---|---|
| 宏任务 | setTimeout、setInterval、I/O 操作、UI 渲染、setImmediate(Node) |
每次事件循环从宏任务队列中取出一个任务执行。 |
| 微任务 | Promise.then/catch/finally、MutationObserver、queueMicrotask、await 之后的代码 |
在当前宏任务执行完毕后、下一个宏任务开始前,清空整个微任务队列。 |
执行模型:
- 从宏任务队列取出一个任务执行(初始脚本整体视为一个宏任务)。
- 该宏任务执行过程中产生的微任务依次进入微任务队列。
- 宏任务执行完毕,清空微任务队列(包括执行过程中新产生的微任务)。
- 如果需要,浏览器进行 UI 渲染。
- 重复步骤 1。
1 | console.log('1'); // 同步代码,立即执行 |
解释:
- 同步代码输出
1和4。 setTimeout回调进入宏任务队列,Promise.then回调进入微任务队列。- 当前宏任务(整体脚本)结束,清空微任务队列:输出
3。 - 从宏任务队列取出
setTimeout回调,输出2。
2.2 微任务的嵌套
在微任务执行过程中,如果又有新的微任务被添加(例如在 then 回调中再次调用 then),它们会在当前微任务队列清空之前被执行。这意味着微任务可能“无限”嵌套而导致宏任务被长时间阻塞。
1 | Promise.resolve().then(function handler() { |
2.3 setTimeout 的延迟并非精确
setTimeout(fn, delay) 表示 fn 在至少 delay 毫秒后才会被推入宏任务队列。如果当前主线程或微任务队列繁忙,实际延迟会大于 delay。delay 为 0 时,并不是立即执行,而是尽快(至少 4ms 的浏览器嵌套限制)。
1 | console.time('timeout'); |
3. async/await 的执行顺序
async 函数本质上是返回 Promise 的语法糖。await 会暂停当前 async 函数的执行,等待右侧 Promise 状态变更。**await 之后的代码可视为被包裹在 .then() 中,进入微任务队列**。
1 | async function foo() { |
解释:
console.log('1')同步输出。foo()执行,同步输出'2'。bar()执行,同步输出'3',返回 resolved Promise。await将console.log('4')放入微任务队列。- 同步代码继续,输出
'5'。 - 当前宏任务结束,清空微任务队列,输出
'4'。
如果 await 右侧是一个已经 resolved 的 Promise(或非 Promise 值),则 await 后面的代码仍然会作为微任务延迟执行,不会立即同步运行。
4. 综合案例分析
1 | async function async1() { |
输出顺序:
1 | script start |
分析:
- 同步代码:
script start→async1 start→async2→promise executor→script end - 微任务队列(按添加顺序):
async1 end(来自await),promise then。执行顺序:async1 end→promise then - 宏任务队列:
setTimeout回调,最后执行。
5. Node.js 的事件循环阶段
Node.js 的事件循环基于 libuv,分为多个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks)。其中 process.nextTick 拥有比 Promise 微任务更高的优先级。在浏览器环境中仅需关注宏任务/微任务,Node.js 中则需留意 nextTick 的插入位置。
课后练习
一、概念自测(选择题 / 填空题)
(单选) 以下关于微任务的说法,正确的是?
A.setTimeout的回调属于微任务。
B. 微任务在当前宏任务执行完毕后、下一个宏任务之前清空。
C. 微任务队列只能存放一个任务。
D. UI 渲染发生在微任务之前。(单选)
await后面的代码会被如何调度?
A. 作为同步代码立即执行。
B. 作为宏任务执行。
C. 作为微任务执行(相当于.then()中的回调)。
D. 放入调用栈顶部优先执行。(填空) 宏任务队列中,每次事件循环只取出
______个任务执行。(多选) 以下哪些属于微任务来源?
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');
参考答案:
宏任务与微任务的区别:
- 宏任务由宿主环境发起(
setTimeout、I/O、UI 渲染),每次事件循环仅处理一个宏任务。 - 微任务由 JavaScript 自身发起(
Promise.then、MutationObserver),在当前宏任务执行完毕后,同步清空所有微任务,包括微任务中产生的新微任务。
- 宏任务由宿主环境发起(
输出顺序:
1、5、3、2、4。- 同步代码输出
1、5。 setTimeout(2)进入宏任务队列。Promise.then回调(输出3)进入微任务队列。- 当前宏任务结束,清空微任务队列:输出
3。在微任务中,setTimeout(4)被推入宏任务队列(排在已有的2之后)。 - 开始下一轮事件循环:取出第一个宏任务输出
2,再下一轮取出4输出。
- 同步代码输出
课后练习答案
一、概念自测答案
B
- 解析:A 错误,
setTimeout是宏任务;C 错误,微任务队列可存放多个任务;D 错误,UI 渲染通常发生在微任务之后。
- 解析:A 错误,
C
- 解析:
await后续代码相当于.then()回调,被放入微任务队列。
- 解析:
**
一**(或1)- 解析:每次事件循环从宏任务队列取出一个任务执行。
A、B、C
- 解析:
Promise.then、queueMicrotask、MutationObserver均为微任务。setInterval是宏任务。
- 解析:
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请编写一个 JavaScript 函数executeInOrder(tasks),串行执行异步任务数组。要求:
- 使用
async/await和for...of循环,等待每个任务完成后再执行下一个。- 返回一个 Promise,解析为所有任务结果的数组。
- 任务函数不接受参数,返回 Promise。
- 编写测试:创建三个函数,分别延时 100ms、50ms、200ms 后 resolve 字符串,验证执行顺序。
- 使用
const声明函数,添加 JSDoc 注释。输出完整代码。”