第七章: 异步 JavaScript

第七章: 异步 JavaScript

摘要: JavaScript 的核心特性之一是单线程,这意味着在任意时刻,它只能执行一件任务。这个特性简化了编程模型,但也带来了巨大的挑战:如果一个任务耗时过长(如网络请求),整个程序(包括用户界面)都将被阻塞。本章,我们将深入 JavaScript 为解决这一根本矛盾而设计的核心机制——异步编程。我们将从一个真实的“UI 阻塞”痛点出发,沿着“定时器与回调 -> Promise -> async/await”的技术演进路径,最终回归到底层核心“事件循环”,让您彻底掌握现代 JavaScript 的异步编程范式。


在本章,我们将遵循一条从实践到理论的探索之路:

  1. 首先,我们将直面 JavaScript 的单线程阻塞问题,理解异步的必要性。
  2. 接着,我们将学习最初的异步工具 setTimeout 与回调函数,并体会“回调地狱”的痛苦。
  3. 然后,我们将深入学习现代异步核心 Promise,包括其链式调用和所有重要的静态并发方法
  4. 紧接着,我们将掌握终极语法糖 async/await,学习如何编写优雅的异步代码。
  5. 最后,在掌握了所有工具后,我们将揭秘事件循环的底层模型,理解这一切是如何工作的。

7.1. 问题所在:JavaScript 的单线程与 UI 阻塞

核心痛点: JavaScript 在浏览器中执行时,JS 引擎与页面的渲染引擎共享同一个主线程。如果 JS 执行一个长时间的同步任务,主线程就会被“霸占”,导致页面渲染完全停止。

场景复现: 想象一下,页面上有一个按钮,点击后需要执行一个非常耗时的计算。

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
27
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button onclick="handleClick()">Click me</button>
<p id="output"></p>
</body>
<script>
function handleClick() {
const output = document.getElementById('output');
output.textContent = 'Calculating...';
const start = Date.now()
while (Date.now() - start < 3000) {
// 阻塞三秒
}

output.textContent = 'Button clicked!';
}
</script>

</html>

实际体验: 当你点击按钮后,会发现页面立即卡死'Calculating...' 这段文本根本不会显示,整个浏览器窗口在 3 秒内无响应。3 秒后,页面恢复,并直接显示 'Calculation finished!'。这就是 UI 阻塞


7.2. 最初的解决方案:异步回调与定时器

解决方案: 我们可以使用浏览器提供的 setTimeout(callback, delay) API,将耗时任务变成一个异步任务。这会告诉浏览器:“请把这个任务(callback)放到后台,等 delay 毫秒后,再把它放进任务队列里等待执行。” 这样,主线程就不会被阻塞了。

1
2
3
4
5
6
7
8
9
10
11
function handleClick() {
const output = document.getElementById('output');
output.textContent = 'Calculating...';
// 使用 setTimeout 将耗时任务变为异步
setTimeout(() => {
const start = Date.now();
while (Date.now() - start < 3000) {
// 现在不会阻塞主线程
}
output.textContent = 'Calculation finished!';
}, 0); // 0ms 延迟意味着“尽快”执行,但必须在当前同步代码之后

体验改善: 再次点击按钮,'Calculating...' 会立刻显示,页面保持流畅。3 秒后,文本更新为 'Calculation finished!'。我们成功避免了 UI 阻塞。

新的痛点:回调地狱
当异步任务之间存在依赖关系时,例如,任务 B 必须在任务 A 完成后才能开始,我们就不得不将回调函数层层嵌套。

1
2
3
4
5
6
7
8
9
setTimeout(() => {
console.log('Task A finished');
setTimeout(() => {
console.log('Task B finished');
setTimeout(() => {
console.log('Task C finished');
}, 1000);
}, 1000);
}, 1000);

这种“毁灭金字塔”结构,使得代码难以阅读、理解和维护,错误处理也变得异常复杂。


7.3. 现代解决方案 (一):Promise 对象

ES6 引入 Promise,正是为了将异步流程从“深坑”中解放出来,实现线性化的书写方式。Promise 是一个代表异步操作最终完成或失败的代理对象,它拥有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。

Promise 通过 .then().catch().finally()链式调用来组织异步流程。

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
27
28
29
30
31
32
33
34
35
36
function asyncTask(name, duration) {
// 返回一个Promise对象:他身上有两个参数,resolve和reject
// resolve:成功时调用
// reject:失败时调用
return new Promise((resolve, reject) => {
console.log(`任务 ${name} 开始...`);
setTimeout(() => {
// 开启一个定时器,内部判断任务名称如果为A则完成,否则则失败
if (name === 'A') {
resolve(`任务 ${name} 完成`);
} else {
reject(new Error(`任务 ${name} 失败`));
}
}, duration);
});
}

// 调用asyncTask函数,并传入任务名称和持续时间
// 使用.then方法链式调用,当任务A完成时,执行下一个任务B
// 使用.catch方法捕获错误,并打印错误信息
// 使用.finally方法在所有任务完成后执行

asyncTask('A', 1000)
.then(result => {
console.log(result); // 打印任务A完成
return asyncTask('B', 1000); // 立刻执行回调函数,执行B任务
})
.then(result => {
console.log(result); // 这里不会走,因为他已经失败了
})
.catch(error => {
console.error(error.message); // 打印任务B失败
})
.finally(() => {
console.log('所有任务完成');
});

7.4. 并发处理:Promise 的核心静态方法

当我们需要处理多个并发的异步任务时,Promise 提供了一组强大的静态方法。

Promise.all(iterable)

业务痛点: 页面初始化时,需要同时请求多个接口(例如,获取用户信息、获取产品列表),并且必须在所有数据都成功返回后才能渲染页面。

解决方案: Promise.all 接收一个 Promise 数组,返回一个新的 Promise。

  • 当所有 Promise 都 fulfilled,它才会 fulfilled,并且其结果是一个包含所有 Promise 结果的数组(顺序与输入一致)。
  • 只要有一个 Promise rejected,它就会立即 rejected,并且其原因是第一个失败的 Promise 的原因。
1
2
3
4
5
6
7
8
9
10
11
12
13
const p1 = Promise.resolve('User Info');
const p2 = new Promise(resolve => setTimeout(() => resolve('Product List'), 500));
const p3 = Promise.reject('API Error');

// 场景一: 全部成功
Promise.all([p1, p2]).then(results => {
console.log('全部成功:', results);
});

// 场景二: 有一个失败
Promise.all([p1, p2, p3]).catch(error => {
console.error('有一个失败:', error);
});

Promise.race(iterable)

业务痛点: 你需要为一个耗时可能很长的 API 请求设置一个超时限制。如果超过 2 秒还未返回,就视为失败。

解决方案: Promise.race 像一场赛跑,返回一个新的 Promise。这个 Promise 的状态会与第一个“撞线”(即第一个 settled,无论是 fulfilled 还是 rejected)的 Promise 的状态保持一致。

1
2
3
4
5
6
7
8
9
function timeout(delay) {
return new Promise((_, reject) => setTimeout(() => reject(new Error('请求超时')), delay));
}
function fetchData() {
return new Promise(resolve => setTimeout(() => resolve('数据接收成功'), 3000));
}
Promise.race([fetchData(), timeout(2000)])
.then(data => console.log(data))
.catch(error => console.error(error.message));

Promise.allSettled(iterable) & Promise.any(iterable)

  • Promise.allSettled: (我全都要) 等待所有 Promise 完成,无论成败,返回一个包含每个任务最终状态的对象数组。
    • 适用于需要知道所有结果的场景。
1
2
3
4
5
6
7
8
9
10
const promises = [Promise.resolve('成功1'), Promise.reject('失败2'), Promise.resolve('成功3')];
Promise.allSettled(promises).then(results => {
console.log(results);
// [
// { status: 'fulfilled', value: '成功1' },
// { status: 'rejected', reason: '失败2' },
// { status: 'fulfilled', value: '成功3' }
// ]
});

  • Promise.any: (谁快用谁,失败不管) 等待第一个 fulfilled 的 Promise。
    • 适用于有多个备用数据源,只需要最快的那一个的场景。
1
2
3
4
5
6
7
const p1 = new Promise(resolve => setTimeout(() => resolve('不会被执行'), 3000));
const p2 = new Promise((_, reject) => setTimeout(() => reject('请求超时'), 2000));
const p3 = new Promise(resolve => setTimeout(() => resolve('最后被执行的任务'), 300));

Promise.any([p1, p2, p3])
.then(results => console.log(results)) // 打印最后被执行的任务
.catch(error => console.error(error)); // 不会执行

7.5. 终极形态:async/await

async/awaitPromise 的语法糖,它允许我们用同步的、阻塞式的写法来处理异步的、非阻塞的逻辑,是目前处理异步的最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const p1 = Promise.resolve('User Info');
const p2 = new Promise(resolve => setTimeout(() => resolve('Product List'), 500));
const p3 = Promise.resolve('API Data');


async function main() {
try {
const [userInfo, productList, apiData] = await Promise.all([p1, p2, p3])
console.log(userInfo, productList, apiData) // User Info Product List API Data
} catch (error) {
console.error(error)
}
}

main()

7.6. 底层揭秘:事件循环模型

在掌握了所有异步工具后,现在是时候回头揭秘这一切是如何工作的了。所有异步行为的根源,都在于我们最开始介绍的事件循环模型。无论是 setTimeout 的回调,还是 Promise.then,最终都是被放入不同的任务队列,等待事件循环的调度。

  • setTimeout, setInterval, I/O 等 -> 宏任务
  • Promise.then, async/await 等 -> 微任务 (更高优先级)

核心痛点: 浏览器是事件驱动的,用户的点击、网络数据的到达、定时器的触发都是异步事件。单线程的 JavaScript 必须有一套高效的机制来处理这些事件而不阻塞 UI。这套机制就是事件循环 (Event Loop)

核心原理: 事件循环模型由几个关键部分组成:

  • 调用栈: 一个后进先出 (LIFO) 的数据结构,用于执行所有的同步任务。

  • Web APIs (浏览器环境): 浏览器提供的异步功能接口 (setTimeout, fetch() 等)。异步任务会交给它们处理。

  • 任务队列:

  • 宏任务队列: 一个先进先出 (FIFO) 的队列,存放 setTimeout, setInterval, I/O, UI rendering 等任务的回调。
    * 微任务队列: 一个拥有更高优先级的队列,主要存放 Promise 的回调(.then, .catch, .finally), queueMicrotask 等。

    • 事件循环: 一个持续不断的进程,其工作流程如下:
  1. 执行调用栈中的所有同步任务,直到栈空。
    2. 检查微任务队列清空整个队列,依次执行所有微任务。如果在执行微任务的过程中,又产生了新的微任务,那么这些新的微任务也会被添加到队列末尾并在当前轮次被执行。
    3. 取出一个宏任务从宏任务队列推入调用栈中执行。
    4. 重复步骤 2 和 3。

7.7. 本章核心原理与高频面试题

核心原理速查

概念核心原理关键点
异步将耗时任务交给宿主环境,通过回调函数处理结果,以避免阻塞主线程。是 JS 实现流畅 UI 的基础。
事件循环同步任务 -> 清空微任务 -> 执行一个宏任务 的循环。这是所有异步行为的底层执行模型。
Promise代表异步结果的状态机对象。.then 链式调用解决了回调地狱。
Promise.all并发执行,一败俱败用于所有任务都必须成功的场景。
Promise.race并发执行,一决胜负(无论成败)。用于超时控制或竞速场景。
async/awaitPromise 的语法糖。用同步的写法实现异步逻辑,是现代 JS 异步编程的最佳实践。

高频面试题与陷阱

面试官深度追问
2025-08-28

请你详细解释一下 JavaScript 的事件循环机制 (Event Loop)。

好的。JavaScript 是单线程的,为了处理耗时操作而不阻塞主线程,它采用了一种基于事件循环的并发模型。这个模型主要由调用栈、任务队列和 Web APIs 组成。

首先,所有同步代码都在调用栈中执行。当遇到像 setTimeout 或网络请求这样的异步操作时,主线程不会等待,而是将其交给浏览器提供的 Web APIs 去处理,然后继续执行后续的同步代码。

当 Web API 中的异步操作完成后,它不会直接把结果返回给主线程,而是将其回调函数放入任务队列中排队。

任务队列又分为宏任务队列和微任务队列,微任务的优先级更高。像 setTimeout, I/O 操作的回调会进入宏任务队列;而 Promise.then的回调会进入微任务队列。

事件循环是一个持续的过程,它会不断检查调用栈。当调用栈为空时,它会先去清空整个微任务队列,将所有微任务的回调依次推入调用栈执行。微任务队列清空后,再从宏任务队列中取出一个任务推入调用栈执行。这个过程不断重复,就构成了事件循环。

理解得很透彻。那么 Promise 本身是同步的还是异步的?

new Promise() 这个构造函数本身是同步执行的。在 new Promise(executor) 时,传入的 executor 函数会立即、同步地执行。但是,executor 函数内部的 resolvereject 函数被调用时,它们会将后续的 .then.catch 的回调函数放入微任务队列,这个调度过程是异步的。所以,Promise 的初始化是同步的,但其结果的处理是异步的。