异步 JavaScript 简介

在本文中,我们将解释什么是异步编程,为什么我们需要它,并简要讨论 JavaScript 中异步函数历史上的一些实现方式。

预备知识 JavaScript 基础知识有扎实的理解。
学习成果
  • 熟悉异步 JavaScript 是什么,它与同步 JavaScript 有何不同,以及为什么我们需要它。
  • 什么是同步编程,以及为什么它有时会成为问题。
  • 异步编程如何旨在解决这些问题。
  • 事件处理程序和回调函数,以及它们与异步编程的关系。

异步编程是一种技术,它使你的程序能够启动一个可能长时间运行的任务,并且在任务运行期间仍然能够响应其他事件,而无需等待该任务完成。一旦任务完成,程序就会得到结果。

浏览器提供的许多函数,尤其是最有趣的那些,可能需要很长时间,因此是异步的。例如:

因此,即使你可能不经常实现自己的异步函数,你也很可能需要正确地使用它们。

在本文中,我们将首先探讨长时间运行的同步函数带来的问题,这些问题使得异步编程成为必然。

同步编程

考虑以下代码:

js
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"

这段代码:

  1. 声明一个名为name的字符串。
  2. 声明另一个名为greeting的字符串,它使用name
  3. 将问候语输出到 JavaScript 控制台。

这里我们应该注意,浏览器实际上是按照我们编写的顺序,一行一行地执行程序的。在每个点上,浏览器都会等待该行完成其工作,然后才继续执行下一行。它必须这样做,因为每一行都依赖于前一行完成的工作。

这使得它成为一个同步程序。即使我们调用一个单独的函数,它仍然是同步的,例如:

js
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()是一个同步函数,因为调用者必须等待函数完成其工作并返回一个值,然后调用者才能继续。

一个长时间运行的同步函数

如果同步函数需要很长时间怎么办?

下面的程序使用一种非常低效的算法,在用户点击“生成素数”按钮时生成多个大素数。用户指定的素数数量越多,操作所需的时间就越长。

html
<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>
js
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 程序是单线程的。线程是程序遵循的指令序列。由于程序由单个线程组成,它一次只能做一件事:所以如果它正在等待我们长时间运行的同步调用返回,它就不能做其他任何事情。

我们需要一种方法让我们的程序能够:

  1. 通过调用函数启动一个长时间运行的操作。
  2. 让该函数立即启动操作并返回,以便我们的程序仍然能够响应其他事件。
  3. 让函数以不阻塞主线程的方式执行操作,例如通过启动新线程。
  4. 当操作最终完成时,通知我们结果。

这正是异步函数使我们能够做到的。本模块的其余部分将解释它们如何在 JavaScript 中实现。

事件处理器

我们刚刚看到的异步函数描述可能会让你想起事件处理程序,如果是这样,你就对了。事件处理程序实际上是一种异步编程形式:你提供一个函数(事件处理程序),它不会立即被调用,而是在事件发生时被调用。如果“事件”是“异步操作已完成”,那么该事件可以用来通知调用者异步函数调用的结果。

一些早期的异步 API 正是这样使用事件的。XMLHttpRequest API 允许你使用 JavaScript 向远程服务器发出 HTTP 请求。由于这可能需要很长时间,因此它是一个异步 API,你可以通过将事件监听器附加到XMLHttpRequest对象来获取请求的进度和最终完成的通知。

以下示例展示了它的实际应用。按下“点击开始请求”以发送请求。我们创建一个新的XMLHttpRequest并监听其loadend事件。处理程序会记录一条“完成!”消息以及状态码。

添加事件监听器后,我们发送请求。请注意,在此之后,我们可以记录“已启动 XHR 请求”:也就是说,我们的程序可以在请求进行时继续运行,并且当请求完成时,我们的事件处理程序将被调用。

html
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>

<pre readonly class="event-log"></pre>
js
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 中实现异步函数的主要方式。

然而,当回调本身必须调用接受回调的函数时,基于回调的代码会变得难以理解。如果你需要执行一些分解为一系列异步函数的操作,这是一个常见的情况。例如,考虑以下内容:

js
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)。作为一个同步程序,这非常简单。但是如果我们将这些步骤用回调实现呢?

js
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,这也是下一篇文章的主题。