JavaScript 编程精解 中文第三版 11、异步编程

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Asynchronous Programmingjavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

孰能浊以澄?静之徐清;github

孰能安以久?动之徐生。apache

老子,《道德经》编程

计算机的核心部分称为处理器,它执行构成咱们程序的各个步骤。 到目前为止,咱们看到的程序都是让处理器忙碌,直到他们完成工做。 处理数字的循环之类的东西,几乎彻底取决于处理器的速度。数组

可是许多程序与处理器以外的东西交互。 例如,他们可能经过计算机网络进行通讯或从硬盘请求数据 - 这比从内存获取数据要慢不少。promise

当发生这种事情时,让处理器处于闲置状态是可耻的 - 在此期间能够作一些其余工做。 某种程度上,它由你的操做系统处理,它将在多个正在运行的程序之间切换处理器。 可是,咱们但愿单个程序在等待网络请求时能作一些事情,这并无什么帮助。浏览器

异步

在同步编程模型中,一次只发生一件事。 当你调用执行长时间操做的函数时,它只会在操做完成时返回,而且能够返回结果。 这会在你执行操做的时候中止你的程序。

异步模型容许同时发生多个事件。 当你开始一个动做时,你的程序会继续运行。 当动做结束时,程序会受到通知并访问结果(例如从磁盘读取的数据)。

咱们可使用一个小例子来比较同步和异步编程:一个从网络获取两个资源而后合并结果的程序。

在同步环境中,只有在请求函数完成工做后,它才返回,执行此任务的最简单方法是逐个建立请求。 这有一个缺点,仅当第一个请求完成时,第二个请求才会启动。 所花费的总时间至少是两个响应时间的总和。

在同步系统中解决这个问题的方法是启动额外的控制线程。 线程是另外一个正在运行的程序,它的执行可能会交叉在操做系统与其余程序当中 - 由于大多数现代计算机都包含多个处理器,因此多个线程甚至可能同时运行在不一样的处理器上。 第二个线程能够启动第二个请求,而后两个线程等待它们的结果返回,以后它们从新同步来组合它们的结果。

在下图中,粗线表示程序正常花费运行的时间,细线表示等待网络所花费的时间。 在同步模型中,网络所花费的时间是给定控制线程的时间线的一部分。 在异步模型中,从概念上讲,启动网络操做会致使时间轴中出现分裂。 启动该动做的程序将继续运行,而且该动做将与其同时发生,并在程序结束时通知该程序。

另外一种描述差别的方式是,等待动做完成在同步模型中是隐式的,而在异步模型中,在咱们的控制之下,它是显式的。

异步性是个双刃剑。 它能够生成不适合直线控制模型的程序,但它也可使直线控制的程序更加笨拙。 本章后面咱们会看到一些方法来解决这种笨拙。

两种重要的 JavaScript 编程平台(浏览器和 Node.js)均可能须要一段时间的异步操做,而不是依赖线程。 因为使用线程进行编程很是困难(理解程序在同时执行多个事情时所作的事情要困可贵多),这一般被认为是一件好事。

乌鸦科技

大多数人都知道乌鸦很是聪明。 他们可使用工具,提早计划,记住事情,甚至能够互相沟通这些事情。

大多数人不知道的是,他们可以作一些事情,而且对咱们隐藏得很好。我据说一个有声望的(但也有点古怪的)专家 corvids 认为,乌鸦技术并不落后于人类的技术,而且正在迎头遇上。

例如,许多乌鸦文明可以构建计算设备。 这些并非电子的,就像人类的计算设备同样,可是它们操做微小昆虫的行动,这种昆虫是与白蚁密切相关的物种,它与乌鸦造成了共生关系。 鸟类为它们提供食物,对之对应,昆虫创建并操做复杂的殖民地,在其内部的生物的帮助下进行计算。

这些殖民地一般位于大而久远的鸟巢中。 鸟类和昆虫一块儿工做,创建一个球形粘土结构的网络,隐藏在巢的树枝之间,昆虫在其中生活和工做。

为了与其余设备通讯,这些机器使用光信号。 鸟类在特殊的通信茎中嵌入反光材料片断,昆虫校准这些反光材料将光线反射到另外一个鸟巢,将数据编码为一系列快速闪光。 这意味着只有具备完整视觉链接的巢才能沟通。

咱们的朋友 corvid 专家已经绘制了 Rhône 河畔的 Hières-sur-Amby 村的乌鸦鸟巢网络。 这张地图显示了鸟巢及其链接。

在一个使人震惊的趋同进化的例子中,乌鸦计算机运行 JavaScript。 在本章中,咱们将为他们编写一些基本的网络函数。

回调

异步编程的一种方法是使执行慢动做的函数接受额外的参数,即回调函数。动做开始,当它结束时,使用结果调用回调函数。

例如,在 Node.js 和浏览器中均可用的setTimeout函数,等待给定的毫秒数(一秒为一千毫秒),而后调用一个函数。

setTimeout(() => console.log("Tick"), 500);

等待一般不是一种很是重要的工做,但在作一些事情时,例如更新动画或检查某件事是否花费比给定时间更长的时间,可能颇有用。

使用回调在一行中执行多个异步操做,意味着你必须不断传递新函数来处理操做以后的计算延续。

大多数乌鸦鸟巢计算机都有一个长期的数据存储器,其中的信息刻在小树枝上,以便之后能够检索。雕刻或查找一段数据须要一些时间,因此长期存储的接口是异步的,并使用回调函数。

存储器按照名称存储 JSON 编码的数据片断。乌鸦能够存储它隐藏食物的地方的信息,其名称为"food caches",它能够包含指向其余数据片断的名称数组,描述实际的缓存。为了在 Big Oak 鸟巢的存储器中查找食物缓存,乌鸦能够运行这样的代码:

import {bigOak} from "./crow-tech";

bigOak.readStorage("food caches", caches => {
  let firstCache = caches[0];
  bigOak.readStorage(firstCache, info => {
    console.log(info);
  });
});

(全部绑定名称和字符串都已从乌鸦语翻译成英语。)

这种编程风格是可行的,但缩进级别随着每一个异步操做而增长,由于你最终会在另外一个函数中。 作更复杂的事情,好比同时运行多个动做,会变得有点笨拙。

乌鸦鸟巢计算机为使用请求-响应对进行通讯而构建。 这意味着一个鸟巢向另外一个鸟巢发送消息,而后它当即返回一个消息,确认收到,并可能包括对消息中提出的问题的回复。

每条消息都标有一个类型,它决定了它的处理方式。 咱们的代码能够为特定的请求类型定义处理器,而且当这样的请求到达时,调用处理器来产生响应。

"./crow-tech"模块所导出的接口为通讯提供基于回调的函数。 鸟巢拥有send方法来发送请求。 它接受目标鸟巢的名称,请求的类型和请求的内容做为它的前三个参数,以及一个用于调用的函数,做为其第四个和最后一个参数,当响应到达时调用。

bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM",
            () => console.log("Note delivered."));

但为了使鸟巢可以接收该请求,咱们首先必须定义名为"note"的请求类型。 处理请求的代码不只要在这台鸟巢计算机上运行,并且还要运行在全部能够接收此类消息的鸟巢上。 咱们只假定一只乌鸦飞过去,并将咱们的处理器代码安装在全部的鸟巢中。

import {defineRequestType} from "./crow-tech";

defineRequestType("note", (nest, content, source, done) => {
  console.log(`${nest.name} received note: ${content}`);
  done();
});

defineRequestType函数定义了一种新的请求类型。该示例添加了对"note"请求的支持,它只是向给定的鸟巢发送备注。咱们的实现调用console.log,以便咱们能够验证请求到达。鸟巢有name属性,保存他们的名字。

handler的第四个参数done,是一个回调函数,它在完成请求时必须调用。若是咱们使用了处理器的返回值做为响应值,那么这意味着请求处理器自己不能执行异步操做。执行异步工做的函数一般会在完成工做以前返回,安排回调函数在完成时调用。因此咱们须要一些异步机制 - 在这种状况下是另外一个回调函数 - 在响应可用时发出信号。

某种程度上,异步性是传染的。任何调用异步的函数的函数,自己都必须是异步的,使用回调或相似的机制来传递其结果。调用回调函数比简单地返回一个值更容易出错,因此以这种方式构建程序的较大部分并非很好。

Promise

当这些概念能够用值表示时,处理抽象概念一般更容易。 在异步操做的状况下,你不须要安排未来某个时候调用的函数,而是返回一个表明这个将来事件的对象。

这是标准类Promise的用途。 Promise是一种异步行为,能够在某个时刻完成并产生一个值。 当值可用时,它可以通知任何感兴趣的人。

建立Promise的最简单方法是调用Promise.resolve。 这个函数确保你给它的值包含在一个Promise中。 若是它已是Promise,那么仅仅返回它 - 不然,你会获得一个新的Promise,并使用你的值当即结束。

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

为了得到Promise的结果,可使用它的then方法。 它注册了一个回调函数,当Promise解析并产生一个值时被调用。 你能够将多个回调添加到单个Promise中,即便在Promise解析(完成)后添加它们,它们也会被调用。

但那不是then方法所作的一切。 它返回另外一个Promise,它解析处理器函数返回的值,或者若是返回Promise,则等待该Promise,而后解析为结果。

Promise视为一种手段,将值转化为异步现实,是有用处的。 一个正常的值就在那里。promised 的值是将来可能存在或可能出现的值。 根据Promise定义的计算对这些包装值起做用,并在值可用时异步执行。

为了建立Promise,你能够将Promise用做构造器。 它有一个有点奇怪的接口 - 构造器接受一个函数做为参数,它会当即调用,并传递一个函数来解析这个Promise。 它以这种方式工做,而不是使用resolve方法,这样只有建立Promise的代码才能解析它。

这就是为readStorage函数建立基于Promise的接口的方式。

function storage(nest, name) {
  return new Promise(resolve => {
    nest.readStorage(name, result => resolve(result));
  });
}

storage(bigOak, "enemies")
  .then(value => console.log("Got", value));

这个异步函数返回一个有意义的值。 这是Promise的主要优势 - 它们简化了异步函数的使用。 基于Promise的函数不须要传递回调,而是相似于常规函数:它们将输入做为参数并返回它们的输出。 惟一的区别是输出可能还不可用。

故障

译者注:这段若是有配套代码会更容易理解,可是没有,因此凑合看吧。

常规的 JavaScript 计算可能会因抛出异常而失败。 异步计算常常须要相似的东西。 网络请求可能会失败,或者做为异步计算的一部分的某些代码,可能会引起异常。

异步编程的回调风格中最紧迫的问题之一是,确保将故障正确地报告给回调函数,是很是困难的。

一个普遍使用的约定是,回调函数的第一个参数用于指示操做失败,第二个参数包含操做成功时生成的值。 这种回调函数必须始终检查它们是否收到异常,并确保它们引发的任何问题,包括它们调用的函数所抛出的异常,都会被捕获并提供给正确的函数。

Promise使这更容易。能够解决它们(操做成功完成)或拒绝(故障)。只有在操做成功时,才会调用解析处理器(使用then注册),而且拒绝会自动传播给由then返回的新Promise。当一个处理器抛出一个异常时,这会自动使then调用产生的Promise被拒绝。所以,若是异步操做链中的任何元素失败,则整个链的结果被标记为拒绝,而且不会调用失败位置以后的任何常规处理器。

就像Promise的解析提供了一个值,拒绝它也提供了一个值,一般称为拒绝的缘由。当处理器中的异常致使拒绝时,异常值将用做缘由。一样,当处理器返回被拒绝的Promise时,拒绝流入下一个PromisePromise.reject函数会建立一个新的,当即被拒绝的Promise

为了明确地处理这种拒绝,Promise有一个catch方法,用于注册一个处理器,当Promise被拒绝时被调用,相似于处理器处理正常解析的方式。 这也很是相似于then,由于它返回一个新的Promise,若是它正常解析,它将解析原始Promise的值,不然返回catch处理器的结果。 若是catch处理器抛出一个错误,新的Promise也被拒绝。

做为简写,then还接受拒绝处理器做为第二个参数,所以你能够在单个方法调用中,装配这两种的处理器。

传递给Promise构造器的函数接收第二个参数,并与解析函数一块儿使用,它能够用来拒绝新的Promise

经过调用thencatch建立的Promise值的链条,能够看做异步值或失败沿着它移动的流水线。 因为这种链条经过注册处理器来建立,所以每一个链条都有一个成功处理器或与其关联的拒绝处理器(或二者都有)。 不匹配结果类型(成功或失败)的处理器将被忽略。 可是那些匹配的对象被调用,而且它们的结果决定了下一次会出现什么样的值 -- 返回非Promise值时成功,当它抛出异常时拒绝,而且当它返回其中一个时是Promise的结果。

就像环境处理未捕获的异常同样,JavaScript 环境能够检测未处理Promise拒绝的时候,并将其报告为错误。

网络是困难的

偶尔,乌鸦的镜像系统没有足够的光线来传输信号,或者有些东西阻挡了信号的路径。 信号可能发送了,但从未收到。

事实上,这只会致使提供给send的回调永远不会被调用,这可能会致使程序中止,而不会注意到问题。 若是在没有获得回应的特定时间段内,请求会超时并报告故障,那就很好。

一般状况下,传输故障是随机事故,例如汽车的前灯会干扰光信号,只需重试请求就可使其成功。 因此,当咱们处理它时,让咱们的请求函数在放弃以前自动重试发送请求几回。

并且,既然咱们已经肯定Promise是一件好事,咱们也会让咱们的请求函数返回一个Promise。 对于他们能够表达的内容,回调和Promise是等同的。 基于回调的函数能够打包,来公开基于Promise的接口,反之亦然。

即便请求及其响应已成功传递,响应也可能代表失败 - 例如,若是请求尝试使用未定义的请求类型或处理器,会引起错误。 为了支持这个,senddefineRequestType遵循前面提到的惯例,其中传递给回调的第一个参数是故障缘由,若是有的话,第二个参数是实际结果。

这些能够由咱们的包装翻译成Promise的解析和拒绝。

class Timeout extends Error {}

function request(nest, target, type, content) {
  return new Promise((resolve, reject) => {
    let done = false;
    function attempt(n) {
      nest.send(target, type, content, (failed, value) => {
        done = true;
        if (failed) reject(failed);
        else resolve(value);
      });
      setTimeout(() => {
        if (done) return;
        else if (n < 3) attempt(n + 1);
        else reject(new Timeout("Timed out"));
      }, 250);
    }
    attempt(1);
  });
}

由于Promise只能解析(或拒绝)一次,因此这个是有效的。 第一次调用resolvereject会决定Promise的结果,而且任何进一步的调用(例如请求结束后到达的超时,或在另外一个请求结束后返回的请求)都将被忽略。

为了构建异步循环,对于重试,咱们须要使用递归函数 - 常规循环不容许咱们中止并等待异步操做。 attempt函数尝试发送请求一次。 它还设置了超时,若是 250 毫秒后没有响应返回,则开始下一次尝试,或者若是这是第四次尝试,则以Timeout实例为理由拒绝该Promise

每四分之一秒重试一次,一秒钟后没有响应就放弃,这绝对是任意的。 甚至有可能,若是请求确实过来了,但处理器花费了更长时间,请求将被屡次传递。 咱们会编写咱们的处理器,并记住这个问题 - 重复的消息应该是无害的。

总的来讲,咱们如今不会创建一个世界级的,强大的网络。 但不要紧 - 在计算方面,乌鸦没有很高的预期。

为了彻底隔离咱们本身的回调,咱们将继续,并为defineRequestType定义一个包装器,它容许处理器返回一个Promise或明确的值,而且链接到咱们的回调。

function requestType(name, handler) {
  defineRequestType(name, (nest, content, source,
                           callback) => {
    try {
      Promise.resolve(handler(nest, content, source))
        .then(response => callback(null, response),
              failure => callback(failure));
    } catch (exception) {
      callback(exception);
    }
  });
}

若是处理器返回的值还不是PromisePromise.resolve用于将转换为Promise

请注意,处理器的调用必须包装在try块中,以确保直接引起的任何异常都会被提供给回调函数。 这很好地说明了使用原始回调正确处理错误的难度 - 很容易忘记正确处理相似的异常,若是不这样作,故障将没法报告给正确的回调。Promise使其大部分是自动的,所以不易出错。

Promise的集合

每台鸟巢计算机在其neighbors属性中,都保存了传输距离内的其余鸟巢的数组。 为了检查当前哪些能够访问,你能够编写一个函数,尝试向每一个鸟巢发送一个"ping"请求(一个简单地请求响应的请求),并查看哪些返回了。

在处理同时运行的Promise集合时,Promise.all函数可能颇有用。 它返回一个Promise,等待数组中的全部Promise解析,而后解析这些Promise产生的值的数组(与原始数组的顺序相同)。 若是任何Promise被拒绝,Promise.all的结果自己被拒绝。

requestType("ping", () => "pong");

function availableNeighbors(nest) {
  let requests = nest.neighbors.map(neighbor => {
    return request(nest, neighbor, "ping")
      .then(() => true, () => false);
  });
  return Promise.all(requests).then(result => {
    return nest.neighbors.filter((_, i) => result[i]);
  });
}

当一个邻居不可用时,咱们不但愿整个组合Promise失败,由于那时咱们仍然不知道任何事情。 所以,在邻居集合上映射一个函数,将它们变成请求Promise,并附加处理器,这些处理器使成功的请求产生true,拒绝的产生false

在组合Promise的处理器中,filter用于从neighbors数组中删除对应值为false的元素。 这利用了一个事实,filter将当前元素的数组索引做为其过滤函数的第二个参数(mapsome和相似的高阶数组方法也同样)。

网络泛洪

鸟巢仅仅能够邻居通讯的事实,极大地减小了这个网络的实用性。

为了将信息广播到整个网络,一种解决方案是设置一种自动转发给邻居的请求。 而后这些邻居转发给它们的邻居,直到整个网络收到这个消息。

import {everywhere} from "./crow-tech";

everywhere(nest => {
  nest.state.gossip = [];
});

function sendGossip(nest, message, exceptFor = null) {
  nest.state.gossip.push(message);
  for (let neighbor of nest.neighbors) {
    if (neighbor == exceptFor) continue;
    request(nest, neighbor, "gossip", message);
  }
}

requestType("gossip", (nest, message, source) => {
  if (nest.state.gossip.includes(message)) return;
  console.log(`${nest.name} received gossip '${
               message}' from ${source}`);
  sendGossip(nest, message, source);
});

为了不永远在网络上发送相同的消息,每一个鸟巢都保留一组已经看到的闲话字符串。 为了定义这个数组,咱们使用everywhere函数(它在每一个鸟巢上运行代码)向鸟巢的状态对象添加一个属性,这是咱们将保存鸟巢局部状态的地方。

当一个鸟巢收到一个重复的闲话消息,它会忽略它。每一个人都盲目从新发送这些消息时,这极可能发生。 可是当它收到一条新消息时,它会兴奋地告诉它的全部邻居,除了发送消息的那个邻居。

这将致使一条新的闲话经过网络传播,如在水中的墨水同样。 即便一些链接目前不工做,若是有一条通往指定鸟巢的替代路线,闲话将经过那里到达它。

这种网络通讯方式称为泛洪 - 它用一条信息充满网络,直到全部节点都拥有它。

咱们能够调用sendGossip看看村子里的消息流。

sendGossip(bigOak, "Kids with airgun in the park");

消息路由

若是给定节点想要与其余单个节点通讯,泛洪不是一种很是有效的方法。 特别是当网络很大时,这会致使大量无用的数据传输。

另外一种方法是为消息设置节点到节点的传输方式,直到它们到达目的地。 这样作的困难在于,它须要网络布局的知识。 为了向远方的鸟巢发送请求,有必要知道哪一个邻近的鸟巢更靠近其目的地。 以错误的方向发送它不会有太大好处。

因为每一个鸟巢只知道它的直接邻居,所以它没有计算路线所需的信息。 咱们必须以某种方式,将这些链接的信息传播给全部鸟巢。 当放弃或建造新的鸟巢时,最好是容许它随时间改变的方式。

咱们能够再次使用泛洪,但不检查给定的消息是否已经收到,而是检查对于给定鸟巢来讲,邻居的新集合,是否匹配咱们拥有的当前集合。

requestType("connections", (nest, {name, neighbors},
                            source) => {
  let connections = nest.state.connections;
  if (JSON.stringify(connections.get(name)) ==
      JSON.stringify(neighbors)) return;
  connections.set(name, neighbors);
  broadcastConnections(nest, name, source);
});

function broadcastConnections(nest, name, exceptFor = null) {
  for (let neighbor of nest.neighbors) {
    if (neighbor == exceptFor) continue;
    request(nest, neighbor, "connections", {
      name,
      neighbors: nest.state.connections.get(name)
    });
  }
}

everywhere(nest => {
  nest.state.connections = new Map;
  nest.state.connections.set(nest.name, nest.neighbors);
  broadcastConnections(nest, nest.name);
});

该比较使用JSON.stringify,由于对象或数组上的==只有在二者彻底相同时才返回true,这不是咱们这里所需的。 比较 JSON 字符串是比较其内容的一种简单而有效的方式。

节点当即开始广播它们的链接,它们应该当即为每一个鸟巢提供当前网络图的映射,除非有一些鸟巢彻底没法到达。

你能够用图作的事情,就是找到里面的路径,就像咱们在第 7 章中看到的那样。若是咱们有一条通往消息目的地的路线,咱们知道将它发送到哪一个方向。

这个findRoute函数很是相似于第 7 章中的findRoute,它搜索到达网络中给定节点的路线。 但不是返回整个路线,而是返回下一步。 下一个鸟巢将使用它的有关网络的当前信息,来决定将消息发送到哪里。

function findRoute(from, to, connections) {
  let work = [{at: from, via: null}];
  for (let i = 0; i < work.length; i++) {
    let {at, via} = work[i];
    for (let next of connections.get(at) || []) {
      if (next == to) return via;
      if (!work.some(w => w.at == next)) {
        work.push({at: next, via: via || next});
      }
    }
  }
  return null;
}

如今咱们能够创建一个能够发送长途信息的函数。 若是该消息被发送给直接邻居,它将照常发送。 若是不是,则将其封装在一个对象中,并使用"route"请求类型,将其发送到更接近目标的邻居,这将致使该邻居重复相同的行为。

function routeRequest(nest, target, type, content) {
  if (nest.neighbors.includes(target)) {
    return request(nest, target, type, content);
  } else {
    let via = findRoute(nest.name, target,
                        nest.state.connections);
    if (!via) throw new Error(`No route to ${target}`);
    return request(nest, via, "route",
                   {target, type, content});
  }
}

requestType("route", (nest, {target, type, content}) => {
  return routeRequest(nest, target, type, content);
});

咱们如今能够将消息发送到教堂塔楼的鸟巢中,它的距离有四跳。

routeRequest(bigOak, "Church Tower", "note",
             "Incoming jackdaws!");

咱们已经在原始通讯系统的基础上构建了几层功能,来使其便于使用。 这是一个(尽管是简化的)真实计算机网络工做原理的很好的模型。

计算机网络的一个显着特色是它们不可靠 - 创建在它们之上的抽象能够提供帮助,可是不能抽象出网络故障。因此网络编程一般关于预测和处理故障。

async函数

为了存储重要信息,据了解乌鸦在鸟巢中复制它。 这样,当一只鹰摧毁一个鸟巢时,信息不会丢失。

为了检索它本身的存储器中没有的信息,鸟巢计算机可能会询问网络中其余随机鸟巢,直到找到一个鸟巢计算机。

requestType("storage", (nest, name) => storage(nest, name));

function findInStorage(nest, name) {
  return storage(nest, name).then(found => {
    if (found != null) return found;
    else return findInRemoteStorage(nest, name);
  });
}

function network(nest) {
  return Array.from(nest.state.connections.keys());
}

function findInRemoteStorage(nest, name) {
  let sources = network(nest).filter(n => n != nest.name);
  function next() {
    if (sources.length == 0) {
      return Promise.reject(new Error("Not found"));
    } else {
      let source = sources[Math.floor(Math.random() *
                                      sources.length)];
      sources = sources.filter(n => n != source);
      return routeRequest(nest, source, "storage", name)
        .then(value => value != null ? value : next(),
              next);
    }
  }
  return next();
}

由于connections 是一个MapObject.keys不起做用。 它有一个key方法,可是它返回一个迭代器而不是数组。 可使用Array.from函数将迭代器(或可迭代对象)转换为数组。

即便使用Promise,这是一些至关笨拙的代码。 多个异步操做以不清晰的方式连接在一块儿。 咱们再次须要一个递归函数(next)来建模鸟巢上的遍历。

代码实际上作的事情是彻底线性的 - 在开始下一个动做以前,它老是等待先前的动做完成。 在同步编程模型中,表达会更简单。

好消息是 JavaScript 容许你编写伪同步代码。 异步函数是一种隐式返回Promise的函数,它能够在其主体中,以看起来同步的方式等待其余Promise

咱们能够像这样重写findInStorage

async function findInStorage(nest, name) {
  let local = await storage(nest, name);
  if (local != null) return local;

  let sources = network(nest).filter(n => n != nest.name);
  while (sources.length > 0) {
    let source = sources[Math.floor(Math.random() *
                                    sources.length)];
    sources = sources.filter(n => n != source);
    try {
      let found = await routeRequest(nest, source, "storage",
                                     name);
      if (found != null) return found;
    } catch (_) {}
  }
  throw new Error("Not found");
}

异步函数由function关键字以前的async标记。 方法也能够经过在名称前面编写async来作成异步的。 当调用这样的函数或方法时,它返回一个Promise。 只要主体返回了某些东西,这个Promise就解析了。 若是它抛出异常,则Promise被拒绝。

findInStorage(bigOak, "events on 2017-12-21")
  .then(console.log);

在异步函数内部,await这个词能够放在表达式的前面,等待解Promise被解析,而后才能继续执行函数。

这样的函数再也不像常规的 JavaScript 函数同样,从头至尾运行。 相反,它能够在有任何带有await的地方冻结,并在稍后恢复。

对于有意义的异步代码,这种标记一般比直接使用Promise更方便。即便你须要作一些不适合同步模型的东西,好比同时执行多个动做,也很容易将await和直接使用Promise结合起来。

生成器

函数暂停而后再次恢复的能力,不是异步函数所独有的。 JavaScript 也有一个称为生成器函数的特性。 这些都是类似的,但没有Promise

当用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表达式,该表达式会暂停它,并使得产生的值成为由迭代器产生的下一个值。 当函数返回时(示例中的那个永远不会),迭代器就结束了。

使用生成器函数时,编写迭代器一般要容易得多。 能够用这个生成器编写group类的迭代器(来自第 6 章的练习):

Group.prototype[Symbol.iterator] = function*() {
  for (let i = 0; i < this.members.length; i++) {
    yield this.members[i];
  }
};

再也不须要建立一个对象来保存迭代状态 - 生成器每次yield时都会自动保存其本地状态。

这样的yield表达式可能仅仅直接出如今生成器函数自己中,而不是在你定义的内部函数中。 生成器在返回(yield)时保存的状态,只是它的本地环境和它yield的位置。

异步函数是一种特殊的生成器。 它在调用时会产生一个Promise,当它返回(完成)时被解析,并在抛出异常时被拒绝。 每当它yieldawait)一个Promise时,该Promise的结果(值或抛出的异常)就是await表达式的结果。

事件循环

异步程序是逐片断执行的。 每一个片断可能会启动一些操做,并调度代码在操做完成或失败时执行。 在这些片断之间,该程序处于空闲状态,等待下一个动做。

因此回调函数不会直接被调度它们的代码调用。 若是我从一个函数中调用setTimeout,那么在调用回调函数时该函数已经返回。 当回调返回时,控制权不会回到调度它的函数。

异步行为发生在它本身的空函数调用堆栈上。 这是没有Promise的状况下,在异步代码之间管理异常很难的缘由之一。 因为每一个回调函数都是以几乎为空的堆栈开始,所以当它们抛出一个异常时,你的catch处理程序不会在堆栈中。

try {
  setTimeout(() => {
    throw new Error("Woosh");
  }, 20);
} catch (_) {
  // This will not run
  console.log("Caught!");
}

不管事件发生多么紧密(例如超时或传入请求),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

在后面的章节中,咱们将看到在事件循环中运行的,各类其余类型的事件。

异步的 bug

当你的程序同步运行时,除了那些程序自己所作的外,没有发生任何状态变化。 对于异步程序,这是不一样的 - 它们在执行期间可能会有空白,这个时候其余代码能够运行。

咱们来看一个例子。 咱们乌鸦的爱好之一是计算整个村庄每一年孵化的雏鸡数量。 鸟巢将这一数量存储在他们的存储器中。 下面的代码尝试枚举给定年份的全部鸟巢的计数。

function anyStorage(nest, source, name) {
  if (source == nest.name) return storage(nest, name);
  else return routeRequest(nest, source, "storage", name);
}

async function chicks(nest, year) {
  let list = "";
  await Promise.all(network(nest).map(async name => {
    list += `${name}: ${
      await anyStorage(nest, name, `chicks in ${year}`)
    }\n`;
  }));
  return list;
}

async name =>部分展现了,经过将单词async放在它们前面,也可使箭头函数变成异步的。

代码不会当即看上去有问题......它将异步箭头函数映射到鸟巢集合上,建立一组Promise,而后使用Promise.all,在返回它们构建的列表以前等待全部Promise

但它有严重问题。 它老是只返回一行输出,列出响应最慢的鸟巢。

chicks(bigOak, 2017).then(console.log);

你能解释为何吗?

问题在于+=操做符,它在语句开始执行时接受list的当前值,而后当await结束时,将list绑定设为该值加上新增的字符串。

可是在语句开始执行的时间和它完成的时间之间存在一个异步间隔。 map表达式在任何内容添加到列表以前运行,所以每一个+ =操做符都以一个空字符串开始,并在存储检索完成时结束,将list设置为单行列表 - 向空字符串添加那行的结果。

经过从映射的Promise中返回行,并对Promise.all的结果调用join,能够轻松避免这种状况,而不是经过更改绑定来构建列表。 像往常同样,计算新值比改变现有值的错误更少。

async function chicks(nest, year) {
  let lines = network(nest).map(async name => {
    return name + ": " +
      await anyStorage(nest, name, `chicks in ${year}`);
  });
  return (await Promise.all(lines)).join("\n");
}

像这样的错误很容易作出来,特别是在使用await时,你应该知道代码中的间隔在哪里出现。 JavaScript 的显式异步性(不管是经过回调,Promise仍是await)的一个优势是,发现这些间隔相对容易。

总结

异步编程能够表示等待长时间运行的动做,而不须要在这些动做期间冻结程序。 JavaScript 环境一般使用回调函数来实现这种编程风格,这些函数在动做完成时被调用。 事件循环调度这样的回调,使其在适当的时候依次被调用,以便它们的执行不会重叠。

Promise和异步函数使异步编程更容易。Promise是一个对象,表明未来可能完成的操做。而且,异步函数使你能够像编写同步程序同样编写异步程序。

练习

跟踪手术刀

村里的乌鸦拥有一把老式的手术刀,他们偶尔会用于特殊的任务 - 好比说,切开纱门或包装。 为了可以快速追踪到手术刀,每次将手术刀移动到另外一个鸟巢时,将一个条目添加到拥有它和拿走它的鸟巢的存储器中,名称为"scalpel",值为新的位置。

这意味着找到手术刀就是跟踪存储器条目的痕迹,直到你发现一个鸟巢指向它自己。

编写一个异步函数locateScalpel,它从它运行的鸟巢开始。 你可使用以前定义的anyStorage函数,来访问任意鸟巢中的存储器。 手术刀已经移动了很长时间,你可能会认为每一个鸟巢的数据存储器中都有一个"scalpel"条目。

接下来,再次写入相同的函数,而不使用asyncawait

在两个版本中,请求故障是否正确显示为拒绝? 如何实现?

async function locateScalpel(nest) {
  // Your code here.
}

function locateScalpel2(nest) {
  // Your code here.
}

locateScalpel(bigOak).then(console.log);
// → Butcher Shop

构建Promise.all

给定Promise的数组,Promise.all返回一个Promise,等待数组中的全部Promise完成。 而后它成功,产生结果值的数组。 若是数组中的一个Promise失败,这个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);
    }
  });
相关文章
相关标签/搜索