原文链接:eloquentjavascript.net/11_async.ht…
作者:Marijn Haverbeke
计算机的核心部分,也就是执行我们程序中各个步骤的部分,称为处理器。到目前为止我们看到的程序会占用处理器直到它们完成任务。像循环这样操作数字的任务执行的速度几乎完全取决于计算机处理器和内存的速度。
但是很多程序需要与处理器之外的内容交互。比如,它们可能需要通过计算机网络进行通信,或者从硬盘请求数据——这比从内存获取数据要慢得多。
当这样的操作进行时,让处理器闲置是很可惜的——在此期间可能有一些其他工作它可以做。这部分工作在一定程度上由操作系统处理,它会在多个运行程序之间切换处理器。但是当我们想要一个程序在等待网络请求时能够继续进行时,这种切换就无济于事了。
异步性
在同步编程模型中,事情依次发生。当你调用一个执行长时间操作的函数时,它只有在操作完成并且可以返回结果时才返回。这会导致你的程序在操作进行的那段时间停止。
异步模型允许多件事情同时发生。当你开始一个操作时,你的程序会继续运行。当操作完成时,程序会被通知并获取到结果(例如,从硬盘读取的数据)。
我们可以用一个小例子来比较同步和异步编程:一个程序通过网络发出两个请求,然后合并结果。
在同步环境中,请求函数只有在完成其工作后才返回,执行这项任务的最简单方法是依次发出请求。这样做的缺点是,第二个请求只能在第一个请求完成后才开始。所需的总时间至少是两个响应时间的总和。
在同步系统中解决这个问题的方法是启动额外的控制线程。线程是另一个运行中的程序,其执行可能会被操作系统与其他程序交错进行——由于大多数现代计算机包含多个处理器,多个线程甚至可能同时在不同的处理器上运行。第二个线程可以启动第二个请求,然后两个线程等待它们的结果返回,之后它们重新同步以合并它们的结果。
在以下示意图中,粗线代表程序正常运行所花费的时间,细线代表程序等待网络的时间。在同步模型中,网络所需的时间是给定控制线程时间轴的一部分。在异步模型中,启动一个网络操作允许程序在网络通信同时进行的情况下继续运行,并在完成时通知程序。
另一种描述差异的方式是,在同步模型中,等待操作完成是隐式的,而在异步模型中,它是显式的(在我们的控制之下,决定在什么时候等待和如何等待。)
异步性是双向的。它使得表达不符合直线控制模型的程序更容易,但也可能使符合直线控制模型的程序表达更加困难。(它让那些不适合直线控制流模型的程序更容易表达,也就是说,当你的程序需要多个操作几乎同时发生,且彼此之间并不依赖时,异步编程是非常有用的。然而,对于那些确实需要按顺序一步接一步执行的程序来说,使用异步编程可能会让事情变得更加复杂和笨拙)我们将在本章后面看到一些减少这种尴尬的方法。
两个主要的JavaScript编程平台(浏览器和 Node.js)都倾向于将可能需要一点时间的操作设置为异步,而不是依赖于线程。这是因为使用线程编程是非常困难(当程序需要同时处理多个任务时,弄清楚每一部分到底在做什么变得非常困难),因此,这通常被认为是好事。
CallBack(回调)
异步编程的一种方法是使需要等待某些内容的函数采用额外的参数,即回调函数。异步函数启动一个进程,并设置在进程完成时调用回调函数,然后返回。
例如,该setTimeout函数在 Node.js 和浏览器中都可用,等待给定的毫秒数,然后调用函数。
setTimeout(() => console.log("Tick"), 500);
等待通常不是重要的工作,但当你需要安排某件事在某个时间发生,或检查某些操作是否比预期花费的时间更长时,它可能非常有用。
另一个常见的异步操作的例子是从设备的存储中读取文件。假设你有一个函数readTextFile,它读取一个文件的内容作为字符串并将其传递给一个回调函数。
readTextFile( "shopping_list.txt" , content => {
console.log( `购物清单:\n ${content} ` );
});
// → 购物清单:
// → 花生酱
// → 香蕉
该readTextFile函数不是标准 JavaScript 的一部分。我们将在后面的章节中看到如何在浏览器和 Node.js 中读取文件。
使用回调连续执行多个异步操作意味着你必须不断传递新函数来处理操作后继续计算。比较两个文件并生成一个布尔值来指示它们的内容是否相同的异步函数可能如下所示:
function compareFiles(fileA, fileB, callback) {
readTextFile(fileA, contentA => {
readTextFile(fileB, contentB => {
callback(contentA == contentB);
});
});
}
这种编程风格是可行的,但是每进行一次异步操作,缩进层级就会增加,因为你最终会进入另一个函数。做更复杂的事情,比如在循环中包装异步操作,可能会变得尴尬。
在某种程度上,异步是具有传染性的。任何调用了异步工作函数的函数自身也必须是异步的,使用回调或类似机制来传递其结果。调用回调相比简单地返回一个值更加复杂且容易出错,因此需要以这种方式构造程序的大部分内容并不是很好。
Promises
构建异步程序的一种稍微不同的方法是让异步函数返回一个表示其(未来)结果的对象,而不是传递回调函数。这样,这些函数实际上会返回一些有意义的东西,并且程序的形状更类似于同步程序
这就是标准类Promise的用途。promise是代表可能尚不可用的值的收据 。 它提供了一种then方法,使你可以注册一个当等待的操作完成时应该调用的函数。当 Promise 被解决时,意味着它的值变得可用,这样的函数(可以有多个)将被调用并带有结果值。在一个已经完成(解决了其值)的promise上调用then方法仍然是可行的,而且回调函数仍然会被执行
创建 Promise 的最简单方法是调用Promise.resolve,这个函数确保你给它的数值被包装在一个promise对象中。如果你提供的数值已经是一个promise对象,那么它会直接返回这个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对象,则新的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对象,产生一个单一的文件名。最后的then调用读取该文件,所以这个函数作为一个整体的结果是一个promise对象,这个promise对象返回一个随机文件的内容。
在此代码中,前两次then调用所使用的函数返回一个常规值,当函数返回时,这个值将立即传入then返回的 promise对象 中。最后一个then调用返回一个promise 对象( textFile(filename)),使其成为一个实际的异步步骤。
这些步骤也可以在单个then回调中执行,因为只有最后一步实际上是异步的。但是,这种只进行一些同步数据转换的then方法封装往往非常有用,例如,当你想要返回一个promise对象,这个promise对象生成某个异步结果的处理后版本时。
function jsonFile(filename) {
return textFile(filename).then(JSON.parse);
}
jsonFile("package.json").then(console.log);
一般来说,将 promise视为一种机制,它允许代码暂时忽略值何时到达的问题。通常一个正常值必须实际存在我们才能引用它。而一个promise所代表的值,则可能已经存在,或者将来某个时刻才会出现。基于promise构建的计算,可以通过then方法进行连接,然后这些计算会随着它们依赖的输入变得可用而异步执行
Promise 使得这一过程更加简单。Promise 可以是已被解决的(操作成功完成),也可以是被拒的(操作失败)。解决处理器(通过 then 注册的)仅在操作成功时被调用,而拒绝会传递给 then 返回的新 Promise。当一个处理器抛出一个异常时,这会自动导致它所对应的 then 调用产生的 Promise 被拒绝。如果在一系列异步操作中的任何一个环节失败了,整个链的结果会被标记为拒绝,从失败点之后就不会再调用成功处理器。
就像解决一个 Promise 会提供一个值一样,拒绝一个 Promise 也会提供一个值,通常称为拒绝的原因。当一个处理器函数中的异常导致了拒绝时,异常值被用作拒绝的原因。同样地,当一个处理器返回一个被拒绝的 Promise 时,该拒绝会流向下一个 Promise。存在一个 Promise.reject 函数,它创建一个新的、立即被拒绝的 Promise。
为了显式地处理这类拒绝,Promise 提供了一个 catch 方法,该方法注册一个在 Promise 被拒绝时调用的处理器,这与 then 处理器处理正常解决的方式相似。catch 在它返回一个新 Promise 上也非常类似于 then,该新 Promise 在原始 Promise 正常解决时解决为原始 Promise 的值,否则解决为 catch 处理器的结果。如果一个 catch 处理器抛出一个错误,新的 Promise 也会被拒绝。
作为一种简写,then 还接受一个拒绝处理器作为第二个参数,因此你可以在单个方法调用中安装这两种类型的处理器:.then(acceptHandler, rejectHandler)。
传递给 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 拒绝未被处理,并会将这报告为一个错误。
Carla
这是柏林的一个阳光明媚的日子。废弃的旧机场的跑道上挤满了骑自行车的人和直排轮滑的人。在垃圾箱附近的草地上,一群乌鸦吵闹地飞来飞去,试图说服一群游客放弃三明治。
其中一只乌鸦格外引人注目——一只身材高大、邋遢的雌性乌鸦,右翼上有几根白色的羽毛。她以一种技巧和自信来引诱人们,这表明她已经这样做了很长时间。当一位老人因另一只乌鸦的滑稽动作而分心时,她漫不经心地猛扑过去,从他手中夺走他吃了一半的面包,然后扬帆而去。
与其他看起来很乐意在这里闲逛的人相反,这只大乌鸦看起来是有目的的。她带着她的战利品,径直飞向机库大楼的屋顶,消失在通风口中。
在建筑物内,你可以听到奇怪的敲击声——轻柔但持久。它来自未完工的楼梯间屋顶下的狭窄空间。乌鸦坐在那里,周围是她偷来的零食、六部智能手机(其中几部已打开)和一堆乱七八糟的电缆。她用喙快速敲击其中一部手机的屏幕。上面出现了文字。如果你不了解的话,你会以为她正在打字。
这只乌鸦被她的同龄人称为“cāāw-krö”。但由于这些声音不太适合人类声带,我们将她称为Carla。
Carla是一只有点奇怪的乌鸦。在她年轻的时候,她对人类语言着迷,偷听人们说话,直到她很好地掌握了他们在说什么。后来,她的兴趣转向人类技术,她开始偷手机来研究它们。她目前的项目是学习编程。事实上,她在隐藏实验室中输入的文本是一段异步 JavaScript 代码。
闯入
Carla喜欢互联网。令人烦恼的是,她正在使用的手机的预付的数据即将耗尽。该大楼有无线网络,但需要密码才能访问。
幸运的是,大楼内的无线路由器已经有 20 年的历史,而且安全性很差。经过一些研究,Carla发现网络身份验证机制有一个她可以利用的缺陷。加入网络时,设备必须发送正确的六位密码。接入点将根据是否提供了正确的代码来回复成功或失败消息。但是,当发送部分代码(例如,只有 3 位数字)时,根据这些数字是否是代码的正确开头,响应会有所不同。发送不正确的号码会立即返回失败消息。当发送正确的数字时,接入点会等待更多的数字。
这使得可以大大加快数字的猜测速度。 Carla 可以通过依次尝试每个数字来找到第一个数字,直到找到一个不会立即返回失败的数字。有了一位数字,她就可以用同样的方式找到第二位数字,依此类推,直到她知道整个密码。
假设 Carla 有一个joinWifi函数。给定网络名称和密码(作为字符串),该函数尝试加入网络,返回一个promise,如果成功则解析,如果身份验证失败则拒绝。她需要的第一件事是一种包装promise的方法,以便在花费太多时间后自动拒绝,以便在接入点没有响应时允许程序快速继续。
function withTimeout(promise, time) {
return new Promise((resolve, reject) => {
promise.then(resolve, reject);
setTimeout(() => reject("Timed out"), time);
});
}
这利用了一个事实,即一个Promise对象只能被解决(resolve)或者拒绝(reject)一次。如果作为参数传递的Promise对象先被解决或者被拒绝,那么这个结果将会成为由withTimeout返回的Promise的结果。如果另一方面,setTimeout先触发,拒绝了Promise,那么任何后续的解决或拒绝调用都将被忽略。
要找到完整的密码,程序需要通过尝试每个数字不断地查找下一个数字。如果认证成功,我们就知道我们找到了我们所寻找的。如果它立即失败,我们知道那个数字是错误的,必须尝试下一个数字。如果请求超时,我们就找到了另一个正确的数字,必须通过添加另一个数字继续。
因为你不能在for循环中等待一个promise,Carla使用一个递归函数来推动这一过程。在每次调用时,这个函数获取我们到目前为止所知的代码,以及要尝试的下一个数字。根据发生的情况,它可能返回一个完成的代码,或者调用自己,要么开始破解代码中的下一个位置,要么再次尝试另一个数字。
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
Carla歪了歪头,叹了口气。如果密码要猜得难一些,这过程会更有满足感。
Async functions (异步函数)
即使有了Promise,这种异步代码仍然令人感到困扰。Promise通常需要以冗长且看上去随意的方式相互关联。为了创建一个异步循环,Carla不得不引入一个递归函数。
实际上,密码破解功能完全是线性的——它总是等待前一个动作完成后再开始下一个动作。在同步编程模型下,表达起来会更直接。
好消息是JavaScript允许你写伪同步代码来描述异步计算。一个async函数隐式地返回一个Promise,并且可以在其函数体内以看似同步的方式等待其他的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来变为异步的。当这样一个函数或方法被调用时,它返回一个promise。一旦函数返回了某样东西,那个promise就解决了。如果函数体抛出一个异常,promise就被拒绝了。
在一个async函数内部,可以在一个表达式前放一个await来等待一个promise解决,然后再继续执行函数。如果promise被拒绝,一个异常会在await的地方被抛出。
这样的函数不再像普通的JavaScript函数那样一气呵成地从头运行到尾。相反,它可以在有await的任何点被冻结,并且可以在稍后的时间恢复执行。
对于大多数异步代码,这种表示法比直接使用Promise更方便。你仍然需要对Promise有所了解,因为在很多情况下你仍然会直接与之交互。但是在将它们连接在一起时,异步函数通常比一连串的then调用更加愉快。
Generators(生成器)
函数可以被暂停然后再次恢复执行这一能力并不是异步函数独有的。JavaScript也有一种叫做生成器函数的特性。这些类似,但没有promises。
当你用function*(在function这个词后面放一个星号)定义一个函数时,它就变成了一个生成器。当你调用一个生成器时,它返回一个迭代器,这是我们在第六章已经见过的。
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 类的迭代器(来自第六章练习)可以用这个生成器编写:
Group.prototype[Symbol.iterator] = function*() {
for (let i = 0; i < this.members.length; i++) {
yield this.members[i];
}
};
不再需要创建一个对象来保存迭代状态了——生成器在每次 yield 的时候会自动保存它们的本地状态。
这样的 yield 表达式只能出现在生成器函数本身中,而不能出现在你在生成器函数内部定义的另一个函数中。生成器在 yield 时保存的状态,仅仅是它的局部环境和它 yield 的位置。
一个异步函数是一种特殊类型的生成器。它在被调用时产生一个 promise,当函数返回(finishes)时这个 promise 被解决(resolved),当函数抛出异常时这个 promise被拒绝(rejected)。每当它 yield(await)一个 promise 时,这个 promise 的结果(值或抛出的异常)就是 await 表达式的结果。
一个鸦科艺术项目
一天早上,乌鸦Carla被机库外停机坪上传来的陌生噪音吵醒。她跳到屋顶边缘,看到人类正在为某事做准备。这里有很多电线、一个舞台,还有一堵巨大的黑墙正在建造中。
作为一只好奇的乌鸦,Carla仔细观察了墙壁。它似乎由许多连接到电缆的大型玻璃面板设备组成。这些设备的背面写着“LedTec SIG-5030”。
在互联网上快速搜索即可找到这些设备的用户手册。它们看起来像是交通标志,带有可编程的琥珀色 LED 灯矩阵。人类的目的可能是在活动期间显示有关他们的某种信息。有趣的是,屏幕可以通过无线网络进行编程。难道它们连接到了大楼的本地网络?
网络上的每个设备都有一个IP 地址,其他设备可以使用该地址向其发送消息。我们将在第 13 章中详细讨论这一点。 Carla 注意到她自己的电话都有类似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灯,因此一个更新指令应该发送1500个数字。
这段代码向本地网络上的所有地址发送一个显示更新消息,看看效果如何。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调用确保网络错误不会导致程序崩溃。所有的请求都会立即发送出去,而不用等待其他请求完成,以防止当有些机器不回答时浪费时间。
完成了网络扫描后,Carla回到外面看结果。令她高兴的是,所有的屏幕现在都在左上角显示了一条亮线。它们连接在本地网络上,而且确实接受命令。她快速记下了每个屏幕显示的数字。一共有9个屏幕,分成三排,三列排列。它们的网络地址如下:
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"
];
现在这为各种恶作剧开启了可能性。她可以在墙上用巨大的字母显示“乌鸦统治,人类流口水”。但那感觉有点粗俗。相反,她计划在晚上用九个屏幕来展示一个飞翔的乌鸦的视频。
Carla找到了一个合适的视频片段,1.5秒钟的镜头可以重复创建一个显示乌鸦翅膀拍打的循环视频。为了适应九个屏幕(每个屏幕可以显示50×30像素),Carla裁剪并调整视频尺寸,得到了每秒十幅的150×90图像。然后将这些图像每个都切成九个矩形,并进行处理,使视频中的黑色部分(乌鸦所在处)显示亮光,而亮色部分(没有乌鸦的地方)保持黑暗,这样就能创造出一个琥珀色的乌鸦在黑色背景前飞翔的效果。
她已经设置了clipImages变量来保存图像帧的数组,其中每个帧由九组像素的数组表示——每个屏幕一组——按照标志所期望的格式。
为了显示视频的单个帧,Carla需要同时向所有屏幕发送请求。但她也需要等待这些请求的结果,一方面是为了不在当前帧正确发送之前就开始发送下一帧,另一方面是为了当请求失败时能够注意到。
Promise有一个静态方法all,可以用来将一个promise数组转换成一个单一的promise,这个promise解决成一个结果数组。这提供了一种方便的方式来让一些异步操作并行发生,等待它们全部完成,然后再用它们的结果做些事情(或者至少等待它们以确保不会失败)。
function displayFrame(frame) {
return Promise.all(frame.map((data, i) => {
return request(screenAddresses[i], {
command: "display",
data
});
}));
}
这通过映射帧中的图像(这是一个显示数据数组的数组)来创建一个请求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);
在屏幕墙安装的整个星期里,每天晚上,当天黑后,一个巨大的发光橙色的鸟神秘地出现在上面。
The event loop (事件循环)
一个异步程序从运行其主脚本开始,它通常会设置回调函数以便稍后调用。那个主脚本,以及回调函数,都是一次性完整运行的,不会被打断。但在它们之间,程序可能会处于空闲状态,等待某些事情发生。
所以回调函数并不是由调度它们的代码直接调用的。如果我在一个函数内部调用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
在后续章节中,我们将看到各种其他类型的事件,这些事件都在事件循环上运行。
Asynchronous bugs(异步错误)
当你的程序同步运行时,一次性完成,除了程序本身所做的那些,没有其他状态变化发生。对于异步程序来说,情况就不同了——它们在执行过程中可能有空档,其他代码可以在这些空档期运行。
让我们看一个例子。这个函数试图报告文件数组中每个文件的大小,确保同时读取它们,而不是顺序读取。
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" 关键字来使其变为异步函数。
这段代码乍一看没什么问题。它将异步箭头函数映射到文件名数组上,生成一个 promise 数组,然后使用 Promise.all在返回构建的列表之前等待所有 promise。
但这个程序完全无法运行。它总是只返回一行输出,列出读取时间最长的文件。
fileSizes(["plans.txt", "shopping_list.txt"])
.then(console.log);
你能理解为什么会这样吗?
问题在于 += 操作符,它取当前执行语句时的 list 值,然后当 await 完成后,将 list 绑定设置为那个值加上添加的字符串。
但是在语句开始执行和结束之间有一个异步间隙。在任何内容被添加到 list 之前,map表达式就已经运行了,所以每一个 += 操作符都是从一个空字符串开始的,并且当它的存储检索完成时,最终将 list 设置为向空字符串添加其行的结果。
这完全可以通过从映射的 promises 返回行并在 Promise.all 的结果上调用 join 来避免,而不是通过改变绑定来构建 list。像往常一样,计算新值比改变现有值更不易出错。
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环境通常使用回调来实现这种风格的编程,当动作完成时调用这些函数。事件循环安排这些回调在适当的时候依次被调用,以便它们的执行不会重叠。
通过Promises(代表可能在未来完成的动作的对象)和async函数(允许你将一个异步程序编写得就像它是同步的一样),异步编程变得更加容易。
练习
安静的时光
有个安全摄像头安装在Carla实验室附近,它有一个动作传感器触发。当摄像头活动时,它就会连接到网络并开始发送视频流。因为Carla希望自己的行踪不被发现,所以她设立了一个系统,这个系统可以识别这种无线网络流量,并且在她的藏身之地外有活动时就会打开一盏灯,这样她就知道何时需要保持安静。
她还记录了一段时间摄像头被触发的时刻,并希望利用这些信息来可视化一个平均周中的哪些时段通常比较安静,而哪些时段通常比较繁忙。日志文件里保存的是用 Date.now() 每行返回一个时间戳编号。
1695709940692
1695701068331
1695701189163
"camera_logs.txt"文件保存了日志文件的列表。编写一个异步函数activityTable(day),它能够返回一个数组,共24个数字,对应一天中的每个小时,这些数字表示那个小时内所观察到的摄像头网络流量的次数。天数用 Date.getDay 的系统表示,周日是0,周六是6。
sandbox提供的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)));
显示提示 ...
你需要将这些文件的内容转换为一个数组。最简单的方法是使用textFile产生的字符串上的split方法。注意,对于日志文件,这将仍然给你一个字符串数组,你需要在将它们传递给new Date之前将它们转换为数字。
将所有时间点汇总到一个每小时的表格中,可以通过创建一个表格(数组)来完成,表格中的每个小时都有一个数字。然后,你可以循环遍历所有时间戳(包括日志文件及每个日志文件中的数字),对于每一个时间戳,如果它发生在正确的天数,取它发生的小时数,并在表格中相应的数字上加一。
确保在使用异步函数的结果之前使用await,否则你将得到一个Promise,而你期望得到的是一个字符串。
Real Promises
将前一个练习中的函数改写成不使用async/await,使用普通的Promise方法。
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);
}
这表明了你如何构建你的 `promises` 可以真正影响工作的安排方式。一个包含 `await` 的简单循环会使过程完全线性化 —— 它会等待每个文件加载后再继续。`Promise.all` 使得在概念上可以同时进行多个任务,允许在文件仍在加载时进一步处理。这可能更快,但它也使得事情发生的顺序变得不太可预测。在这个案例中,我们只是在表中增加数字,这样做是安全的。对于其他种类的问题,可能要困难得多。
当列表中的一个文件不存在时,由 textFile 返回的 promise 将会被拒绝。因为如果给它的任何 promise 失败了,Promise.all 就会拒绝,所以传递给第一个 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 成功时我们就从中减去 1。当它达到 0 时,我们就完成了。确保你考虑到输入数组为空的情况(因此没有 promise 会被解决)。
处理失败需要一些思考,但事实证明是非常简单的。只需要将包装 promise 的拒绝函数传递给数组中的每一个 promise 作为 catch 处理器,或者作为 then 的第二个参数,这样其中一个失败就会触发整个包装 promise 的拒绝。