异步编程
计算机的核心部分,执行构成程序的单个步骤的部分被称为处理器。我们到目前为止看到的程序会使处理器一直忙碌,直到它们完成工作。循环操作数字等程序的执行速度,几乎完全取决于计算机的处理器和内存速度。
但许多程序会与处理器外部的事物进行交互。例如,它们可能通过计算机网络进行通信,或从硬盘请求数据——这比从内存获取数据要慢得多。
当发生这种情况时,让处理器处于空闲状态会很可惜——在此期间,它可能还可以做一些其他的工作。部分原因是您的操作系统会处理这个问题,操作系统将在多个正在运行的程序之间切换处理器。但这对我们来说没有帮助,因为我们希望一个单个程序能够在等待网络请求时继续执行。
异步性
在同步编程模型中,事情是按顺序发生的。当您调用一个执行长时间运行操作的函数时,它只有在操作完成并能够返回结果后才会返回。这会使您的程序停止,直到操作完成。
异步模型允许多个事情同时发生。当您启动一个操作时,您的程序会继续运行。当操作完成时,程序会收到通知并获得结果(例如,从磁盘读取的数据)。
我们可以使用一个简单的例子来比较同步和异步编程:一个通过网络发出两个请求,然后合并结果的程序。
在同步环境中,如果请求函数只有在完成工作后才返回,那么执行此任务最简单的方法是按顺序发出请求。这样做有一个缺点,即只有第一个请求完成后才会启动第二个请求。总共花费的时间至少是两个响应时间之和。
在同步系统中,解决这个问题的方法是启动额外的控制线程。线程是另一个运行的程序,其执行可以由操作系统与其他程序交织在一起——由于大多数现代计算机包含多个处理器,因此多个线程甚至可以在不同的处理器上同时运行。第二个线程可以启动第二个请求,然后这两个线程都等待它们的请求返回结果,之后它们重新同步以合并结果。
在下图中,粗线表示程序正常运行的时间,细线表示等待网络的时间。在同步模型中,网络花费的时间是给定控制线程的时间轴的一部分。在异步模型中,启动网络操作允许程序在网络通信同时进行时继续运行,并在网络通信完成时通知程序。
另一种描述区别的方式是,在同步模型中,等待操作完成是隐式的,而在异步模型中,等待操作完成是显式的——在我们控制之下。
异步性是双向的。它使表达不适合控制直线模型的程序变得更容易,但也可能使表达确实遵循直线的程序变得更加尴尬。我们将在本章后面看到一些减少这种尴尬的方法。
两个主要的 JavaScript 编程平台——浏览器和 Node.js——都使可能需要一段时间才能完成的操作异步化,而不是依赖线程。由于用线程进行编程是出了名的困难(当程序同时执行多项任务时,理解程序的行为要困难得多),因此这通常被认为是一件好事。
回调
异步编程的一种方法是让需要等待某些东西的函数接受一个额外的参数,即回调函数。异步函数启动一个进程,设置回调函数在进程完成后被调用的条件,然后返回。
例如,setTimeout
函数(在 Node.js 和浏览器中都可用)会等待给定的毫秒数,然后调用一个函数。
setTimeout(() => console.log("Tick"), 500);
等待通常不是重要的工作,但当您需要安排在特定时间发生某些事情,或者检查某个操作是否花费的时间过长时,它非常有用。
另一个常见异步操作的示例是从设备存储中读取文件。假设您有一个函数 readTextFile
,它读取文件的內容作为字符串,并将其传递给回调函数。
readTextFile("shopping_list.txt", content => { console.log(`Shopping List:\n${content}`); }); // → Shopping List: // → Peanut butter // → Bananas
readTextFile
函数不是标准 JavaScript 的一部分。我们将在后面的章节中学习如何在浏览器和 Node.js 中读取文件。
使用回调函数依次执行多个异步操作意味着您必须不断传递新的函数来处理操作完成后计算的继续。比较两个文件并产生一个布尔值(指示它们的內容是否相同)的异步函数可能看起来像这样
function compareFiles(fileA, fileB, callback) { readTextFile(fileA, contentA => { readTextFile(fileB, contentB => { callback(contentA == contentB); }); }); }
这种编程风格是可行的,但缩进级别随着每个异步操作而增加,因为您最终会进入另一个函数。执行更复杂的操作(例如将异步操作包装在循环中)会变得很麻烦。
在某种程度上,异步性是具有传染性的。任何调用异步工作的函数的函数本身都必须是异步的,使用回调或类似机制来传递其结果。调用回调比简单地返回值更复杂,也更容易出错,因此需要以这种方式构建程序的大部分内容并不理想。
Promise
构建异步程序的另一种稍微不同的方法是让异步函数返回一个表示其(未来)结果的对象,而不是传递回调函数。这样,这些函数实际上会返回一些有意义的东西,程序的形状更接近于同步程序的形状。
这就是标准类 Promise
的作用。Promise 是一个收据,它代表一个可能尚未可用的值。它提供了一个 then
方法,允许您注册一个函数,该函数应该在等待的操作完成时被调用。当 Promise 被解析时,这意味着它的值变得可用,这些函数(可能有多个)将使用结果值被调用。在已经解析的 Promise 上调用 then
是可能的——您的函数仍然会被调用。
创建 Promise 最简单的方法是调用 Promise.resolve
。此函数确保您提供的值被包装在一个 Promise 中。如果它已经是 Promise,则直接返回它。否则,您将获得一个新的 Promise,它会立即解析,并将您的值作为其结果。
let fifteen = Promise.resolve(15); fifteen.then(value => console.log(`Got ${value}`)); // → Got 15
要创建一个不会立即解析的 Promise,可以使用 Promise
作为构造函数。它有一个有点奇怪的接口:构造函数期望一个函数作为其参数,它会立即调用该函数,并将一个函数传递给它,该函数可以用来解析 Promise。
例如,以下是如何为 readTextFile
函数创建一个基于 Promise 的接口
function textFile(filename) { return new Promise(resolve => { readTextFile(filename, text => resolve(text)); }); } textFile("plans.txt").then(console.log);
请注意,与回调风格的函数相比,这个异步函数返回了一个有意义的值——一个在将来某个时间点为您提供文件內容的 Promise。
then
方法的一个有用之处在于它本身会返回另一个 Promise。它解析为回调函数返回的值,或者,如果该返回值是一个 Promise,则解析为该 Promise 解析的值。因此,您可以将多个 then
调用“链接”在一起,以建立一系列异步操作。
此函数读取一个包含文件名列表的文件,并返回该列表中随机文件的內容,展示了这种异步 Promise 管道
function randomFile(listFile) { return textFile(listFile) .then(content => content.trim().split("\n")) .then(ls => ls[Math.floor(Math.random() * ls.length)]) .then(filename => textFile(filename)); }
该函数返回此 then
调用链的结果。初始 Promise 获取文件名列表作为字符串。第一个 then
调用将该字符串转换为一行数组,生成一个新的 Promise。第二个 then
调用从该数组中选择一行,生成第三个 Promise,该 Promise 生成单个文件名。最后一个 then
调用读取此文件,因此整个函数的结果是一个返回随机文件內容的 Promise。
在此代码中,前两个 then
调用中使用的函数返回一个普通的值,该值将在函数返回时立即传递到 then
返回的 Promise 中。最后一个 then
调用返回一个 Promise(textFile(filename)
),使其成为一个实际的异步步骤。
也可以在单个 then
回调中执行所有这些步骤,因为只有最后一步才是实际的异步步骤。但是,仅执行一些同步数据转换的 then
包装器通常很有用,例如,当您要返回一个 Promise,它生成一些异步结果的处理版本时。
function jsonFile(filename) { return textFile(filename).then(JSON.parse); } jsonFile("package.json").then(console.log);
通常,将 Promise 视为一种允许代码忽略值何时到达的机制是很有用的。普通值必须在我们可以引用它之前实际存在。Promise 值是可能已经存在或可能在将来某个时间点出现的 value。通过使用 then
调用将 Promise 链接在一起,以 Promise 定义的计算将异步执行,因为它们的输入变得可用。
失败
常规 JavaScript 计算可能会通过抛出异常而失败。异步计算通常需要类似的东西。网络请求可能失败,文件可能不存在,或者异步计算的一部分代码可能会抛出异常。
回调式异步编程中最紧迫的问题之一是,它使确保将失败正确报告给回调变得极其困难。
一种常见的约定是使用回调的第一个参数来指示操作失败,而使用第二个参数来传递操作成功时产生的值。
someAsyncFunction((error, value) => { if (error) handleError(error); else processValue(value); });
这样的回调函数必须始终检查它们是否收到异常,并确保它们引起的任何问题(包括它们调用的函数抛出的异常)都被捕获并传递给正确的函数。
Promise 使这变得更容易。它们可以被解决(操作成功完成)或拒绝(操作失败)。解决处理程序(如使用 then
注册的处理程序)仅在操作成功时调用,拒绝会传播到 then
返回的新 Promise。当处理程序抛出异常时,这会自动导致其 then
调用产生的 Promise 被拒绝。如果异步操作链中的任何元素失败,则整个链的结果将被标记为拒绝,并且不会调用超出失败点的任何成功处理程序。
就像解决 Promise 提供了一个值一样,拒绝 Promise 也提供了一个值,通常称为拒绝的原因。当处理程序函数中的异常导致拒绝时,异常值将用作原因。类似地,当处理程序返回被拒绝的 Promise 时,该拒绝会流入下一个 Promise。有一个 Promise.reject
函数可以创建一个新的、立即被拒绝的 Promise。
为了显式地处理此类拒绝,Promise 具有一个 catch
方法,该方法会注册一个处理程序,以便在 Promise 被拒绝时调用,类似于 then
处理程序如何处理正常解决。它也与 then
非常相似,因为它返回一个新的 Promise,该 Promise 在正常解决时解析为原始 Promise 的值,否则解析为 catch
处理程序的结果。如果 catch
处理程序抛出错误,则新的 Promise 也将被拒绝。
作为一种简写,then
还接受拒绝处理程序作为第二个参数,因此您可以在单个方法调用中安装两种类型的处理程序:.
。
传递给 Promise
构造函数的函数除了解析函数之外,还接收第二个参数,它可以使用该参数来拒绝新的 Promise。
当我们的 readTextFile
函数遇到问题时,它将错误传递给其回调函数作为第二个参数。我们的 textFile
包装器实际上应该检查该参数,以便失败导致它返回的 Promise 被拒绝。
function textFile(filename) { return new Promise((resolve, reject) => { readTextFile(filename, (text, error) => { if (error) reject(error); else resolve(text); }); }); }
因此,通过调用 then
和 catch
创建的 Promise 值链形成了一个管道,异步值或失败会通过该管道移动。由于此类链是通过注册处理程序创建的,因此每个链接都有一个成功处理程序或一个拒绝处理程序(或两者都有)与其相关联。与结果类型(成功或失败)不匹配的处理程序将被忽略。匹配的处理程序将被调用,它们的结果将决定接下来是什么样的值——成功时返回非 Promise 值,失败时抛出异常,以及它们返回 Promise 时 Promise 的结果。
new Promise((_, reject) => reject(new Error("Fail"))) .then(value => console.log("Handler 1:", value)) .catch(reason => { console.log("Caught failure " + reason); return "nothing"; }) .then(value => console.log("Handler 2:", value)); // → Caught failure Error: Fail // → Handler 2: nothing
第一个 then
处理程序函数不会被调用,因为在管道的那个点,Promise 持有一个拒绝。catch
处理程序处理该拒绝并返回一个值,该值将传递给第二个 then
处理程序函数。
就像未捕获的异常由环境处理一样,JavaScript 环境可以检测到 Promise 拒绝何时未被处理,并将此报告为错误。
卡拉
柏林是一个阳光明媚的日子。旧的、退役的机场的跑道上挤满了骑自行车的人和滑旱冰的人。在垃圾箱附近的一块草地上,一群乌鸦吵闹地乱窜,试图说服一群游客放弃他们的三明治。
其中一只乌鸦很突出——一只体型庞大的蓬头垢面的母乌鸦,右翼上有几根白羽毛。她以一种熟练而自信的态度引诱着人们,这表明她已经这样做很久了。当一位老人被另一只乌鸦的滑稽动作分散注意力时,她随意地俯冲下来,从他的手中抢走了他吃了一半的圆面包,然后飞走了。
与这群人中其他看起来很乐意在这里消磨一整天时间胡闹的人不同,这只体型庞大的乌鸦看起来很有目标。她带着她的战利品,直接飞向机库建筑的屋顶,消失在通风口里。
在建筑物内部,你可以听到奇怪的敲击声——轻柔但持续。它来自一处未完成的楼梯间屋顶下的狭窄空间。乌鸦正坐在那里,周围是她的偷来的零食、半打智能手机(其中几个开着)和一堆电线。她用喙快速地敲击其中一部手机的屏幕。上面出现了文字。如果你不知道真相,你会以为她在打字。
这只乌鸦在同类中被称为“cāāw-krö”。但是,由于这些声音不适合人类的声带,我们将称她为卡拉。
卡拉是一只有点奇怪的乌鸦。在她年轻的时候,她对人类语言很着迷,她偷听人们说话,直到她对人们说的话有了很好的理解。后来,她的兴趣转向了人类科技,她开始偷手机来研究它们。她目前的项目是学习编程。她在隐蔽的实验室里打字的文字实际上是一段异步 JavaScript 代码。
闯入
卡拉喜欢互联网。令人恼火的是,她正在使用的手机即将用完预付数据。这栋楼里有无线网络,但需要密码才能访问。
幸运的是,大楼里的无线路由器已经 20 年了,安全性很差。经过一番调查,卡拉发现网络身份验证机制存在一个她可以利用的缺陷。当设备加入网络时,它必须发送正确的六位数密码。接入点将根据是否提供了正确的代码来回复成功或失败消息。但是,当发送部分代码(例如,只有三位数)时,响应会根据这些数字是否为代码的正确开头而有所不同。发送错误的数字会立即返回失败消息。当发送正确的数字时,接入点会等待更多数字。
这使得能够大大加快数字的猜测速度。卡拉可以通过依次尝试每个数字来找到第一个数字,直到找到一个不会立即返回失败的数字。有了第一个数字,她就可以用相同的方法找到第二个数字,依此类推,直到她知道整个密码。
假设卡拉有一个 joinWifi
函数。给定网络名称和密码(作为字符串),该函数尝试加入网络,返回一个 Promise,如果成功则解析,如果身份验证失败则拒绝。她首先需要一种方法来包装一个 Promise,以便它在超时后自动拒绝,以允许程序在接入点没有响应的情况下快速继续。
function withTimeout(promise, time) { return new Promise((resolve, reject) => { promise.then(resolve, reject); setTimeout(() => reject("Timed out"), time); }); }
这利用了 Promise 只能被解决或拒绝一次的事实。如果作为其参数给出的 Promise 首先被解决或拒绝,那么结果将是 withTimeout
返回的 Promise 的结果。另一方面,如果 setTimeout
首先触发并拒绝 Promise,则任何进一步的解决或拒绝调用都会被忽略。
为了找到整个密码,程序需要重复查找下一个数字,方法是尝试每个数字。如果身份验证成功,我们就知道我们找到了目标。如果它立即失败,我们就知道该数字是错误的,必须尝试下一个数字。如果请求超时,我们找到了另一个正确的数字,必须通过添加另一个数字来继续。
因为你不能在 for
循环中等待一个 Promise,所以卡拉使用一个递归函数来驱动这个过程。在每次调用时,此函数会获取到目前为止已知的代码,以及要尝试的下一个数字。根据发生的情况,它可能会返回一个完成的代码,或者调用自身,以开始破解代码中的下一个位置或尝试使用另一个数字再次尝试。
function crackPasscode(networkID) { function nextDigit(code, digit) { let newCode = code + digit; return withTimeout(joinWifi(networkID, newCode), 50) .then(() => newCode) .catch(failure => { if (failure == "Timed out") { return nextDigit(newCode, 0); } else if (digit < 9) { return nextDigit(code, digit + 1); } else { throw failure; } }); } return nextDigit("", 0); }
接入点往往会在大约 20 毫秒内对错误的身份验证请求做出响应,因此为了安全起见,此函数会在请求超时之前等待 50 毫秒。
crackPasscode("HANGAR 2").then(console.log); // → 555555
卡拉歪着头,叹了口气。如果代码稍微难猜一点,这会更令人满意。
异步函数
即使有了 Promise,这种异步代码也很难编写。Promise 通常需要以冗长、随意的方式联系在一起。为了创建一个异步循环,卡拉被迫引入了一个递归函数。
破解函数实际上做的只是完全线性的——它总是在开始下一个操作之前等待上一个操作完成。在同步编程模型中,表达起来会更直观。
好消息是,JavaScript 允许你编写伪同步代码来描述异步计算。一个 async
函数隐式地返回一个 Promise,并且可以在其函数体中 await
其他 Promise,从而使代码看起来像是同步的。
async function crackPasscode(networkID) { for (let code = "";;) { for (let digit = 0;; digit++) { let newCode = code + digit; try { await withTimeout(joinWifi(networkID, newCode), 50); return newCode; } catch (failure) { if (failure == "Timed out") { code = newCode; break; } else if (digit == 9) { throw failure; } } } } }
这个版本更清楚地展示了该函数的双重循环结构(内部循环尝试从 0 到 9 的数字,外部循环向密码添加数字)。
一个 async
函数用在 function
关键字之前的 async
字来标记。方法也可以通过在它们的名字之前写 async
来变成 async
的。当这样的函数或方法被调用时,它会返回一个 Promise。一旦函数返回了某些东西,该 Promise 就会被解析。如果函数体抛出了异常,则该 Promise 会被拒绝。
在一个 async
函数内部,可以在表达式前面加上 await
来等待一个 Promise 被解析,然后才继续执行函数。如果 Promise 被拒绝,则在 await
的位置会抛出一个异常。
这样的函数不再像常规的 JavaScript 函数那样从头到尾地运行。相反,它可以在任何包含 await
的地方被“冻结”,并且可以在以后的时间恢复执行。
对于大多数异步代码,这种表示法比直接使用 Promise 更方便。你仍然需要了解 Promise,因为在很多情况下,你仍然会直接与它们交互。但是,在将它们连接在一起时,async
函数通常比 then
调用链更容易编写。
生成器
函数可以被暂停然后恢复的能力并不局限于 async
函数。JavaScript 还提供了一种名为生成器函数的功能。它们与 async
函数类似,但没有 Promise。
当你在 function
关键字后面加上星号 (function*
) 定义一个函数时,它就会变成一个生成器。当你调用一个生成器时,它会返回一个迭代器,我们已经在第 6 章中看到了。
function* powers(n) { for (let current = n;; current *= n) { yield current; } } for (let power of powers(3)) { if (power > 50) break; console.log(power); } // → 3 // → 9 // → 27
最初,当你调用 powers
函数时,该函数会被冻结在开始位置。每次你调用迭代器上的 next
方法时,函数就会运行,直到遇到 yield
表达式,该表达式会暂停函数,并将 yield
的值设置为迭代器产生的下一个值。当函数返回时(示例中的函数永远不会返回),迭代器就完成了。
当你使用生成器函数时,编写迭代器通常会容易得多。Group
类(来自第 6 章中的练习)的迭代器可以使用这个生成器来编写
Group.prototype[Symbol.iterator] = function*() { for (let i = 0; i < this.members.length; i++) { yield this.members[i]; } };
不再需要创建一个对象来保存迭代状态——生成器会自动在每次 yield
时保存其本地状态。
这样的 yield
表达式只能直接出现在生成器函数本身中,不能出现在你在其中定义的内部函数中。生成器在 yield
时保存的状态只是其本地环境和它 yield
的位置。
一个 async
函数是一种特殊的生成器。它在被调用时会产生一个 Promise,在返回(完成)时解析,在抛出异常时拒绝。每当它 yield
(await
)一个 Promise 时,该 Promise 的结果(值或抛出的异常)就是 await
表达式的结果。
乌鸦的艺术项目
一天早上,卡拉醒来,听到她机库外的停机坪上传来不寻常的声音。她跳到屋顶的边缘,看到人类正在为某个东西做准备。那里有很多电缆,一个舞台,以及正在建造的某种大型黑墙。
卡拉作为一只好奇的乌鸦,仔细观察了这堵墙。它似乎由许多大型带玻璃前挡板的设备组成,这些设备连接到电缆上。设备的背面写着“LedTec SIG-5030”。
快速搜索网络,找到了这些设备的用户手册。它们似乎是交通标志,带有一个可编程的琥珀色 LED 灯矩阵。人类的意图可能是要在他们的活动中使用它们来显示某种信息。有趣的是,这些屏幕可以通过无线网络进行编程。它们是否连接到建筑物的本地网络?
网络上的每个设备都有一个IP 地址,其他设备可以使用它向该设备发送消息。我们将在第 13 章中详细介绍。卡拉注意到她的手机都获得了类似于 10.0.0.20
或 10.0.0.33
的地址。也许值得尝试向所有这样的地址发送消息,看看其中是否有任何一个响应手册中描述的标志界面。
第 18 章展示了如何在真实网络上发出真实请求。在本章中,我们将使用一个名为 request
的简化虚拟函数来进行网络通信。该函数接受两个参数——网络地址和消息(可以是任何可以作为 JSON 发送的东西)——并返回一个 Promise,该 Promise 或者解析为来自给定地址的机器的响应,或者如果出现问题则拒绝。
根据手册,你可以通过发送包含类似于 {"command": "display", "data": [0, 0, 3, …]}
内容的消息来更改 SIG-5030 标志上显示的内容,其中 data
为每个 LED 点保存一个数字,表示其亮度——0 表示关闭,3 表示最大亮度。每个标志宽 50 个灯,高 30 个灯,因此更新命令应该发送 1,500 个数字。
这段代码向本地网络上的所有地址发送了一个显示更新消息,以查看哪些地址能够接收。IP 地址中的每个数字都可以从 0 到 255。在发送的数据中,它激活了与网络地址最后一个数字相对应的数量的灯。
for (let addr = 1; addr < 256; addr++) { let data = []; for (let n = 0; n < 1500; n++) { data.push(n < addr ? 3 : 0); } let ip = `10.0.0.${addr}`; request(ip, {command: "display", data}) .then(() => console.log(`Request to ${ip} accepted`)) .catch(() => {}); }
由于大多数这些地址可能不存在或不会接受此类消息,因此 catch
调用确保网络错误不会使程序崩溃。所有请求都立即发出,而不会等待其他请求完成,这样可以避免在某些机器没有响应时浪费时间。
卡拉发射了她的网络扫描,然后回到外面查看结果。令她高兴的是,所有屏幕现在都显示了其左上角的一条光带。它们确实在本地网络上,并且确实接受命令。她很快记下了每个屏幕上显示的数字。共有九个屏幕,排列成三列三行。它们的网络地址如下
const screenAddresses = [ "10.0.0.44", "10.0.0.45", "10.0.0.41", "10.0.0.31", "10.0.0.40", "10.0.0.42", "10.0.0.48", "10.0.0.47", "10.0.0.46" ];
现在,这为各种恶作剧提供了可能性。她可以在墙上用巨大的字母显示“乌鸦统治,人类流口水”。但这感觉有点粗鲁。相反,她计划在晚上在所有屏幕上显示一只飞翔的乌鸦的视频。
卡拉找到了一段合适的视频片段,其中一秒半的镜头可以重复播放,从而创建一个循环视频,显示乌鸦的翅膀拍打。为了适应九个屏幕(每个屏幕可以显示 50×30 像素),卡拉剪切并调整视频大小,得到一系列 150×90 的图像,每秒 10 幅。然后将它们分别剪切成九个矩形,并进行处理,使视频中的暗点(乌鸦所在的地方)显示明亮的光,而亮点(没有乌鸦)保持黑暗,这样应该会创造出一个琥珀色的乌鸦在黑色背景上飞行的效果。
她已经设置了 clipImages
变量来保存一组帧,其中每帧都用九组像素数组来表示——每个屏幕一组——格式与标志期望的一致。
要显示视频的单帧,卡拉需要一次向所有屏幕发送请求。但她也需要等待这些请求的结果,这样做的目的是为了防止在当前帧发送完毕之前就开始发送下一帧,以及为了在请求失败时及时发现。
Promise
有一个静态方法 all
,它可以用于将一组 Promise 转换为一个单独的 Promise,该 Promise 解析为一组结果。这提供了一种方便的方式,可以使一些异步操作并行发生,等待它们全部完成,然后对它们的结果执行某些操作(或者至少等待它们以确保它们不会失败)。
function displayFrame(frame) { return Promise.all(frame.map((data, i) => { return request(screenAddresses[i], { command: "display", data }); })); }
这会遍历 frame
中的图像(它是一个包含显示数据数组的数组),以创建一个请求 Promise 数组。然后,它返回一个 Promise,该 Promise 将所有这些 Promise 合并在一起。
为了能够停止播放视频,该过程被封装在一个类中。该类有一个异步的 play
方法,它返回一个 Promise,该 Promise 只有在通过 stop
方法再次停止播放时才会解析。
function wait(time) { return new Promise(accept => setTimeout(accept, time)); } class VideoPlayer { constructor(frames, frameTime) { this.frames = frames; this.frameTime = frameTime; this.stopped = true; } async play() { this.stopped = false; for (let i = 0; !this.stopped; i++) { let nextFrame = wait(this.frameTime); await displayFrame(this.frames[i % this.frames.length]); await nextFrame; } } stop() { this.stopped = true; } }
wait
函数将 setTimeout
封装在一个 Promise 中,该 Promise 在给定的毫秒数后解析。这对于控制播放速度很有用。
let video = new VideoPlayer(clipImages, 100); video.play().catch(e => { console.log("Playback failed: " + e); }); setTimeout(() => video.stop(), 15000);
在屏幕墙立着的整整一周里,每天晚上,当夜幕降临的时候,一个巨大的橙色发光鸟会神秘地出现在上面。
事件循环
一个异步程序从运行其主脚本开始,该脚本通常会设置稍后调用的回调函数。主脚本以及回调函数都会不间断地运行到完成。但在它们之间,程序可能会处于空闲状态,等待某些事件发生。
因此,回调函数不是由安排它们的代码直接调用。如果我在一个函数内部调用 setTimeout
,那么该函数将在回调函数被调用时已经返回。并且当回调函数返回时,控制权不会回到安排它的函数。
异步行为发生在它自己的空函数调用栈上。这是在没有 Promise 的情况下,跨异步代码管理异常如此困难的原因之一。由于每个回调都从一个几乎为空的栈开始,因此当回调抛出异常时,您的catch
处理程序将不在栈中。
try { setTimeout(() => { throw new Error("Woosh"); }, 20); } catch (e) { // This will not run console.log("Caught", e); }
无论事件(如超时或传入请求)发生得多么紧密,JavaScript 环境一次只能运行一个程序。您可以将其视为围绕您的程序运行一个大循环,称为事件循环。当没有事情要做时,该循环就会暂停。但是,随着事件的到来,它们会被添加到队列中,它们的代码会一个接一个地执行。由于没有两件事同时运行,因此运行缓慢的代码会延迟对其他事件的处理。
此示例设置了一个超时,但之后会延迟,直到超时预期的时间点之后,导致超时延迟。
let start = Date.now(); setTimeout(() => { console.log("Timeout ran at", Date.now() - start); }, 20); while (Date.now() < start + 50) {} console.log("Wasted time until", Date.now() - start); // → Wasted time until 50 // → Timeout ran at 55
Promise 始终以新的事件方式解决或拒绝。即使 Promise 已经解决,等待它也会导致您的回调在当前脚本完成之后运行,而不是立即运行。
Promise.resolve("Done").then(console.log); console.log("Me first!"); // → Me first! // → Done
在后面的章节中,我们将看到在事件循环上运行的各种其他类型的事件。
异步错误
当您的程序同步运行,一次运行完所有内容时,除了程序本身进行的更改外,不会发生任何状态更改。对于异步程序,情况有所不同——它们在执行期间可能存在间隙,在此期间其他代码可以运行。
让我们看一个例子。这是一个函数,它试图报告文件数组中每个文件的大小,确保同时读取所有文件,而不是按顺序读取。
async function fileSizes(files) { let list = ""; await Promise.all(files.map(async fileName => { list += fileName + ": " + (await textFile(fileName)).length + "\n"; })); return list; }
async fileName =>
部分显示了箭头函数如何通过在它们前面加上async
来使其变为async
。
代码一开始看起来并不可疑……它将async
箭头函数映射到名称数组上,创建一个 Promise 数组,然后使用Promise.all
等待所有这些 Promise 完成,然后返回它们构建的列表。
但这个程序完全崩溃了。它将始终只返回一行输出,列出读取时间最长的文件。
fileSizes(["plans.txt", "shopping_list.txt"]) .then(console.log);
问题在于+=
运算符,它获取语句开始执行时list
的当前值,然后,当await
完成时,将list
绑定设置为该值加上添加的字符串。
但在语句开始执行和完成之间,存在异步间隙。map
表达式在向列表中添加任何内容之前运行,因此每个+=
运算符都从一个空字符串开始,并在其存储检索完成时,将list
设置为将其行添加到空字符串的结果。
这本来可以通过从映射的 Promise 中返回行并对Promise.all
的结果调用join
来轻松避免,而不是通过更改绑定来构建列表。与往常一样,计算新值比更改现有值更容易出错。
async function fileSizes(files) { let lines = files.map(async fileName => { return fileName + ": " + (await textFile(fileName)).length; }); return (await Promise.all(lines)).join("\n"); }
这种错误很容易犯,尤其是在使用await
时,您应该注意代码中存在间隙的位置。JavaScript 的显式异步性的优势(无论是通过回调、Promise 还是await
)在于,发现这些间隙相对容易。
总结
异步编程使得在不冻结整个程序的情况下表达等待长时间运行的操作成为可能。JavaScript 环境通常使用回调来实现这种编程风格,回调是在操作完成时调用的函数。事件循环调度这些回调在适当的时候被调用,一个接一个,以便它们的执行不会重叠。
Promise 使异步编程变得更容易,Promise 是表示可能在将来完成的操作的对象,而async
函数允许您将异步程序编写为同步程序。
练习
安静时间
卡拉实验室附近有一个由运动传感器激活的安全摄像头。它连接到网络,并在激活时开始发送视频流。因为她不想被发现,卡拉设置了一个系统,该系统会注意到这种无线网络流量,并在外面有活动时打开她巢穴里的灯,这样她就知道什么时候应该保持安静。
她还记录了摄像头被触发的时间,并希望使用这些信息来可视化在平均一周中哪些时间往往是安静的,哪些时间往往是繁忙的。日志存储在文件中,每个文件都保存一行时间戳数字(由Date.now()
返回)。
1695709940692 1695701068331 1695701189163
"camera_logs.
文件包含日志文件列表。编写一个异步函数activityTable(day)
,该函数对于一周中的某一天返回一个包含 24 个数字的数组,每个数字对应一天中的每一小时,这些数字保存该天该小时观察到的摄像头网络流量观察次数。使用Date.getDay
中使用的系统标识天数,其中星期日为 0,星期六为 6。
沙箱提供的activityGraph
函数将这样的表格总结成一个字符串。
要读取文件,请使用之前定义的textFile
函数——给定一个文件名,它返回一个解析为文件内容的 Promise。请记住,new Date(timestamp)
为该时间创建一个Date
对象,该对象具有getDay
和getHours
方法,分别返回星期几和一天中的小时。
两种类型的文件——日志文件列表和日志文件本身——每个数据都在单独的行上,由换行符 ("\n"
) 字符分隔。
async function activityTable(day) { let logFileList = await textFile("camera_logs.txt"); // Your code here } activityTable(1) .then(table => console.log(activityGraph(table)));
显示提示...
真正的 Promise
使用纯Promise
方法重写上一个练习中的函数,不使用async
/await
。
function activityTable(day) { // Your code here } activityTable(6) .then(table => console.log(activityGraph(table)));
在这种风格中,使用Promise.all
比尝试模拟日志文件的循环更方便。在async
函数中,仅仅在循环中使用await
更简单。如果读取文件需要一些时间,这两种方法中哪一种运行时间最短?
如果文件列表中列出的文件之一存在拼写错误,并且读取它失败,那么该错误如何最终出现在您的函数返回的Promise
对象中?
显示提示...
编写此函数最直接的方法是使用一系列then
调用。第一个 Promise 由读取日志文件列表生成。第一个回调可以拆分此列表并对其进行textFile
映射,以获取一个 Promise 数组以传递给Promise.all
。它可以返回Promise.all
返回的对象,以便无论它返回什么都成为第一个then
返回值的返回值。
现在我们有了返回日志文件数组的 Promise。我们可以再次在它上面调用then
,并将时间戳计数逻辑放在那里。类似这样
function activityTable(day) { return textFile("camera_logs.txt").then(files => { return Promise.all(files.split("\n").map(textFile)); }).then(logs => { // analyze... }); }
或者,为了更好地安排工作,您可以将每个文件的分析放在Promise.all
中,以便在第一个文件从磁盘返回时就开始对它进行分析,甚至在其他文件返回之前就开始分析。
function activityTable(day) { let table = []; // init... return textFile("camera_logs.txt").then(files => { return Promise.all(files.split("\n").map(name => { return textFile(name).then(log => { // analyze... }); })); }).then(() => table); }
这表明您构建 Promise 的方式会对工作的安排方式产生真正的影响。使用await
的简单循环将使该过程完全线性——它等待每个文件加载完毕,然后继续执行。Promise.all
使多个任务可以在概念上同时进行,允许它们在文件仍在加载时取得进展。这可能更快,但它也使事物的发生顺序变得不那么可预测。在这种情况下,我们只需要在一个表中递增数字,这很容易以安全的方式完成。对于其他类型的问题,这可能要困难得多。
当列表中的文件不存在时,textFile
返回的 Promise 将被拒绝。因为Promise.all
会在传递给它的 Promise 中的任何一个失败时拒绝,所以传递给第一个then
的回调的返回值也将是一个被拒绝的 Promise。这使得then
返回的 Promise 失败,因此传递给第二个then
的回调甚至没有被调用,并且一个被拒绝的 Promise 从函数中返回。
构建 Promise.all
正如我们所见,给定一个 Promise 数组,Promise.all
返回一个 Promise,它等待数组中的所有 Promise 完成。然后它成功,生成一个结果值数组。如果数组中的 Promise 失败,则all
返回的 Promise 也会失败,并传递来自失败 Promise 的失败原因。
自己实现类似的功能,将其作为名为 Promise_all
的常规函数。
请记住,在 promise 成功或失败后,它不能再次成功或失败,对解析它的函数的后续调用将被忽略。这可以简化您处理 promise 失败的方式。
function Promise_all(promises) { return new Promise((resolve, reject) => { // Your code here. }); } // Test code. Promise_all([]).then(array => { console.log("This should be []:", array); }); function soon(val) { return new Promise(resolve => { setTimeout(() => resolve(val), Math.random() * 500); }); } Promise_all([soon(1), soon(2), soon(3)]).then(array => { console.log("This should be [1, 2, 3]:", array); }); Promise_all([soon(1), Promise.reject("X"), soon(3)]) .then(array => { console.log("We should not get here"); }) .catch(error => { if (error != "X") { console.log("Unexpected failure:", error); } });
显示提示...
传递给 Promise
构造函数的函数将必须对给定数组中的每个 promise 调用 then
。当其中一个 promise 成功时,需要发生两件事。结果值需要存储在结果数组的正确位置,并且我们必须检查这是否是最后一个挂起的 promise,如果确实是,则完成我们自己的 promise。
后者可以使用一个计数器来完成,该计数器初始化为输入数组的长度,并且每次 promise 成功时我们都会从中减去 1。当它达到 0 时,我们就完成了。请确保考虑输入数组为空的情况(因此永远不会解析任何 promise)。
处理失败需要一些思考,但事实证明非常简单。只需将包装 promise 的 reject
函数作为 catch
处理程序或作为 then
的第二个参数传递给数组中的每个 promise,这样其中一个 promise 失败就会触发整个包装 promise 的拒绝。