谈谈JavaScript中的异步

34 阅读8分钟

概述

异步编程技术使你的程序可以在执行一个长期运行的任务的同时继续对其他事件做出反应而不必等待任务完成。与此同时,你的程序也将在任务完成后显示结果。

浏览器提供的许多功能可能需要很长的时间来完成,因此需要异步完成,例如:

事件处理器

事件处理程序实际上就是异步编程的一种形式:你提供的函数(事件处理程序)将在事件发生时被调用(而不是立即被调用)。如果事件是异步操作已经完成,那么你就可以看到事件如何被用来通知调用者(异步函数)调用的结果。

一些早期的异步 API 正是以这种方式来使用事件的。XMLHttpRequest API 可以让你用 JavaScript 向远程服务器发起 HTTP 请求。由于这样的操作可能需要很长的时间,所以它被设计成异步 API,你可以通过给 XMLHttpRequest 对象附加事件监听器来让程序在请求进展和最终完成时获得通知。

e.g:

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 都不使用回调。

Promise

事实上,JavaScript 中异步编程的基础是 Promise

在基于 Promise 的 API 中,异步函数会启动操作并返回 Promise 对象。然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

e.g:

const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
​
console.log(fetchPromise);
​
fetchPromise.then( response => {
  console.log(`已收到响应:${response.status}`);
});
​
console.log("已发送请求……");

我们在这里:

  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。

完整的输出结果应该是这样的:

Promise { <state>: "pending" }
已发送请求……
已收到响应: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/fetching-data/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/fetching-data/can-store/products.json');
​
fetchPromise
  .then( response => {
    return response.json();
  })
  .then( json => {
    console.log(json[0].name);
  });

Promise错误捕获

这给我们带来了最后一个问题:我们如何处理错误?fetch() API 可能因为很多原因抛出错误(例如,没有网络连接或 URL 本身存在问题),我们也会在服务器返回错误消息时抛出一个错误。

Promise 对象提供了一个 catch() 方法来支持错误处理。这很像 then():你调用它并传入一个处理函数。然后,当异步操作成功时,传递给 then() 的处理函数被调用,而当异步操作失败时,传递给 catch() 的处理函数被调用。

如果将 catch() 添加到 Promise 链的末尾,它就可以在任何异步函数失败时被调用。于是,我们就可以将一个操作实现为几个连续的异步函数调用,并在一个地方处理所有错误。

const fetchPromise = fetch('bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/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}`);
  });

Promise 术语

Promise 中有一些具体的术语值得我们弄清楚。

首先,Promise 有三种状态:

  • 待定(pending) :初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled) :意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected) :意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

合并使用多个 Promise

当你的操作由几个异步函数组成,而且你需要在开始下一个函数之前完成之前每一个函数时,你需要的就是 Promise 链。但是在其他的一些情况下,你可能需要合并多个异步函数的调用,Promise API 为解决这一问题提供了帮助。

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。

譬如:

const fetchPromise1 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
const fetchPromise2 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found');
const fetchPromise3 = fetch('https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json');

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then( responses => {
    for (const response of responses) {
      console.log(`${response.url}:${response.status}`);
    }
  })
  .catch( error => {
    console.error(`获取失败:${error}`)
  });

这里我们向三个不同的 URL 发出三个 fetch() 请求。如果它们都被兑现了,我们将输出每个请求的响应状态。如果其中任何一个被拒绝了,我们将输出失败的情况。

根据我们提供的 URL,应该所有的请求都会被兑现,尽管因为第二个请求中请求的文件不存在,服务器将返回 404(Not Found)而不是 200(OK)。所以输出应该是:

https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json:200
https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found:404
https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json:200

如果我们用一个错误编码的 URL 尝试同样的代码,就像这样:

const fetchPromise1 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
const fetchPromise2 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found');
const fetchPromise3 = fetch('bad-scheme://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json');

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then( responses => {
    for (const response of responses) {
      console.log(`${response.url}:${response.status}`);
    }
  })
  .catch( error => {
    console.error(`获取失败:${error}`)
  });

……然后 catch() 处理程序将被运行,我们应该看到像这样的输出:

获取失败:TypeError: Failed to fetch

有时,你可能需要等待一组 Promise 中的某一个 Promise 的执行,而不关心是哪一个。在这种情况下,你需要 Promise.any()。这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

const fetchPromise1 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
const fetchPromise2 = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found');
const fetchPromise3 = fetch('https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json');

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then( response => {
    console.log(`${response.url}:${response.status}`);
  })
  .catch( error => {
    console.error(`获取失败:${error}`)
  });

值得注意的是,在这种情况下,我们无法预测哪个获取请求会先被兑现。

这两个用于组合多个承诺的函数只是额外的 Promise 函数中的两个。要了解其余的内容,参见 Promise 参考文档。

async 和 await

async 关键字为你提供了一种更简单的方法来处理基于异步 Promise 的代码。在一个函数的开头添加 async,就可以使其成为一个异步函数。

async function myFunction() {
  // 这是一个异步函数
}

在异步函数中,你可以在调用一个返回 Promise 的函数之前使用 await 关键字。这使得代码在该点上等待,直到 Promise 被完成,这时 Promise 的响应被当作返回值,或者被拒绝的响应被作为错误抛出。

这使你能够编写像同步代码一样的异步函数。例如,我们可以用它来重写我们的 fetch 示例。

async function fetchProducts() {
  try {
    // 在这一行之后,我们的函数将等待 `fetch()` 调用完成
    // 调用 `fetch()` 将返回一个“响应”或抛出一个错误
    const response = await fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    // 在这一行之后,我们的函数将等待 `response.json()` 的调用完成
    // `response.json()` 调用将返回 JSON 对象或抛出一个错误
    const json = await response.json();
    console.log(json[0].name);
  }
  catch(error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

fetchProducts();

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Pomise,所以你不能做这样的事情:

async function fetchProducts() {
  try {
    const response = await fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const json = await response.json();
    return json;
  }
  catch(error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const json = fetchProducts();
console.log(json[0].name);   // json 是一个 Promise 对象,因此这句代码无法正常工作

相反,你需要做一些事情,比如:

async function fetchProducts() {
  try {
    const response = await fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const json = await response.json();
    return json;
  }
  catch(error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const jsonPromise = fetchProducts();
jsonPromise.then((json) => console.log(json[0].name));

你可能会在需要使用 Promise 链的地方使用 async 函数,这也使得 Promise 的工作更加直观。

请记住,就像一个 Promise 链一样,await 强制异步操作以串联的方式完成。如果下一个操作的结果取决于上一个操作的结果,这是必要的,但如果不是这样,像 Promise.all() 这样的操作会有更好的性能。

小结

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写异步代码那样编写同步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio APIMedia Capture and Streams 等等。