一文看懂 JavaScript 异步相关知识
异步
是我们在阅读技术文章时经常看到的字眼,那 异步
是什么意思?他重要吗?要怎么实现异步呢?本文将试着说明清楚这些事情。
异步 JavaScript 简介
异步编程技术使你的程序可以在执行一个可能长期运行的任务的同时继续对其他事件做出反应而不必等待任务完成。与此同时,你的程序也将在任务完成后显示结果。
浏览器提供的许多功能(尤其是最有趣的那一部分)可能需要很长的时间来完成,因此需要异步完成,例如:
- 使用
fetch()
发起 HTTP 请求 - 使用
getUserMedia()
访问用户的摄像头和麦克风 - 使用
showOpenFilePicker()
请求用户选择文件以供访问
因此,即使你可能不需要经常实现自己的异步函数,你也很可能需要正确使用它们。
在这部分中,我们将从同步函数长时间运行时存在的问题开始,并以此进一步认识异步编程的必要性。
同步编程
观察下面的代码:
const name = 'Miriam';
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"
这段代码:
- 声明了一个叫做
name
的字符串常量 - 声明了另一个叫做
greeting
的字符串常量(并使用了name
常量的值) - 将
greeting
常量输出到 JavaScript 控制台中。
我们应该注意的是,实际上浏览器是按照我们书写代码的顺序一行一行地执行程序的。浏览器会等待代码的解析和工作,在上一行完成后才会执行下一行。这样做是很有必要的,因为每一行新的代码都是建立在前面代码的基础之上的。
这也使得它成为一个同步程序。
事实上,调用函数的时候也是同步的,就像这样:
function makeGreeting(name) {
return `Hello, my name is ${name}!`;
}
const name = 'Miriam';
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"
在这里 makeGreeting()
就是一个同步函数,因为在函数返回之前,调用者必须等待函数完成其工作。
一个耗时的同步函数
如果同步函数需要很长的时间怎么办?
当用户点击“生成素数”按钮时,这个程序将使用一种非常低效的算法生成一些大素数。你可以控制要生成的素数数量,这也会影响操作需要的时间。
function generatePrimes(quota) {
function isPrime(n) {
for (let c = 2; c <= Math.sqrt(n); ++c) {
if (n % c === 0) {
return false;
}
}
return true;
}
const primes = [];
const maximum = 1000000;
while (primes.length < quota) {
const candidate = Math.floor(Math.random() * (maximum + 1));
if (isPrime(candidate)) {
primes.push(candidate);
}
}
return primes;
}
document.querySelector('#generate').addEventListener('click', () => {
const quota = document.querySelector('#quota').value;
const primes = generatePrimes(quota);
document.querySelector('#output').textContent = `完成!已生成素数${quota}个。`;
});
document.querySelector('#reload').addEventListener('click', () => {
document.location.reload()
});
耗时同步函数的问题
接下来的示例和上一个一样,不过我们增加了一个文本框供你输入。这一次,试着点击“生成素数”,然后在文本框中输入。
你会发现,当我们的 generatePrimes()
函数运行时,我们的程序完全没有反应:用户不能输入任何东西,也不能点击任何东西,或做任何其他事情。
这就是耗时的同步函数的基本问题。在这里我们想要的是一种方法,以让我们的程序可以:
- 通过调用一个函数来启动一个长期运行的操作
- 让函数开始操作并立即返回,这样我们的程序就可以保持对其他事件做出反应的能力
- 当操作最终完成时,通知我们操作的结果。
这就是异步函数为我们提供的能力,本模块的其余部分将解释它们是如何在 JavaScript 中实现的。
事件处理程序
我们刚才看到的对异步函数的描述可能会让你想起事件处理程序,这么想是对的。事件处理程序实际上就是异步编程的一种形式:你提供的函数(事件处理程序)将在事件发生时被调用(而不是立即被调用)。如果“事件”是“异步操作已经完成”,那么你就可以看到事件如何被用来通知调用者异步函数调用的结果的。
一些早期的异步 API 正是以这种方式来使用事件的。XMLHttpRequest
API 可以让你用 JavaScript 向远程服务器发起 HTTP 请求。由于这样的操作可能需要很长的时间,所以它被设计成异步 API,你可以通过给 XMLHttpRequest
对象附加事件监听器来让程序在请求进展和最终完成时获得通知。
下面的例子展示了这样的操作。点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest
并监听它的 loadend
事件。而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。
我们在添加了事件监听器后发送请求。注意,在这之后,我们仍然可以在控制台中输出“请求已发起”,也就是说,我们的程序可以在请求进行的同时继续运行,而我们的事件处理程序将在请求完成时被调用。
const log = document.querySelector('.event-log'); document.querySelector('#xhr').addEventListener('click', () => { log.textContent = ''; const xhr = new XMLHttpRequest(); xhr.addEventListener('loadend', () => { log.textContent = `${log.textContent}完成!状态码:${xhr.status}`; }); xhr.open('GET', 'https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json'); xhr.send(); log.textContent = `${log.textContent}请求已发起/n`;}); document.querySelector('#reload').addEventListener('click', () => { log.textContent = ''; document.location.reload(); });
这次的事件不是像点击按钮那样的用户行为,而是某个对象的状态变化。
回调
事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候被调用的函数。正如我们刚刚所看到的:回调函数曾经是 JavaScript 中实现异步函数的主要方式。
然而,当回调函数本身需要调用其他同样接受回调函数的函数时,基于回调的代码会变得难以理解。当你需要执行一些分解成一系列异步函数的操作时,这将变得十分常见。例如下面这种情况:
function doStep1(init) { return init + 1; } function doStep2(init) { return init + 2; } function doStep3(init) { return init + 3; } function doOperation() { let result = 0; result = doStep1(result); result = doStep2(result); result = doStep3(result); console.log(`结果:${result}`); } doOperation();
现在我们有一个被分成三步的操作,每一步都依赖于上一步。在这个例子中,第一步给输入的数据加 1,第二步加 2,第三步加 3。从输入 0 开始,最终结果是 6(0+1+2+3)。作为同步代码,这很容易理解。但是如果我们用回调来实现这些步骤呢?
function doStep1(init, callback) { const result = init + 1; callback(result); } function doStep2(init, callback) { const result = init + 2; callback(result); } function doStep3(init, callback) { const result = init + 3; callback(result); } function doOperation() { doStep1(0, result1 => { doStep2(result1, result2 => { doStep3(result2, result3 => { console.log(`结果:${result3}`); }); }); }); } doOperation();
因为必须在回调函数中调用回调函数,我们就得到了这个深度嵌套的
doOperation()
函数,这就更难阅读和调试了。在一些地方这被称为“回调地狱”或“厄运金字塔”(因为缩进看起来像一个金字塔的侧面)。面对这样的嵌套回调,处理错误也会变得非常困难:你必须在“金字塔”的每一级处理错误,而不是在最高一级一次完成错误处理。
由于以上这些原因,大多数现代异步 API 都不使用回调。事实上,JavaScript 中异步编程的基础是
Promise
,这也是我们下一章节要讲述的主题。如何使用 Promise
Promise 是现代 JavaScript 中异步编程的基础,是一个由异步函数返回的可以向我们指示当前操作所处的状态的对象。在 Promise 返回给调用者的时候,操作往往还没有完成,但 Promise 对象可以让我们操作最终完成时对其进行处理(无论成功还是失败)。
在上一章文章中,我们谈到使用回调实现异步函数的方法。在这种设计中,我们需要在调用异步函数的同时传入回调函数。这个异步函数会立即返回,并在操作完成后调用传入的回调。
在基于 Promise 的 API 中,异步函数会启动操作并返回
Promise
对象。然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。使用 fetch() API
在这个例子中,我们将从[https://mdn.github.io/learning-area/javascript/apis/can-store/products.json下载 JSON 文件,并记录一些相关信息。
在这篇文章中,我们将通过复制页面上的代码示例到浏览器的 JavaScript 控制台中运行的方式来学习 Promise。因此在正式开始学习之前你需要进行以下设置:
- 在浏览器的新标签页中访问 https://example.org。
- 在该标签页中,打开
浏览器开发者工具
中的 JavaScript 控制台- 把我们展示的代码示例复制到控制台中运行。值得注意的是,你必须在每次输入新的示例之前重新加载页面,否则控制台会报错“重新定义了
fetchPromise
”。要做到这一点,我们将向服务器发出一个 HTTP 请求。在 HTTP 请求中,我们向远程服务器发送一个请求信息,然后它向我们发送一个响应。这次,我们将发送一个请求,从服务器上获得一个 JSON 文件。还记得在上一篇文章中,我们使用
XMLHttpRequest
API 进行 HTTP 请求吗?那么,在这篇文章中,我们将使用fetch()
API,一个现代的、基于 Promise 的、用于替代XMLHttpRequest
的方法。把下列代码复制到你的浏览器 JavaScript 控制台中:
const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json'); console.log(fetchPromise); fetchPromise.then( response => { console.log(`已收到响应:${response.status}`); }); console.log("已发送请求……");
我们在这里:
- 调用
fetch()
API,并将返回值赋给fetchPromise
变量。- 紧接着,输出
fetchPromise
变量,输出结果应该像这样:Promise {
。这告诉我们有一个: "pending" } Promise
对象,它有一个state
属性,值是"pending"
。"pending"
状态意味着操作仍在进行中。- 将一个处理函数传递给 Promise 的
then()
方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的Response
对象。- 输出一条信息,说明我们已经发送了这个请求。
完整的输出结果应该是这样的:
已发送请求…… 已收到响应:200
请注意,
已发送请求……
的消息在我们收到响应之前就被输出了。与同步函数不同,fetch()
在请求仍在进行时返回,这使我们的程序能够保持响应性。响应显示了200
(OK)的状态码
。可能这看起来很像上一篇文章中的例子中我们把事件处理程序添加到
XMLHttpRequest
对象中。但不同的是,我们这一次将处理程序传递到返回的 Promise 对象的then()
方法中。链式使用 Promise
在你通过
fetch()
API 得到一个Response
对象的时候,你需要调用另一个函数来获取响应数据。这次,我们想获得JSON格式的响应数据,所以我们会调用Response
对象的json()
方法。事实上,json()
也是异步的,因此我们必须连续调用两个异步函数。试试这个:
const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json'); fetchPromise.then( response => { const jsonPromise = response.json(); jsonPromise.then( json => { console.log(json[0].name); }); });
在这个示例中,就像我们之前做的那样,我们给
fetch()
返回的 Promise 对象添加了一个then()
处理程序。但这次我们的处理程序调用response.json()
方法,然后将一个新的then()
处理程序传递到response.json()
返回的 Promise 中。执行代码后应该会输出
“baked beans”
(“products.json”中第一个产品的名称)。等等! 还记得上一篇文章吗?我们好像说过,在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?这不是也一样吗,只不过变成了用
then()
调用而已?当然如此。但 Promise 的优雅之处在于
then()
本身也会返回一个 Promise,这个 Promise 将指示then()
中调用的异步函数的完成状态。这意味着我们可以(当然也应该)把上面的代码改写成这样:const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json'); fetchPromise .then( response => { return response.json(); }) .then( json => { console.log(json[0].name); });
不必在第一个
then()
的处理程序中调用第二个then()
,我们可以直接返回json()
返回的 Promise,并在该返回值上调用第二个 "then()"。这被称为 Promise 链,意味着当我们需要连续进行异步函数调用时,我们就可以避免不断嵌套带来的缩进增加。在进入下一步之前,还有一件事要补充:我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:
const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json'); fetchPromise .then( response => { if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json(); }) .then( json => { console.log(json[0].name); });
错误捕获
这给我们带来了最后一个问题:我们如何处理错误?
fetch()
API 可能因为很多原因抛出错误(例如,没有网络连接或 URL 本身存在问题),我们也会在服务器返回错误消息时抛出一个错误。在上一篇文章中,我们看到在嵌套回调中进行错误处理非常困难,我们需要在每一个嵌套层中单独捕获错误。
Promise
对象提供了一个catch()
方法来支持错误处理。这很像then()
:你调用它并传入一个处理函数。然后,当异步操作成功时,传递给then()
的处理函数被调用,而当异步操作失败时,传递给catch()
的处理函数被调用。如果将
catch()
添加到 Promise 链的末尾,它就可以在任何异步函数失败时被调用。于是,我们就可以将一个操作实现为几个连续的异步函数调用,并在一个地方处理所有错误。试试这个版本的
fetch()
代码。我们使用catch()
添加了一个错误处理函数,并修改了 URL(这样请求就会失败)。const fetchPromise = fetch('bad-scheme://mdn.github.io/learning-area/javascript/apis//can-store/products.json'); fetchPromise .then( response => { if (!response.ok) { throw new Error(`HTTP 请求错误:${response.status}`); } return response.json(); }) .then( json => { console.log(json[0].name); }) .catch( error => { console.error(`无法获取产品列表:${error}`); });
尝试运行这个版本:你应该会看到
catch()
处理函数输出的错误。Promise 术语
Promise 中有一些具体的术语值得我们弄清楚。
首先,Promise 有三种状态:
-
待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用
fetch()
返回 Promise 时的状态,此时请求还在进行中。 -
已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的
then()
处理函数被调用。 -
已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的
catch()
处理函数被调用。
注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch()
认为服务器返回一个错误(如 404 Not Found
)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。
有时我们用 已敲定(settled) 这个词来同时表示 已兑现(fulfilled) 和 已拒绝(rejected) 两种情况。
如果一个 Promise 处于已决议(resolved)状态,或者它被“锁定”以跟随另一个 Promise 的状态,那么它就是 已兑现(fulfilled)。
合并使用多个 Promise
当你的操作由几个异步函数组成,而且你需要在开始下一个函数之前完成之前每一个函数时,你需要的就是 Promise 链。但是在其他的一些情况下,你可能需要合并多个异步函数的调用,Promise
API 为解决这一问题提供了帮助。
有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all()
方法。它接收一个 Promise 数组,并返回一个单一的 Promise。
共有 0 条评论