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);
    }
  });
相關文章
相關標籤/搜索