js 你不知道的那些东西——异步JavaScript

144 阅读3分钟

这是我参与「掘金日新计划 · 8 月更文挑战」的第14天,点击查看活动详情

介绍异步 JavaScript

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

先决条件:基本的计算机知识,对 JavaScript 基础知识的合理理解,包括函数和事件处理程序。
客观的:了解什么是异步 JavaScript,它与同步 JavaScript 有何不同,以及我们为什么需要它。

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

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

  • 使用 HTTP 请求fetch()
  • 使用访问用户的相机或麦克风getUserMedia()
  • 要求用户使用showOpenFilePicker()

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

在本文中,我们将首先研究长时间运行的同步函数的问题,这使得异步编程成为必要。

同步编程

考虑以下代码:

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 控制台。

我们应该注意到,浏览器一次一行地有效地执行程序,按照我们编写它的顺序。在每一点,浏览器都会等待该行完成其工作,然后再继续下一行。它必须这样做,因为每一行都依赖于前面几行所做的工作。

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

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

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

如果同步功能需要很长时间怎么办?

当用户单击“Generate primes”按钮时,下面的程序使用非常低效的算法来生成多个大 primes。用户指定的 primes越多,操作所需的时间就越长。

<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>
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 = `Finished generating ${quota} primes!`;
});

document.querySelector('#reload').addEventListener('click', () => {
  document.location.reload()
});

长时间运行的同步函数的问题

下一个示例与上一个示例一样,只是我们添加了一个文本框供您输入。这一次,单击“生成素数”,然后立即尝试在文本框中输入。

您会发现,当我们的generatePrimes()函数运行时,我们的程序完全没有响应:您无法键入任何内容、单击任何内容或执行其他任何操作。

这是长时间运行的同步函数的基本问题。我们需要的是一种让我们的程序能够:

  1. 通过调用函数启动长时间运行的操作。
  2. 让该函数开始操作并立即返回,以便我们的程序仍然可以响应其他事件。
  3. 最终完成时通知我们操作结果。

这正是异步函数可以做的。本模块的其余部分解释了它们是如何在 JavaScript 中实现的。

事件处理程序

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

一些早期的异步 API 就是以这种方式使用事件的。该XMLHttpRequestAPI 使您能够使用 JavaScript 向远程服务器发出 HTTP 请求。由于这可能需要很长时间,因此它是一个异步 API,通过将事件侦听器附加到XMLHttpRequest对象,您会收到有关请求的进度和最终完成的通知。

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

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

<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()函数,它更难阅读和调试。这有时被称为“回调地狱”或“末日金字塔”(因为压痕看起来像金字塔的侧面)。

当我们像这样嵌套回调时,处理错误也会变得非常困难:通常您必须在“金字塔”的每一层处理错误,而不是在顶层只处理一次错误。