65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript 中的异步编程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2021年7月6日

CPOL

8分钟阅读

viewsIcon

5761

JavaScript 异步编程的演进:回调函数、Promise、async/await

在编程中,我们经常会遇到这种情况:当调用一个可能耗时的输入/输出(I/O)操作(或任何长时间运行的操作,例如执行复杂计算)时,程序执行必须等待其结果返回后才能继续。调用此类操作并等待其结果,同时阻塞主程序的进一步执行(及其整个线程),这代表了一次同步操作调用。隐含的等待/阻塞对于在浏览器线程中执行的 JS 程序来说是一个问题,因为在等待期间,用户界面(在浏览器标签页中)会冻结,这从用户体验的角度来看是不可接受的,因此浏览器不会接受这种行为。

因此,在 JavaScript 中,无法同步调用 I/O 操作,例如(使用内置的 XMLHttpRequestfetch API)从网页获取数据,或(通过 HTTP 请求-响应消息)访问远程数据库。这些类型的操作必须以异步(非阻塞)方式执行。

JavaScript 中的异步编程概念经历了从回调函数Promise再到生成器(协程),以及最近的通过 await 调用表达式和 async 定义的异步过程调用。每一次演进都使异步编程对于那些努力熟悉它的人来说变得容易得多。

由于这种演进,旧的 JS 输入/输出 API(以内置对象形式可用),例如用于 HTTP 消息传递的 XMLHttpRequest 或用于对象数据库管理的 indexedDB,使用回调函数工作;而较新的 API,例如用于 HTTP 消息传递的 fetch,则使用 Promise,并且也可以使用 await 调用。

回调

一种简单的异步编程方法包括定义一个在异步操作完成后要执行的过程。这允许在调用异步操作后继续程序执行,但并不假定操作结果可用。但执行环境如何知道在完成异步操作后要调用哪个过程呢?

在 JS 中,我们可以将 JS 函数作为参数传递给异步操作的调用。回调函数就是这样一个 JS 函数。

考虑以下示例。可以在(在已经加载的带有相关 JS 代码的网页的上下文中)通过以下步骤动态加载外部 JS 文件:(1) 以编程方式创建一个 HTML script 元素 DOM 对象,其文件的 URL 作为脚本 src 属性的值;(2) 将新创建的 script 元素插入到文档 head 元素的最后一个子节点之后。

function loadJsFile( fileURL) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  document.head.append( scriptEl);
}

当新的脚本元素插入到文档的 DOM 中时,例如借助异步 DOM 操作 append(在 loadJsFile 过程的末尾),浏览器将加载 JS 文件,然后解析并执行它,这需要一些时间。假设我们有一个 JS 代码文件,其中包含一个名为 addTwoNumbers 的函数,该函数执行其名称所表示的操作,我们首先加载文件,然后按以下方式调用该函数:

loadJsFile("addTwoNumbers.js");
console.log( addTwoNumbers( 1, 2));

这将不起作用。我们会收到一个错误消息,而不是 1 和 2 的总和,因为当第二个语句执行时,第一个语句的预期结果,即 addTwoNumbers 函数的可用性,尚未获得。

我们可以通过向 loadJsFile 过程添加第二个参数作为回调过程,并将其分配给 JS 文件 load 事件的事件处理程序来解决此问题。

function loadJsFile( fileURL, callback) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  script.onload = callback;
  document.head.append( scriptEl);
}

现在,在调用 loadJsFile 时,我们可以在匿名回调函数中提供在加载“addTwoNumbers.js”文件后要执行的代码。

loadJsFile("addTwoNumbers.js", function () {
  console.log( addTwoNumbers( 1, 2));  // results in 3
]);

由于 JS 文件加载可能会失败,我们最好通过定义 error 事件的事件处理程序来添加一些错误处理。我们可以通过将错误参数传递给回调过程来在回调过程中处理可能的错误。

function loadJsFile( fileURL, callback) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  script.onload = callback;
  script.onerror = function () {
      callback( new Error(`Script load error for ${fileURL}`));
  };
  document.head.append( scriptEl);
}

现在我们使用一个具有 error 参数的匿名回调函数调用 loadJsFile

loadJsFile("addTwoNumbers.js", function (error) {
  if (!error) console.log( addTwoNumbers(1,2));  // results in 3
  else console.log( error);
]);

回调函数在简单情况下可以很好地作为异步编程方法。但是,当需要按顺序执行多个异步操作时,很快就会陷入“回调地狱”,这个术语指的是由此产生的深度嵌套的代码结构,难以阅读和维护。

Promises

Promise(在某些编程语言(如 Python)中也称为Future)是一个特殊对象,它将异步操作的延迟结果提供给等待该结果的代码。Promise 对象最初处于pending状态。如果异步操作成功(当调用 resolve 函数并带有提供结果值的参数时),Promise 状态将从pending变为fulfilled。如果失败(当调用 reject 函数并带有提供错误值的参数时),Promise 状态将从pending变为rejected

返回 Promise 的内置异步操作的一个示例是用于动态加载 JS 代码文件(和 ES6 模块)的 import。我们可以使用它来代替前面部分讨论的用户定义的 loadJsFile 过程来加载 addTwoNumbers.js 文件,然后执行使用 addTwoNumbers 函数的代码(或在加载失败时报告错误)。

import("addTwoNumbers.js")
.then( function () {
  console.log( addTwoNumbers( 1, 2));
})
.catch( function (error) {
  console.log( error);
});

此示例代码表明,在 import 返回的 promise 对象上,我们可以调用预定义的函数 thencatch

然后
仅当 import 操作以fulfilled Promise 完成时,才继续执行;
catch
用于处理rejected Promise 的错误结果。

使用 Promise 的异步编程的一般方法要求每个异步操作都返回一个 Promise 对象,该对象通常在 Promise 被 fulfilled 时提供结果值,或在 Promise 被 rejected 时提供错误值。对于用户定义的异步过程,这意味着它们必须创建 Promise 作为其返回值,如下面的 Promise 值 loadJsFile 函数所示。

可以通过提供一个匿名函数表达式作为 Promise 构造函数调用的参数(带有两个参数 resolvereject,它们代表 JS 函数)来使用 Promise 构造函数创建 Promise 对象。我们在下面一个 Promise 值 loadJsFile 函数的示例中这样做,它是之前讨论的基于回调的 loadJsFile 过程的一个变体。

function loadJsFile( fileURL) {
  return new Promise( function (resolve, reject) {
    const scriptEl = document.createElement("script");
    scriptEl.src = fileURL;
    scriptEl.onload = resolve;
    scriptEl.onerror = function () {
        reject( new Error(`Script load error for ${fileURL}`));
    };
    document.head.append( scriptEl);
  });
}

这个新版本的异步 loadJsFile 操作的使用方式如下:

loadJsFile("addTwoNumbers.js")
.then( function () {
  console.log( addTwoNumbers( 1, 2));
})
.catch( function (error) {
  console.log( error);
});

我们可以看到,即使是带有 thencatch 的简单 Promise 值函数调用的语法,也比基于回调的异步过程调用的语法更清晰。当涉及到链接异步过程调用时,这种优势更加显著,例如在下面的示例中,我们首先顺序加载三个 JS 文件,然后调用它们的函数。

loadJsFile("addTwoNumbers.js")
.then( function () {
  return loadJsFile("multiplyBy3.js");})
.then( function () {
  return loadJsFile("decrementBy2.js");})
.then( function () {
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(1,2))));})
.catch( function (error) {
  console.log( error);
});

请注意,要使用 then 执行一系列异步操作,我们需要确保每个 then 函数都返回一个 Promise。

作为异步操作顺序执行的替代方案,我们也可以使用 Promise.all 并行执行它们。

Promise.all([ loadJsFile("addTwoNumbers.js"),
  loadJsFile("multiplyBy3.js"),
  loadJsFile("decrementBy2.js")
])
.then( function () {
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(1,2))));
})
.catch( function (error) {console.log( error);});

loadJsFile 不同,后者仅以副作用(加载 JS 代码)完成,但没有返回结果值,典型的异步操作会返回一个 promise 对象,该对象在 Promise 被 fulfilled 时提供结果值,或在 Promise 被 rejected 时提供错误值。

让我们考虑另一个示例,其中我们有带有结果值的异步操作。JS 内置的 fetch 操作允许通过发送 HTTP 请求消息分两步检索远程资源文件的内容。

  1. 第一步,它返回一个 Promise,该 Promise 以 response 对象作为其结果值解析,其中包含检索到的 HTTP 头部信息。
  2. 然后,在之前检索到的 response 对象上调用 text()json() 函数会返回一个 Promise,当从远程服务器检索到 HTTP 响应消息的正文时(以字符串或 JSON 对象的形式),该 Promise 会解析为该正文。

在这种情况下,当我们链接两个或多个带有结果值的异步操作调用时,每个后续调用都可以表示为使用箭头函数将前一个结果转换为新结果,如下面示例的第 2 行所示。

fetch("user1.json")
.then( response => response.json())
.then( function (user1) {alert( user1.name);})
.catch( function (error) {console.log( error);});

请注意,假定文本文件“user1.json”包含一个 JSON 对象,该对象描述了具有 name 字段的特定用户。此 JSON 对象使用第 2 行中的箭头函数表达式检索。

使用 Await 调用异步操作

当执行包含异步过程调用(带有 await)的语句的程序时,程序将执行到该语句,调用过程,并暂停执行,直到异步过程执行完成,这意味着如果它返回一个 Promise,则该 Promise 已settled。暂停执行意味着将控制权交还给事件循环,以便其他异步过程也有机会运行。如果异步过程执行的 Promise 被 fulfilled,程序执行将恢复,await 表达式的值将是已 fulfilled 的 Promise 的值。如果被 rejected,await 表达式将抛出已 rejected 的 Promise 的值(其错误)。

当我们将 await 用于调用 Promise 值 JS 函数时,我们通常不使用 .then 进行 Promise 链接,因为 await 为我们处理了等待。并且我们可以使用常规的 try-catch 块代替 Promise 链接的 .catch 子句,如下面的示例代码所示。

try {
  await loadJsFile("addTwoNumbers.js");
  console.log( addTwoNumbers(2,3));
} catch (error) {
  console.log( error);
}

请注意,这是 ES6 模块的代码。在普通的 JS 文件中,await 只能在 async 函数内使用。

当我们连续使用 await 调用多个异步过程时,代码的阅读方式非常自然,类似于调用同步过程的代码。

try {
  await loadJsFile("addTwoNumbers.js");
  await loadJsFile("multiplyBy3.js");
  await loadJsFile("decrementBy2.js");
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(2,3))));
} catch (error) {
  console.log( error);
}

async 函数中,我们可以在 await 表达式中调用 Promise 值函数。由于 async 函数返回一个 Promise,因此它本身也可以使用 await 调用。

async function load3JsFiles() {
  await loadJsFile("addTwoNumbers.js");
  await loadJsFile("multiplyBy3.js");
  await loadJsFile("decrementBy2.js");
}
try {
  await load3JsFiles();
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(2,3))));
} catch (error) {
  console.log( error);
}

在更典型的情况下,使用带有结果值的异步操作调用,我们可以获得类似下面的代码,这是上面使用 fetch 的 Promise 相关示例的 await 版本。

try {
  const response = await fetch("user1.json");
  const user1 = await response.json();
  alert( user1.name);
} catch (error) {
  console.log( error);
}

有关异步编程技术的更多信息,请参阅 Promises, async/awaitDemystifying Async Programming in JavaScript

© . All rights reserved.