第七章: 异步 JavaScript
第七章: 异步 JavaScript
Prorise第七章: 异步 JavaScript
摘要: JavaScript 的核心特性之一是单线程,这意味着在任意时刻,它只能执行一件任务。这个特性简化了编程模型,但也带来了巨大的挑战:如果一个任务耗时过长(如网络请求),整个程序(包括用户界面)都将被阻塞。本章,我们将深入 JavaScript 为解决这一根本矛盾而设计的核心机制——异步编程。我们将从一个真实的“UI 阻塞”痛点出发,沿着“定时器与回调 -> Promise -> async/await”的技术演进路径,最终回归到底层核心“事件循环”,让您彻底掌握现代 JavaScript 的异步编程范式。
在本章,我们将遵循一条从实践到理论的探索之路:
- 首先,我们将直面 JavaScript 的单线程阻塞问题,理解异步的必要性。
- 接着,我们将学习最初的异步工具
setTimeout
与回调函数,并体会“回调地狱”的痛苦。 - 然后,我们将深入学习现代异步核心
Promise
,包括其链式调用和所有重要的静态并发方法。 - 紧接着,我们将掌握终极语法糖
async/await
,学习如何编写优雅的异步代码。 - 最后,在掌握了所有工具后,我们将揭秘事件循环的底层模型,理解这一切是如何工作的。
7.1. 问题所在:JavaScript 的单线程与 UI 阻塞
核心痛点: JavaScript 在浏览器中执行时,JS 引擎与页面的渲染引擎共享同一个主线程。如果 JS 执行一个长时间的同步任务,主线程就会被“霸占”,导致页面渲染完全停止。
场景复现: 想象一下,页面上有一个按钮,点击后需要执行一个非常耗时的计算。
1 |
|
实际体验: 当你点击按钮后,会发现页面立即卡死。'Calculating...'
这段文本根本不会显示,整个浏览器窗口在 3 秒内无响应。3 秒后,页面恢复,并直接显示 'Calculation finished!'
。这就是 UI 阻塞。
7.2. 最初的解决方案:异步回调与定时器
解决方案: 我们可以使用浏览器提供的 setTimeout(callback, delay)
API,将耗时任务变成一个异步任务。这会告诉浏览器:“请把这个任务(callback
)放到后台,等 delay
毫秒后,再把它放进任务队列里等待执行。” 这样,主线程就不会被阻塞了。
1 | function handleClick() { |
体验改善: 再次点击按钮,'Calculating...'
会立刻显示,页面保持流畅。3 秒后,文本更新为 'Calculation finished!'
。我们成功避免了 UI 阻塞。
新的痛点:回调地狱
当异步任务之间存在依赖关系时,例如,任务 B 必须在任务 A 完成后才能开始,我们就不得不将回调函数层层嵌套。
1 | setTimeout(() => { |
这种“毁灭金字塔”结构,使得代码难以阅读、理解和维护,错误处理也变得异常复杂。
7.3. 现代解决方案 (一):Promise
对象
ES6 引入 Promise
,正是为了将异步流程从“深坑”中解放出来,实现线性化的书写方式。Promise
是一个代表异步操作最终完成或失败的代理对象,它拥有三种状态:pending
(进行中)、fulfilled
(已成功)、rejected
(已失败)。
Promise
通过 .then()
、.catch()
、.finally()
的链式调用来组织异步流程。
1 | function asyncTask(name, duration) { |
7.4. 并发处理:Promise
的核心静态方法
当我们需要处理多个并发的异步任务时,Promise
提供了一组强大的静态方法。
Promise.all(iterable)
业务痛点: 页面初始化时,需要同时请求多个接口(例如,获取用户信息、获取产品列表),并且必须在所有数据都成功返回后才能渲染页面。
解决方案: Promise.all
接收一个 Promise 数组,返回一个新的 Promise。
- 当所有 Promise 都
fulfilled
时,它才会fulfilled
,并且其结果是一个包含所有 Promise 结果的数组(顺序与输入一致)。 - 只要有一个 Promise
rejected
,它就会立即rejected
,并且其原因是第一个失败的 Promise 的原因。
1 | const p1 = Promise.resolve('User Info'); |
1
2
All success: [ 'User Info', 'Product List' ]
One failed: API Error
Promise.race(iterable)
业务痛点: 你需要为一个耗时可能很长的 API 请求设置一个超时限制。如果超过 2 秒还未返回,就视为失败。
解决方案: Promise.race
像一场赛跑,返回一个新的 Promise。这个 Promise 的状态会与第一个“撞线”(即第一个 settled
,无论是 fulfilled
还是 rejected
)的 Promise 的状态保持一致。
1 | function timeout(delay) { |
Promise.allSettled(iterable)
& Promise.any(iterable)
Promise.allSettled
: (我全都要) 等待所有 Promise 完成,无论成败,返回一个包含每个任务最终状态的对象数组。- 适用于需要知道所有结果的场景。
1 | const promises = [Promise.resolve('成功1'), Promise.reject('失败2'), Promise.resolve('成功3')]; |
Promise.any
: (谁快用谁,失败不管) 等待第一个fulfilled
的 Promise。- 适用于有多个备用数据源,只需要最快的那一个的场景。
1 | const p1 = new Promise(resolve => setTimeout(() => resolve('不会被执行'), 3000)); |
7.5. 终极形态:async/await
async/await
是 Promise
的语法糖,它允许我们用同步的、阻塞式的写法来处理异步的、非阻塞的逻辑,是目前处理异步的最佳实践。
1 | const p1 = Promise.resolve('User Info'); |
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
等。- 事件循环: 一个持续不断的进程,其工作流程如下:
- 执行调用栈中的所有同步任务,直到栈空。
2. 检查微任务队列,清空整个队列,依次执行所有微任务。如果在执行微任务的过程中,又产生了新的微任务,那么这些新的微任务也会被添加到队列末尾并在当前轮次被执行。
3. 取出一个宏任务从宏任务队列推入调用栈中执行。
4. 重复步骤 2 和 3。
7.7. 本章核心原理与高频面试题
核心原理速查
概念 | 核心原理 | 关键点 |
---|---|---|
异步 | 将耗时任务交给宿主环境,通过回调函数处理结果,以避免阻塞主线程。 | 是 JS 实现流畅 UI 的基础。 |
事件循环 | 同步任务 -> 清空微任务 -> 执行一个宏任务 的循环。 | 这是所有异步行为的底层执行模型。 |
Promise | 代表异步结果的状态机对象。 | .then 链式调用解决了回调地狱。 |
Promise.all | 并发执行,一败俱败。 | 用于所有任务都必须成功的场景。 |
Promise.race | 并发执行,一决胜负(无论成败)。 | 用于超时控制或竞速场景。 |
async/await | Promise 的语法糖。 | 用同步的写法实现异步逻辑,是现代 JS 异步编程的最佳实践。 |
高频面试题与陷阱
请你详细解释一下 JavaScript 的事件循环机制 (Event Loop)。
好的。JavaScript 是单线程的,为了处理耗时操作而不阻塞主线程,它采用了一种基于事件循环的并发模型。这个模型主要由调用栈、任务队列和 Web APIs 组成。
首先,所有同步代码都在调用栈中执行。当遇到像 setTimeout
或网络请求这样的异步操作时,主线程不会等待,而是将其交给浏览器提供的 Web APIs 去处理,然后继续执行后续的同步代码。
当 Web API 中的异步操作完成后,它不会直接把结果返回给主线程,而是将其回调函数放入任务队列中排队。
任务队列又分为宏任务队列和微任务队列,微任务的优先级更高。像 setTimeout
, I/O 操作的回调会进入宏任务队列;而 Promise.then
的回调会进入微任务队列。
事件循环是一个持续的过程,它会不断检查调用栈。当调用栈为空时,它会先去清空整个微任务队列,将所有微任务的回调依次推入调用栈执行。微任务队列清空后,再从宏任务队列中取出一个任务推入调用栈执行。这个过程不断重复,就构成了事件循环。
理解得很透彻。那么 Promise
本身是同步的还是异步的?
new Promise()
这个构造函数本身是同步执行的。在 new Promise(executor)
时,传入的 executor
函数会立即、同步地执行。但是,executor
函数内部的 resolve
或 reject
函数被调用时,它们会将后续的 .then
或 .catch
的回调函数放入微任务队列,这个调度过程是异步的。所以,Promise 的初始化是同步的,但其结果的处理是异步的。