34. JavaScript中的异步编程

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

34.1.1 同步函数

正常的函数都是 同步 的:调用者等待被调用者完成其计算。行A中的divideSync()就是一个同步函数的调用:

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

34.1.2 JavaScript在单个进程中顺序执行任务

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

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

这个循环也称为 事件循环 (event loop),因为事件(比如单击鼠标)会将任务添加到队列中。由于这种协作多任务的风格,我们不希望一个任务在等待来自服务器的结果时阻止其他任务的执行。下一小节将探讨如何处理这种情况。

34.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()向服务器发送请求
  • 然后当前任务main()执行结束,其他任务可以执行
  • 当来自服务器的响应到达时,它会是:
    • 一个错误err:会把以下任务添加到任务队列
      taskQueue.enqueue(() => callback(err));
      
    • 一个结果r:会把以下任务添加到任务队列
      taskQueue.enqueue(() => callback(null, r));
      

34.1.4 基于Promise的异步函数

Promises是两样东西:

  • 一个使回调工作更容易的标准模式
  • 异步函数(下一小节的主题)所基于的机制。

调用一个基于Promise的异步函数如下。

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

34.1.5 Async函数

一种看待Async函数的方法是对基于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只能在一种特殊的函数中使用,即 async函数 (注意关键字async在关键字function前面)。await暂停了当前的async函数并且从中返回值。一旦等待的结果准备好了,函数的执行将从它停止的地方继续执行。

34.1.6 接下来的步骤

  • 在本章中,我们将看到同步函数调用是如何工作的。我们还将探讨JavaScript通过 事件循环 在单个进程中执行代码的方法。
  • 本章还描述了通过回调实现的异步性。
  • 下面的章节将涵盖Promisesasync函数
  • 本系列关于异步编程的章节以关于异步迭代的章节结束,异步迭代与同步迭代相似,但是迭代后的值是异步交付的。

34.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)后,堆栈有一个条目:

  • Line 12 (location in top-level scope)

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

  • Line 10 (location in f())
  • Line 12 (location in top-level scope)

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

  • Line 7 (location in g())
  • Line 10 (location in f())
  • Line 12 (location in top-level scope)

日志记录错误在第3行,产生以下输出:

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

这就是所谓的Error对象创建位置的 堆栈跟踪 。注意,它记录调用的位置,而不是返回的位置。在第2行中创建异常是另一个调用。这就是堆栈跟踪在h()中包含一个位置的原因。在第3行之后,每个函数都会终止,每次都会从调用堆栈中删除顶部条目。函数f完成后,我们回到顶层作用域,堆栈为空。当代码片段结束时,就像隐式return。如果我们将代码片段视为正在执行的任务,那么返回空调用堆栈将结束该任务。

34.3 事件循环

默认情况下,JavaScript在一个进程中运行——在Web浏览器和Node.js中都是如此。所谓的 事件循环 按顺序执行该流程中的 任务 (代码段)。事件循环如图21所示。

图21:任务源 向任务队列添加要运行的代码,任务队列将被事件循环清空。图21:任务源 向任务队列添加要运行的代码,任务队列将被事件循环清空。

访问任务队列的双方:

  • 任务源将任务添加到队列中。其中一些源代码同时运行到JavaScript进程。例如,一个任务源负责处理用户界面事件:如果用户单击某个地方,并且注册了一个单击侦听器,则将该侦听器的调用添加到任务队列中。
  • 事件循环在JavaScript进程中持续运行。在每个循环迭代期间,它从队列中取出一个任务(如果队列是空的,它将等待,直到它不是空的)并执行它。当调用堆栈为空且有一个return时,则当前任务完成。控件返回到事件循环,然后事件循环从队列中检索下一个任务并执行它等等。

下面的JavaScript代码是一个近似的事件循环:

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

34.4 怎么避免阻塞JavaScript进程

34.4.1 浏览器的用户界面可以被阻塞

浏览器的许多用户界面机制也在JavaScript进程中运行(作为任务)。因此,长时间运行的JavaScript代码可能会阻塞用户界面。让我们看一个演示这一点的Web页面。你可以用两种方法来尝试这个页面:

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

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

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

其思想是单击“Block”,然后通过JavaScript执行一个长时间运行的循环。在该循环期间,你不能单击按钮,因为浏览器/JavaScript进程被阻塞。JavaScript代码的简化版本如下:

document.getElementById('block')
  .addEventListener('click', doBlock); // (A)
function doBlock(event) {
  // ···
  displayStatus('Blocking...');
  // ···
  sleep(5000); // (B)
  displayStatus('Done');
}
function sleep(milliseconds) {
  const start = Date.now();
  while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
  document.getElementById('statusMessage')
    .textContent = status;
}

这是几个代码的关键点:

  • 行A:当单击ID为block的HTML元素时,我们告诉浏览器调用doBlock()
  • doBlock()显示状态信息,然后调用sleep()将JavaScript进程阻塞5000毫秒(行B)。
  • sleep()通过循环阻塞JavaScript进程,直到经过了足够的时间才停止。
  • displayStatus()在ID为statusMessage的<div>中显示状态信息。

34.4.2 怎么避免阻塞浏览器?

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

  • 该操作可以异步 地交付其结果:有些操作(如下载)可以并发地执行到JavaScript进程。触发此类操作的JavaScript代码注册了一个回调,该回调在操作完成后使用结果调用。调用是通过任务队列处理的。这种交付结果的方式称为异步,因为调用者不会等到结果准备好了才进行调用。而普通函数调用会同步地交付它们的结果。
  • 在单独的进程中执行长时间的计算:这可以通过所谓的Web workers来完成。Web workers是与主进程并行运行的重量级进程。它们中的每一个都有自己的运行时环境(全局变量等)。它们是完全孤立的,必须通过消息传递进行通信。更多信息请参考MDN web文档
  • 在长时间的计算中休息。下一小节将解释如何进行。

34.4.3 休息

下面的全局函数在延迟ms毫秒后执行参数callback(类型签名被简化了——setTimeout()具有更多特性):

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

函数通过以下全局函数返回一个句柄 (ID),该句柄可用于清除 定时器(取消回调的执行):

function clearTimeout(handle?: any): void

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

setTimeout()让任务能够暂时停止

查看setTimeout()的另一种方法是,当前任务暂停,然后通过回调继续执行。

34.4.4 run-to-completion语义

JavaScript与任务之间约定:每个任务总是在执行下一个任务之前完成(“run to completion”)。因此,任务在处理数据时不必担心数据被更改(并发修改)。这简化了JavaScript的编程。下面的例子演示了这种保证:

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

setTimeout()将其参数放入任务队列。因此,该参数在当前代码段(任务)完全完成后的某个时候执行。参数ms只指定任务何时放入队列,而不是确切地何时运行。它甚至可能永远不会运行;例如,如果队列中有一个永远不会终止的任务在它之前。这就解释了为什么前面的代码日志'end''callback'之前,即使参数ms0

34.5 用于交付异步结果的模式

为了避免在等待长时间运行的操作完成时阻塞主进程,通常使用JavaScript异步地交付结果。以下是三种常用的模式:

  • Events(事件)
  • Callbacks(回调)
  • Promises

前两个模式将在接下来的两个小节中进行解释。Promises将在下一章解释。

34.5.1 通过事件交付异步结果

事件作为模式工作如下:

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

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

34.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”,方法是.open(),请求对象是openRequest
  • 操作的参数是通过请求对象提供的,而不是通过方法的参数提供的。例如,事件监听器(函数)存储在.onsuccess.onerror属性中。
  • 操作的调用通过方法(在第A行)添加到任务队列。也就是说,我们在将操作的调用添加到队列之后配置该操作。只有run-to-completion语义将我们从这里的竞争条件中拯救出来,并确保在当前代码片段完成后运行操作。
34.5.1.2 事件:XMLHttpRequest

XMLHttpRequestAPI允许我们在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行),如果出现网络错误,侦听器将收到通知。
34.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)。然后,我们为所有单击事件添加一个侦听器(行B)。在监听器中,我们首先告诉浏览器不要执行它的默认操作(第C行)——指向链接的目标。然后,如果当前按下shift键,我们将登录到控制台(第D行)。

34.5.2 通过回调传递异步结果

回调是处理异步结果的另一种模式。它们只用于一次性结果,并且具有比事件更简洁的优点。例如,考虑一个函数readFile(),它读取文本文件并异步返回其内容。如果readFile()使用Node.js风格的回调函数,这是你如何去调用它:

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.mjs
  • .map实现一个基于回调的版本:exercises/async-js/map_cb_test.mjs

34.6 异步代码:缺点

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

  • 异步代码比同步代码更冗长。
  • 如果您调用异步代码,那么您的代码也必须成为异步的。这是因为您不能同步地等待异步结果。异步代码具有传染性。

第一个缺点随着Promise(下一章将介绍)变得不那么严重,而随着async函数(再下一章将介绍)基本上消失了。唉,异步代码的传染性并没有消失。但是,使用async函数可以很容易地在同步和异步之间切换,这一事实缓解了这个问题。

34.7 资源

下一节:在本章中,我们将探索 Promise,另一种交付异步结果的模式。本章建立在前一章的基础上,以JavaScript异步编程为背景。