面向急性子的程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请不要屏蔽。)

39 JavaScript 中的异步编程



本章解释 JavaScript 中异步编程的基础知识。

39.1 JavaScript 异步编程路线图

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

  别担心细节!

如果您还不能理解所有内容,请不要担心。这只是对即将发生的事情的快速浏览。

39.1.1 同步函数

普通函数是*同步的*:调用者会一直等待,直到被调用者完成其计算。第 A 行中的 divideSync() 是一个同步函数调用

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

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

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

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

此循环也称为*事件循环*,因为事件(例如单击鼠标)会将任务添加到队列中。

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

39.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)

然后会发生以下步骤

39.1.4 基于 Promise 的异步函数

Promise 是两件事

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

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

39.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 会暂停当前异步函数并从中返回。一旦等待的结果准备好,函数的执行就会从中断的地方继续。

39.1.6 后续步骤

39.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) 之后,栈中有一个条目

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

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

在第 3 行记录 error 会产生以下输出

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

这就是所谓的*堆栈跟踪*,它记录了 Error 对象的创建位置。请注意,它记录的是调用发生的位置,而不是返回位置。在第 2 行创建异常是另一次调用。这就是堆栈跟踪包含 h() 内部位置的原因。

在第 3 行之后,每个函数都终止,并且每次都会从调用栈中删除顶部条目。函数 f 完成后,我们回到顶级作用域,并且栈为空。当代码片段结束时,就像隐式 return 一样。如果我们将代码片段视为要执行的任务,则在调用栈为空的情况下返回将结束该任务。

39.3 事件循环

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

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

两方访问任务队列

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

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

39.4 如何避免阻塞 JavaScript 进程

39.4.1 浏览器的用户界面可能会被阻塞

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

以下 HTML 是页面的用户界面

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

其思路是,您单击“阻塞”,然后通过 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;
}

这些是代码的关键部分

39.4.2 如何避免阻塞浏览器?

您可以通过多种方式防止长时间运行的操作阻塞浏览器

39.4.3 休息一下

以下全局函数在延迟 ms 毫秒后执行其参数 callback(类型签名已简化——setTimeout() 具有更多功能)

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

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

function clearTimeout(handle?: any): void

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

  setTimeout() 让任务休息一下

另一种看待 setTimeout() 的方式是,当前任务休息一下,稍后再通过回调继续。

39.4.4 运行至完成语义

JavaScript 对任务做出保证

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

因此,任务不必担心其数据在其处理过程中被更改(*并发修改*)。这简化了 JavaScript 中的编程。

以下示例演示了此保证

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

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

setTimeout() 将其参数放入任务队列中。因此,该参数会在当前代码段(任务)完全完成后才执行。

参数 ms 仅指定何时将任务放入队列中,而不指定何时准确运行。它甚至可能永远不会运行——例如,如果队列中有一个任务在其之前并且永远不会终止。这就解释了为什么前面的代码在 'callback' 之前记录 'end',即使参数 ms0

39.5 传递异步结果的模式

为了避免在等待长时间运行的操作完成时阻塞主进程,结果通常在 JavaScript 中异步传递。以下是三种流行的模式

前两种模式在接下来的两个小节中解释。Promise 在下一章中解释。

39.5.1 通过事件传递异步结果

事件作为一种模式的工作原理如下

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

39.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 调用操作的方式很不寻常

39.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 行)。配置包括

39.5.1.3 事件:DOM

我们已经在§39.4.1 “浏览器的用户界面可能会被阻塞”中看到了 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 行)。

39.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,则表示发生了错误。否则,结果可以在第二个参数中找到。

  练习:基于回调的代码

以下练习使用异步代码的测试,这些测试与同步代码的测试不同。有关更多信息,请参阅§10.3.2 “Mocha 中的异步测试”

39.6 异步代码:缺点

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

第一个缺点随着 Promise(在下一章中介绍)的出现而减轻,并随着异步函数(在下下一章中介绍)的出现而基本消失。

唉,异步代码的传染性并没有消失。但由于使用异步函数在同步和异步之间切换很容易,因此这种情况有所缓解。

39.7 资源