本章解释 JavaScript 中异步编程的基础知识。
本节提供 JavaScript 中异步编程内容的路线图。
别担心细节!
如果您还不能理解所有内容,请不要担心。这只是对即将发生的事情的快速浏览。
普通函数是*同步的*:调用者会一直等待,直到被调用者完成其计算。第 A 行中的 divideSync()
是一个同步函数调用
function main() {
try {
const result = divideSync(12, 3); // (A)
.equal(result, 4);
assertcatch (err) {
} .fail(err);
assert
} }
默认情况下,JavaScript *任务* 是在单个进程中顺序执行的函数。看起来像这样
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
此循环也称为*事件循环*,因为事件(例如单击鼠标)会将任务添加到队列中。
由于这种协作式多任务处理方式,我们不希望某个任务在例如等待来自服务器的结果时阻止其他任务执行。下一小节将探讨如何处理这种情况。
如果 divide()
需要服务器来计算其结果怎么办?然后应该以不同的方式传递结果:调用者不应该(同步地)等待结果准备好;而应该在结果准备好时(异步地)得到通知。异步传递结果的一种方法是为 divide()
提供一个回调函数,用于通知调用者。
function main() {
divideCallback(12, 3,
, result) => {
(errif (err) {
.fail(err);
assertelse {
} .equal(result, 4);
assert
};
}) }
当存在异步函数调用时
divideCallback(x, y, callback)
然后会发生以下步骤
divideCallback()
向服务器发送请求。main()
完成,可以执行其他任务。错误 err
:然后将以下任务添加到队列中。
.enqueue(() => callback(err)); taskQueue
result
值:然后将以下任务添加到队列中。
.enqueue(() => callback(null, result)); taskQueue
Promise 是两件事
调用基于 Promise 的函数如下所示。
function main() {
dividePromise(12, 3)
.then(result => assert.equal(result, 4))
.catch(err => assert.fail(err));
}
异步函数的一种理解方式是,它是基于 Promise 的代码的更好语法
async function main() {
try {
const result = await dividePromise(12, 3); // (A)
.equal(result, 4);
assertcatch (err) {
} .fail(err);
assert
} }
我们在第 A 行中调用的 dividePromise()
与上一节中的基于 Promise 的函数相同。但我们现在有了用于处理调用的同步语法。await
只能在一种特殊类型的函数(即*异步函数*)中使用(请注意关键字 function
前面的关键字 async
)。await
会暂停当前异步函数并从中返回。一旦等待的结果准备好,函数的执行就会从中断的地方继续。
每当一个函数调用另一个函数时,我们需要记住在后一个函数完成后返回到哪里。这通常是通过一个栈(*调用栈*)来完成的:调用者将要返回的位置压入栈中,被调用者在完成后跳转到该位置。
这是一个发生多次调用的示例
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)
之后,栈中有两个条目
f()
中的位置)在第 6 行调用函数 h(y + 1)
之后,栈中有三个条目
g()
中的位置)f()
中的位置)在第 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
一样。如果我们将代码片段视为要执行的任务,则在调用栈为空的情况下返回将结束该任务。
默认情况下,JavaScript 在单个进程中运行——无论是在 Web 浏览器还是 Node.js 中。所谓的*事件循环*在该进程中顺序执行*任务*(代码段)。事件循环如图 21 所示。
两方访问任务队列
*任务源*将任务添加到队列中。其中一些源与 JavaScript 进程并发运行。例如,一个任务源负责用户界面事件:如果用户单击某个位置并且注册了单击侦听器,则会将该侦听器的调用添加到任务队列中。
*事件循环*在 JavaScript 进程中持续运行。在每次循环迭代期间,它都会从队列中取出一个任务(如果队列为空,它会一直等待,直到队列非空)并执行它。当调用栈为空并且存在 return
时,该任务完成。控制权返回到事件循环,然后事件循环从队列中检索下一个任务并执行它。依此类推。
以下 JavaScript 代码是事件循环的近似值
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
浏览器的许多用户界面机制也在 JavaScript 进程中运行(作为任务)。因此,长时间运行的 JavaScript 代码可能会阻塞用户界面。让我们看一个演示这一点的网页。您可以通过两种方式试用该页面
demos/async-js/blocking.html
以下 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;
}
这些是代码的关键部分
block
的 HTML 元素时,就调用 doBlock()
。doBlock()
显示状态信息,然后调用 sleep()
以阻塞 JavaScript 进程 5000 毫秒(第 B 行)。sleep()
通过循环阻塞 JavaScript 进程,直到经过足够的时间。displayStatus()
在 ID 为 statusMessage
的 <div>
中显示状态消息。您可以通过多种方式防止长时间运行的操作阻塞浏览器
该操作可以*异步*传递其结果:某些操作(例如下载)可以在 JavaScript 进程之外并发执行。触发此类操作的 JavaScript 代码会注册一个回调,该回调会在操作完成后使用结果调用。调用是通过任务队列处理的。这种传递结果的方式称为*异步*,因为调用者不会等待结果准备好。普通函数调用会同步传递其结果。
在单独的进程中执行长时间计算:这可以通过所谓的*Web Worker*来完成。Web Worker 是与主进程并发运行的重量级进程。它们中的每一个都有自己的运行时环境(全局变量等)。它们是完全隔离的,必须通过消息传递进行通信。有关更多信息,请参阅MDN Web 文档。
在长时间计算期间休息一下。下一小节将解释如何做到这一点。
以下全局函数在延迟 ms
毫秒后执行其参数 callback
(类型签名已简化——setTimeout()
具有更多功能)
function setTimeout(callback: () => void, ms: number): any
该函数返回一个*句柄*(一个 ID),可用于通过以下全局函数*清除*超时(取消回调的执行)
function clearTimeout(handle?: any): void
setTimeout()
在浏览器和 Node.js 上都可用。下一小节将展示它的实际应用。
setTimeout()
让任务休息一下
另一种看待 setTimeout()
的方式是,当前任务休息一下,稍后再通过回调继续。
JavaScript 对任务做出保证
每个任务总是在执行下一个任务之前完成(“运行至完成”)。
因此,任务不必担心其数据在其处理过程中被更改(*并发修改*)。这简化了 JavaScript 中的编程。
以下示例演示了此保证
console.log('start');
setTimeout(() => {
console.log('callback');
, 0);
}console.log('end');
// Output:
// 'start'
// 'end'
// 'callback'
setTimeout()
将其参数放入任务队列中。因此,该参数会在当前代码段(任务)完全完成后才执行。
参数 ms
仅指定何时将任务放入队列中,而不指定何时准确运行。它甚至可能永远不会运行——例如,如果队列中有一个任务在其之前并且永远不会终止。这就解释了为什么前面的代码在 'callback'
之前记录 'end'
,即使参数 ms
为 0
。
为了避免在等待长时间运行的操作完成时阻塞主进程,结果通常在 JavaScript 中异步传递。以下是三种流行的模式
前两种模式在接下来的两个小节中解释。Promise 在下一章中解释。
事件作为一种模式的工作原理如下
在 JavaScript 的世界中,存在这种模式的多种变体。接下来我们将看三个例子。
IndexedDB 是一个内置于 Web 浏览器中的数据库。这是一个使用它的例子
const openRequest = indexedDB.open('MyDatabase', 1); // (A)
.onsuccess = (event) => {
openRequestconst db = event.target.result;
// ···
;
}
.onerror = (error) => {
openRequestconsole.error(error);
; }
indexedDB
调用操作的方式很不寻常
每个操作都有一个用于创建请求对象的关联方法。例如,在 A 行中,操作是“打开”,方法是 .open()
,请求对象是 openRequest
。
操作的参数是通过请求对象提供的,而不是通过方法的参数提供的。例如,事件监听器(函数)存储在属性 .onsuccess
和 .onerror
中。
操作的调用通过方法(在 A 行中)添加到任务队列中。也就是说,我们在操作的调用已添加到队列中之后对其进行配置。只有运行至完成语义才能使我们免于出现竞争条件,并确保操作在当前代码片段完成后运行。
XMLHttpRequest
XMLHttpRequest
API 允许我们从 Web 浏览器中进行下载。这就是我们下载文件 http://example.com/textfile.txt
的方式
const xhr = new XMLHttpRequest(); // (A)
.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
xhrif (xhr.status == 200) {
processData(xhr.responseText);
else {
} .fail(new Error(xhr.statusText));
assert
};
}.onerror = () => { // (D)
xhr.fail(new Error('Network error'));
assert;
}.send(); // (E)
xhr
function processData(str) {
.equal(str, 'Content of textfile.txt\n');
assert }
使用此 API,我们首先创建一个请求对象(A 行),然后对其进行配置,然后激活它(E 行)。配置包括
GET
、POST
、PUT
等。xhr
传递的。(我不喜欢这种输入和输出数据的混合方式。)我们已经在§39.4.1 “浏览器的用户界面可能会被阻塞”中看到了 DOM 事件的实际应用。以下代码还处理 click
事件
const element = document.getElementById('my-link'); // (A)
.addEventListener('click', clickListener); // (B)
element
function clickListener(event) {
event.preventDefault(); // (C)
console.log(event.shiftKey); // (D)
}
我们首先要求浏览器检索 ID 为 'my-link'
的 HTML 元素(A 行)。然后,我们为所有 click
事件添加一个监听器(B 行)。在监听器中,我们首先告诉浏览器不要执行其默认操作(C 行)——转到链接的目标。然后,如果当前按下了 Shift 键,我们会记录到控制台(D 行)。
回调是处理异步结果的另一种模式。它们仅用于一次性结果,并且具有比事件更简洁的优点。
例如,考虑一个函数 readFile()
,它读取文本文件并异步返回其内容。如果您使用 Node.js 风格的回调,则可以这样调用 readFile()
readFile('some-file.txt', {encoding: 'utf8'},
, data) => {
(errorif (error) {
.fail(error);
assertreturn;
}.equal(data, 'The content of some-file.txt\n');
assert; })
有一个回调可以同时处理成功和失败。如果第一个参数不是 null
,则表示发生了错误。否则,结果可以在第二个参数中找到。
练习:基于回调的代码
以下练习使用异步代码的测试,这些测试与同步代码的测试不同。有关更多信息,请参阅§10.3.2 “Mocha 中的异步测试”。
exercises/async-js/read_file_cb_exrc.mjs
.map()
版本:exercises/async-js/map_cb_test.mjs
在许多情况下,无论是浏览器还是 Node.js,您都没有选择,必须使用异步代码。在本章中,我们已经看到了此类代码可以使用的几种模式。它们都有两个缺点
第一个缺点随着 Promise(在下一章中介绍)的出现而减轻,并随着异步函数(在下下一章中介绍)的出现而基本消失。
唉,异步代码的传染性并没有消失。但由于使用异步函数在同步和异步之间切换很容易,因此这种情况有所缓解。