异步 JavaScript 简介
在本文中,我们将解释什么是异步编程,为什么我们需要它,以及简要讨论 JavaScript 中异步函数在历史上的一些实现方式。
先决条件 | 对 JavaScript 基础知识有合理的理解,包括函数和事件处理程序。 |
---|---|
目标 | 熟悉什么是异步 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
。 - 将问候语输出到 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()
是一个同步函数,因为调用方必须等待函数完成其工作并返回值,然后调用方才能继续。
长时间运行的同步函数
如果同步函数需要很长时间怎么办?
下面的程序使用一个非常低效的算法,在用户点击“生成素数”按钮时生成多个大素数。用户指定的素数数量越多,操作时间就越长。
<label for="quota">Number of primes:</label>
<input type="text" id="quota" name="quota" value="1000000" />
<button id="generate">Generate primes</button>
<button id="reload">Reload</button>
<div id="output"></div>
const MAX_PRIME = 1000000;
function isPrime(n) {
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) {
return false;
}
}
return n > 1;
}
const random = (max) => Math.floor(Math.random() * max);
function generatePrimes(quota) {
const primes = [];
while (primes.length < quota) {
const candidate = random(MAX_PRIME);
if (isPrime(candidate)) {
primes.push(candidate);
}
}
return primes;
}
const quota = document.querySelector("#quota");
const output = document.querySelector("#output");
document.querySelector("#generate").addEventListener("click", () => {
const primes = generatePrimes(quota.value);
output.textContent = `Finished generating ${quota.value} primes!`;
});
document.querySelector("#reload").addEventListener("click", () => {
document.location.reload();
});
尝试点击“生成素数”。根据您计算机的速度,程序可能需要几秒钟才能显示“完成!”消息。
长时间运行的同步函数的问题
下一个示例与上一个示例相同,只是我们添加了一个文本框供您输入。这次,点击“生成素数”,然后立即尝试在文本框中输入内容。
您会发现,当我们的generatePrimes()
函数正在运行时,我们的程序完全无响应:您无法输入任何内容,无法点击任何内容,也无法执行任何其他操作。
出现这种情况的原因是这个 JavaScript 程序是单线程的。线程是程序遵循的一系列指令。因为程序由单个线程组成,所以它一次只能做一件事:因此,如果它正在等待我们长时间运行的同步调用返回,它就无法执行任何其他操作。
我们需要一种方法让我们的程序
- 通过调用函数来启动一个长时间运行的操作。
- 让该函数启动操作并立即返回,以便我们的程序仍然能够响应其他事件。
- 让该函数以不阻塞主线程的方式执行操作,例如启动一个新线程。
- 在操作最终完成后,用操作结果通知我们。
这正是异步函数使我们能够做到的。本模块的其余部分解释了它们如何在 JavaScript 中实现。
事件处理程序
我们刚刚看到的异步函数的描述可能会让您想起事件处理程序,如果确实如此,那您是对的。事件处理程序实际上是异步编程的一种形式:您提供一个函数(事件处理程序),该函数不会立即被调用,而是在事件发生时被调用。如果“事件”是“异步操作已完成”,那么该事件可以用来通知调用方异步函数调用的结果。
一些早期的异步 API 恰好以这种方式使用事件。XMLHttpRequest
API 使您能够使用 JavaScript 向远程服务器发出 HTTP 请求。由于这可能需要很长时间,因此它是一个异步 API,您可以通过向XMLHttpRequest
对象附加事件监听器来获取有关请求的进度和最终完成的通知。
以下示例演示了这一点。按“点击启动请求”发送请求。我们创建一个新的XMLHttpRequest
并监听其loadend
事件。处理程序记录“完成!”消息以及状态代码。
添加事件监听器后,我们发送请求。请注意,在此之后,我们可以记录“已启动 XHR 请求”:也就是说,我们的程序可以在请求进行的同时继续运行,并且当请求完成时,我们的事件处理程序将被调用。
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>
<pre readonly class="event-log"></pre>
const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
log.textContent = "";
const xhr = new XMLHttpRequest();
xhr.addEventListener("loadend", () => {
log.textContent = `${log.textContent}Finished with status: ${xhr.status}`;
});
xhr.open(
"GET",
"https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
);
xhr.send();
log.textContent = `${log.textContent}Started XHR request\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: ${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(`result: ${result3}`);
});
});
});
}
doOperation();
因为我们必须在回调中调用回调,所以我们得到了一个嵌套很深的doOperation()
函数,这使得阅读和调试变得更加困难。这有时被称为“回调地狱”或“厄运金字塔”(因为缩进看起来像一个侧面的金字塔)。
当我们像这样嵌套回调时,处理错误也可能变得非常困难:通常您必须在“金字塔”的每个级别处理错误,而不是只在顶层处理一次错误。
由于这些原因,大多数现代异步 API 都不使用回调。相反,JavaScript 中异步编程的基础是Promise
,这是下一篇文章的主题。