36. JavaScript 中的异步编程

优质
小牛编辑
134浏览
2023-12-01

原文: http://exploringjs.com/impatient-js/ch_async-js.html

本章介绍了 JavaScript 中异步编程的基础。

36.1。 JavaScript 中异步编程的路线图

本节提供了 JavaScript 中异步编程内容的路线图。

不要担心细节!

如果你不理解一切,不要担心。这只是快速查看即将发生的事情。

36.1.1。同步功能

正常函数是 synchronous :调用者等待被调用者完成计算。第(A)行中的divideSync()是同步函数调用:

function main() {
  try {
    const result = divideSync(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

36.1.2。 JavaScript 在单个进程中按顺序执行任务

默认情况下,JavaScript _ 任务 _ 是在单个进程中按顺序执行的函数。看起来像这样:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

此循环也称为 _ 事件循环 _,因为单击鼠标等事件会将任务添加到队列中。

由于这种协作式多任务方式,我们不希望任务阻止其他任务被执行,例如,它等待来自服务器的结果。下一节将探讨如何处理这种情况。

36.1.3。基于回调的异步函数

如果divide()需要服务器来计算其结果怎么办?然后结果应该以不同的方式传递:调用者不必等待(同步)直到结果准备好,它应该被通知(异步)。异步传递结果的一种方法是给divide()一个回调函数,用于通知调用者。

function main() {
  divideCallback(12, 3,
    (err, result) => {
      if (err) {
        assert.fail(err);
      } else {
        assert.equal(result, 4);
      }
    });
}

这是异步函数调用divideCallback(x, y, callback)后发生的情况:

  • divideCallback()向服务器发送请求。
  • 然后当前任务完成(暂时)并且可以执行其他任务。
  • When a response from the server arrives, it is either:
    • 错误err:然后将以下任务添加到队列中。

      taskQueue.enqueue(() => callback(err));
      
    • 结果r:然后将以下任务添加到队列中。

      taskQueue.enqueue(() => callback(null, r));
      

36.1.4。基于 Promise 的异步函数

Promise 是两件事:

  • 一种标准模式,可以更轻松地处理回调。
  • 构建 _ 异步函数 _(下一节的主题)的机制。

调用基于 Promise 的函数如下所示。

function main() {
  dividePromise(12, 3)
    .then(result => assert.equal(result, 4))
    .catch(err => assert.fail(err));
}

36.1.5。异步功能

查看异步函数的一种方法是为基于 Promise 的代码提供更好的语法:

async function main() {
  try {
    const result = await dividePromise(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }  
}

我们在 A 行调用的dividePromise()与前一节中基于 Promise 的函数相同。但是我们现在具有用于进行调用的同步语法。 await只能在特殊功能中使用,_ 异步功能 _(注意关键字function前面的关键字async)。 await暂停当前的异步功能并从中返回。一旦等待结果准备就绪,该功能的执行将从中断处继续。

36.1.6。下一步

  • 在本章中,我们将看到同步函数调用的工作原理。我们还将通过其 _ 事件循环 _ 探索 JavaScript 在单个进程中执行代码的方式。
  • 本章还介绍了通过回调的异步性。
  • 以下章节涵盖 Promises和异步函数。
  • 这一系列关于异步编程的章节以结束了关于异步迭代的一章,它类似于同步迭代,但是迭代值是异步传递的。

36.2。调用堆栈

每当一个函数调用另一个函数时,我们需要记住在后一个函数完成后返回的位置。这通常是通过堆栈完成的,_ 调用堆栈 _:调用者将其返回到该位置,并且被调用者在完成后跳转到该位置。

这是几个调用发生的示例:

function h(z) {
  const error = new Error();
  console.log(error.stack);
}
function g(y) {
  h(y + 1);
}
function f(x) {
  g(x + 1);
}
f(3);
// done

最初,在运行这段代码之前,调用堆栈是空的。在第 11 行的函数调用f(3)之后,堆栈有一个条目:

  • 第 12 行(顶级范围内的位置)

在第 9 行的函数调用g(x + 1)之后,堆栈有两个条目:

  • 第 10 行(f()中的位置)
  • 第 12 行(顶级范围内的位置)

在第 6 行的函数调用h(y + 1)之后,堆栈有三个条目:

  • 第 7 行(g()中的位置)
  • 第 10 行(f()中的位置)
  • 第 12 行(顶级范围内的位置)

在第 2 行中创建异常是另一个调用。这就是在异常error内记录的调用堆栈包含h()内的位置的原因。记录error会产生以下输出(请注意,堆栈跟踪记录了调用的位置,而不是返回位置):

Error
    at h (demos/async-js/stack_trace.js:2:17)
    at g (demos/async-js/stack_trace.js:6:3)
    at f (demos/async-js/stack_trace.js:9:3)
    at <top level> (demos/async-js/stack_trace.js:11:1)

然后,每个函数终止,每次从堆栈中删除顶部条目。函数f完成后,我们回到顶级范围,调用堆栈为空。当代码片段结束时,那就像隐式return。如果我们将代码片段视为已执行的任务,则使用空调用堆栈返回将结束任务。

36.3。事件循环

默认情况下,JavaScript 在单个进程中运行 - 在 Web 浏览器和 Node.js 中。所谓的 _ 事件循环 _ 在该进程内依次执行 _ 任务 _(代码片段)。事件循环如图 2 所示。 20

Figure 20: Task sources add code to run to the task queue, which is emptied by the event loop.

Figure 20: Task sources add code to run to the task queue, which is emptied by the event loop.

双方访问任务队列:

  • _ 任务源 _ 将任务添加到队列中。其中一些源同时运行到 JavaScript 进程。例如,一个任务源负责用户界面事件:如果用户单击某处并触发 JavaScript 代码,则该代码将添加到任务队列中。

  • _ 事件循环 _ 在 JavaScript 进程内连续运行。它从队列中取出一个任务并执行它。一旦调用堆栈为空并且存在return,则当前任务完成。控制返回到事件循环,然后从队列中检索下一个任务并执行它。等等。

以下 JavaScript 代码是事件循环的近似值:

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

36.4。如何避免阻止 JavaScript 进程

36.4.1。可以阻止浏览器的用户界面

许多浏览器的用户界面机制也在 JavaScript 进程中运行(作为任务)。因此,长时间运行的 JavaScript 代码可能会阻止用户界面。让我们看一下展示这一点的网页。您可以通过两种方式试用该页面:

  • 您可以在线运行
  • 您可以使用练习在存储库中打开以下文件:demos/async-js/blocking.html

以下 HTML 是页面的用户界面:

<a href="">Block</a>
<div></div>
<button>Click me!</button>

这个想法是你单击“阻止”,并通过 JavaScript 执行长时间运行的循环。在该循环期间,您无法单击该按钮,因为浏览器/ JavaScript 进程被阻止。

JavaScript 代码的简化版本如下所示:

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)
  
function doBlock(event) {
  // ···
  setStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  setStatus('Done');
}

function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function setStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

这些是代码的关键部分:

  • A 行:我们告诉浏览器只要点击 ID 为block的 HTML 元素就调用doBlock()
  • doBlock()运行循环 5000 毫秒(B 行)。
  • sleep()执行实际循环。
  • setStatus()显示 ID 为statusMessage<div>内的状态信息。

36.4.2。我们如何避免阻止浏览器?

有几种方法可以阻止长时间运行的操作阻止浏览器:

  • 该操作可以异步传递其结果:某些操作(如下载)可以与 JavaScript 进程同时执行。触发此类操作的 JavaScript 代码会注册一个回调,一旦操作完成,就会调用该回调。通过任务队列处理调用。这种传递结果的方式称为 _ 异步 _,因为调用者不会等到结果准备就绪。正常函数调用同步传递其结果。

  • 在不同的进程中执行长计算:这可以通过所谓的 Web Workers 来完成。 Web Workers 是与主进程同时运行的重量级进程。它们中的每一个都有自己的运行时环境(全局变量等)。它们是完全隔离的,必须通过消息传递进行通信。有关更多信息,请参阅 MDN Web 文档

  • 在长时间计算期间休息。下一节将介绍如何操作。

36.4.3。休息一下

延迟ms毫秒后,以下全局函数执行其参数callback(简化了类型定义 - setTimeout()具有更多功能):

function setTimeout(callback: () => void, ms: number): any

该函数返回一个 _ 句柄 _(一个 ID),可通过以下全局函数用于 _ 清除 _(取消)它:

function clearTimeout(handle?: any): void

setTimeout()在浏览器和 Node.js 上都可用。下一节将展示它的实际效果。

** setTimeout()让任务休息**

另一种查看setTimeout()的方法是当前任务需要休息并在稍后通过回调继续。

36.4.4。运行到完成语义

JavaScript 为任务提供以下保证:

在执行下一个任务之前,每个任务总是完成(“运行到完成”)。

这意味着任务在处理数据时不必担心数据被更改(_ 并发修改 _)。这简化了 JavaScript 中的编程。

以下示例演示了此保证:

console.log('start');
setTimeout(() => {
  console.log('callback');
}, 0);
console.log('end');

// Output:
// 'start'
// 'end'
// 'callback'

setTimeout()将其参数放入任务队列。因此,在完成当前代码(任务)之后的某个时间执行该参数。

参数ms仅指定将任务放入队列的时间,它不指定任务何时运行。如果在队列中有一个永不终止的任务,它甚至可能永远不会运行。这就解释了为什么前面的代码在'delayed'之前记录'end',即使延迟是零毫秒。

36.5。用于提供异步结果的模式

这些是在 JavaScript 中异步传递结果的三种流行模式:

  • 活动
  • 回调
  • Promise

接下来解释前两种模式。Promise 将在下一章解释。

36.5.1。通过事件提供异步结果

作为模式的事件工作如下:

  • 它们用于异步传递值。
  • 他们这样做了零次或多次。
  • 这种模式有三个角色:
    • _ 事件 _(对象)携带要传递的数据。
    • _ 事件监听器 _ 是一个通过参数接收事件的函数。
    • _ 事件源 _ 发送事件并允许您注册事件侦听器。

JavaScript 的世界中存在这种模式的多种变体。接下来我们将看三个例子。

36.5.1.1。事件:IndexedDB

IndexedDB 是内置于 Web 浏览器中的数据库。这是使用它的一个例子:

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB有一种不寻常的调用操作方式:

  • 每个操作都有一个相关的方法来创建 _ 请求对象 _。例如,在行 A 中,操作是“打开”,方法是.open(),请求对象是openRequest

  • 操作的参数通过请求对象提供,而不是通过方法的参数提供。例如,事件侦听器(函数)存储在属性.onsuccess.onerror中。

  • 通过该方法将操作的调用添加到任务队列中(在行 A 中)。也就是说,我们在之后配置操作 ,其调用已经添加到队列中。只有 run-to-completion 语义才能保存我们的竞争条件,并确保在当前代码片段完成后运行。

36.5.1.2。活动:XMLHttpRequest

XMLHttpRequest API 允许您从 Web 浏览器中进行下载。这是你下载文件http://example.com/textfile.txt的方法:

const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
  if (xhr.status == 200) {
    processData(xhr.responseText);
  } else {
    assert.fail(new Error(xhr.statusText));
  }
};
xhr.onerror = () => { // (D)
  assert.fail(new Error('Network error'));
};
xhr.send(); // (E)

function processData(str) {
  assert.equal(str, 'Content of textfile.txt\n');
}

使用此 API,首先创建一个请求对象(行 A),然后对其进行配置,然后发送它(行 E)。配置包括:

  • 指定要使用的 HTTP 请求方法(B 行):GETPOSTPUT等。
  • 注册一个监听器(C 行),如果可以下载某些内容,则通知该监听器。在侦听器内部,您仍需要确定下载是否包含您请求的内容或通知您错误。请注意,某些结果数据通过xhr传送。我不是这种输入和输出数据混合的粉丝。
  • 注册如果出现网络错误,将收到通知的侦听器(D 行)。
36.5.1.3。事件:DOM

我们已经在关于阻止浏览器 UI的部分中看到了 DOM 事件的实际应用。以下代码还处理click事件:

const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)

function clickListener(event) {
  event.preventDefault(); // (C)
  console.log(event.shiftKey); // (D)
}

我们首先要求浏览器检索 ID 为my-link的 HTML 元素(行 A)。然后我们为所有click事件添加一个监听器(B 行)。在监听器中,我们首先告诉浏览器不要执行其默认操作 - 转到链接的目标(行 C)。然后,如果当前按下 shift 键,我们将登录到控制台(D 行)。

36.5.2。通过回调传递异步结果

回调是处理异步结果的另一种模式。它们仅用于一次性结果,并且具有比事件更简洁的优点。

例如,考虑一个函数readFile(),它读取文本文件并异步返回其内容。如果它使用 Node.js 样式的回调,这就是你调用readFile()的方法:

readFile('some-file.txt', {encoding: 'utf8'},
  (error, data) => {
    if (error) {
      assert.fail(error);
      return;
    }
    assert.equal(data, 'The content of some-file.txt\n');
  });

只有一个回调可以处理成功和失败。如果第一个参数不是null,则发生错误。否则,结果可以在第二个参数中找到。

练习:基于回调的代码

以下练习使用异步代码测试,这与同步代码测试不同。有关详细信息,请参阅有关 mocha 中异步测试的部分(在测试章节中)。

  • 从同步代码到基于回调的代码:exercises/async-js/read_file_cb_exrc.js
  • 实现基于回调的.map()版本:exercises/async-js/map_cb_test.js

36.6。异步代码:缺点

在许多情况下,无论是浏览器还是 Node.js,您别无选择:必须使用异步代码。在本章中,我们已经看到了这些代码可以使用的几种模式。所有这些都有两个缺点:

  • 异步代码比同步代码更冗长。
  • 如果调用异步代码,则代码也必须变为异步。那是因为你不能同步等待异步结果。异步代码具有传染性。

Promise 的第一个缺点变得不那么严重(在下一章中讨论),并且大部分消失了异步函数(在下一章之后的章节中介绍)。

唉,异步代码的传染性并没有消失。但是,使用异步功能可以轻松地在同步和异步之间切换,从而减轻了这种情况。

36.7。资源