即使有了 async/await,原生 Promise 對於編寫最理想的並行 JS 仍然十分重要

原文  https://medium.com/@bluepnume...javascript

隨着 es2017 即將到來,async/await 的時代也就不遠了。在以前的文章中 我建議要充分掌握 Promise ,由於它是創建 async/await 的基礎。理解 Promise 有助於理解 async/await 的基礎概念,並有助於你編寫更好的 async 函數。java

可是,即便你已緊跟 async 潮流(這是我我的喜歡的)而且徹底理解 Promise,在異步函數中繼續使用它們仍然還有一些很是使人信服的理由。async/await 絕對不會讓你徹底擺脫每一種狀況。爲何?很簡單:node

  • 你可能仍然須要去編寫一些運行在瀏覽器上的代碼。git

  • 單純用 async/await 來編寫並行代碼有時候是不可能或不容易的。github

爲瀏覽器編寫代碼?Babel 不就能夠解決嗎?

很明顯除非你是純粹爲 node 服務端編寫,不然你將不得不考慮在瀏覽器中運行你的 javascript。經過 Babel 編譯,可使 ES2015+ 編寫的代碼運行在較老的瀏覽器中,或者還可使用 Facebook 的一款優秀編譯器 Regenerator,Babel 甚至會能將 async/await 編譯爲向下兼容的代碼。ajax

問題獲得解決,而後呢?好吧,這並不徹底是。c#

關鍵點在於,生成的代碼並不必定是你想在客戶端上運行的。例以下面一個簡單的 async 函數,它使用異步映射函數對數組進行連續映射:數組

async function serialAsyncMap(collection, fn) {
  let result = [];
  
  for (let item of collection) {
    result.push(await fn(item));
  }
  
  return result;
}

這是由 Babel/Regenerator 編譯後的 56 行代碼:promise

var serialAsyncMap = function () {
  var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee(collection, fn) {
    var result, _iterator, _isArray, _i, _ref2, item;
return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            result = [];
            _iterator = collection, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();

請參閱完整代碼瀏覽器

這僅僅是編譯結果中的 7 行代碼。順便說下,在 bundle 前甚至引入了 regeneratorRuntime_asyncToGenerator。雖然 Regenerator 是一個很好的技術功能,可是它並不能編譯出最精簡的代碼來運行在瀏覽器中,我猜想它並非最優或性能最佳的代碼。它也很難閱讀、理解和調試。

假設若是咱們使用原生 Promise 來編寫相同的函數:

function serialAsyncMap(collection, fn) {
  
  let results = [];
  let promise = Promise.resolve();
  
  for (let item of collection) {
    promise = promise.then(() => {
      return fn(item).then(result => {
        results.push(result);
      });
    });
  }
  return promise.then(() => {
    return results;
  });
}

或者更簡潔的版本:

function serialAsyncMap(collection, fn) {
  
  let results = [];
  let promise = Promise.resolve();
  
  for (let item of collection) {
    promise = promise.then(() => fn(item))
                     .then(result => results.push(result));
  }
  
  return promise.then(() => results);
}

原生 Promise 的確比 Regenerator 編譯後的 async/await 代碼更加精簡、易讀及便於調試。調試與環境中 source-map 的支持力度密切相關(一般是必不可少的調試環境,如低版本的IE瀏覽器)。

有其它選擇嗎?

確實有幾種來替代 Regenerator 編譯 async/await 的方式,它提取 async 代碼並嘗試轉換成更傳統的 .then.catch 標記。以個人經驗來講,對於簡單的函數這些轉換運做較良好,在 awaitreturn,最多再加上 try/catch 塊。但更復雜的 async 函數(加上一些條件 await 語句或循環)編譯後的代碼就像是一坨意大利麪。

至少對我來講,這還不夠好;若是我不能簡單的看着編譯後的代碼想象出編譯的結果看起來會是什麼樣,那我可以輕鬆調試代碼的機會就也變得很小。

瀏覽器徹底支持 async/await 是須要很長時間的,因此請不要屏息等待並在客戶端編寫你熟悉的 Promise 代碼。

好吧好吧,因此在客戶端我仍然須要寫 Promise,但只要我運行在 node 服務端就可使用 async/await 了,對嗎?

沒錯,但也不必定。

一般你能夠在 JS 服務器端用一些 async 函數和 await 語法,好比作一些 http 請求,都沒什麼問題。你甚至可使用 Promise.all 來並行異步任務(儘管我認爲這樣有點不適當,用 async/await 運行並行更好)。

但當你想寫一些比 「串聯運行一些異步任務」 或 「並行運行一些異步任務」 更復雜的事情時會發生什麼呢?

舉一個例子

咱們想作一個披薩,考慮如下幾點。

  • 咱們單獨作生麪糰。

  • 咱們單獨作調味醬。

  • 咱們想先品嚐下調味醬再決定用哪一種奶酪搭配比薩餅。

因此,讓咱們從一個超簡單的純 async/await 解決方案開始:

async function makePizza(sauceType = 'red') {
  
  let dough  = await makeDough();
  let sauce  = await makeSauce(sauceType);
  let cheese = await grateCheese(sauce.determineCheese());
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

這有一個很大的優點:它十分簡單、很容易閱讀和理解。首先咱們作生麪糰,而後咱們作調味醬,而後咱們磨奶酪。簡單!

可是這並不徹底是最佳的。咱們得一步一步地作事情,實際咱們應該讓 JS 引擎同時運行這些任務。所以而不是:

|-------- dough --------> |-------- sauce --------> |-- cheese -->

咱們想要的東西更像:

|-------- dough -------->
|-------- sauce --------> |-- cheese -->

用這種方式,這個任務完成得更快了。讓咱們再試一下:

async function makePizza(sauceType = 'red') {
  
  let [ dough, sauce ] =
    await Promise.all([ makeDough(), makeSauce(sauceType) ]);
  let cheese = await grateCheese(sauce.determineCheese());
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

好的,使用 Promise.all 後咱們的代碼看起來更酷些,至少如今是最佳的,對吧?嗯...並非。我甚至在等待生麪糰和調味醬作好後才能開始磨奶酪。若是我很快作好調味醬怎麼辦?如今個人執行看起來像這樣:

|-------- dough -------->
|--- sauce ---> |-- cheese -->

注意,在我想要磨奶酪前,還須要等待生麪糰和調味醬何時作好?在磨奶酪前我只需把調味醬作好,因此我在這裏浪費時間。而後讓咱們回到繪圖板,嘗試使用 Promise.all,而不是 async/await:

function makePizza(sauceType = 'red') {
  
  let doughPromise  = makeDough();
  let saucePromise  = makeSauce(sauceType);
  let cheesePromise = saucePromise.then(sauce => {
    return grateCheese(sauce.determineCheese());
  });
  
  return Promise.all([ doughPromise, saucePromise, cheesePromise ])
    .then(([ dough, sauce, cheese ]) => {
      
      dough.add(sauce);
      dough.add(cheese);
      
      return dough;
    });
}

這樣操做起來好多了。一旦全部的依賴關係實現,如今每一個任務將會盡快地完成。因此惟一能夠阻止我磨奶酪的事情就是等待調味醬。

|--------- dough --------->
|---- sauce ----> |-- cheese -->

可是爲了這樣作,咱們不得不所有退出編寫 async/await 代碼而且所有用 Promise。咱們嘗試着回到 async/await 上。

async function makePizza(sauceType = 'red') {
  
  let doughPromise = makeDough();
  let saucePromise = makeSauce(sauceType);
  
  let sauce  = await saucePromise;
  let cheese = await grateCheese(sauce.determineCheese());
  let dough  = await doughPromise;
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

好吧,因此如今咱們是最佳的,並回到 async/await 塊... 但這仍然感受像一個倒退。咱們必須預先設置每一個 Promise,因此要作好心理準備。咱們同時還依賴這些 Promise 來運行,在任意 await 前將指定任務設置好。這在閱讀代碼時體現並非很明顯,而且未來可能會被意外的分解或破壞。因此這多是我最不喜歡的實現。

讓咱們再試一次。咱們能夠再試一次:

async function makePizza(sauceType = 'red') {
  
  let prepareDough  = memoize(async () => makeDough());
  let prepareSauce  = memoize(async () => makeSauce(sauceType));
  let prepareCheese = memoize(async () => {
    return grateCheese((await prepareSauce()).determineCheese());
  });
  
  let [ dough, sauce, cheese ] = 
    await Promise.all([
      prepareDough(), prepareSauce(), prepareCheese()
    ]);
    
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

這是我最喜歡的解決方案。不用預先設置 Promise,它們隱式地並行運行,咱們能夠設置三個 memoized 任務(確保每次只運行一次),並在 Promise.all 中調用它們以並行運行。

這裏除 Promise.all 外,咱們幾乎沒有涉及其餘的 Promise,儘管它們在 async/await 的底層運轉。這個模式我在另外一篇關於緩存和並行的文章中更詳細地介紹過。但在我看來,這致使了最佳並行和可讀性/可維護性的完美結合。

固然,我老是願意被你們證實是錯的,因此若是你有一個更喜歡的 makePizza 實現,請讓我知道!

因此咱們很快地作了一個披薩,點在哪裏呢?

點在於,若是你在計劃寫徹底並行的代碼,即使是用最新 node.js 版本,知道怎麼將 Promise 和 async/await 混合在一塊兒仍然是一個很是必要的技能。不管你最喜歡的 makePizza 實現是怎樣的,你仍然須要考慮如何將 Promise 連接組合在一塊兒,使函數運行時儘量減小沒必要要的延遲。

async/await 就到這裏,若是你不瞭解 Promise 在你的代碼中如何運行, 你將會卡在這裏並找不到明顯的方式來優化你的並行任務。

到了這裏……

不要懼怕,讓輔助函數從你的業務邏輯中抽象出來 Promise/並行邏輯。一旦你瞭解 Promise 的工做原理,這樣可使你的代碼擺脫意大利麪同樣的雜亂,而且使你的異步程序/業務邏輯函數更清晰的體現出想要作什麼,而不是老是擠在樣板裏。

執行此功能,若是用戶登陸,它將每十秒鐘檢查一次,並在 Promise 中檢測到如下狀況時進行 resolves:

function onUserLoggedIn(id) {
  
  return ajax(`user/${id}`).then(user => {
    
    if (user.state === 'logged_in') {
      return;
    }
    
    return new Promise(resolve => {
      return setTimeout(resolve, 10 * 1000));
    }).then(() => {
      return onUserLoggedIn(id);
    })
  });
}

這並非我想要執行的函數 - 業務邏輯和 promise/delay 邏輯很是緊密地耦合在一塊兒。在我想對函數作些調整前不得不去閱讀和理解這整段內容。
爲了改進這一點,我能夠將 async/promise 邏輯拆成一些獨立的輔助函數,並使個人業務邏輯更簡潔:

function delay(time) {
  return new Promise(resolve => {
    return setTimeout(resolve, time));
  });
}
function until(conditionFn, delayTime = 1000) {
  return Promise.resolve().then(() => {
    return conditionFn();
    
  }).then(result => {
    
    if (!result) {
      return delay(delayTime).then(() => {
        return until(conditionFn, delayTime);
      });
    }
  });
}

或者這些輔助函數的超簡潔版本:

let delay = time =>
    new Promise(resolve =>
        setTimeout(resolve, time)
    );
let until = (cond, time) =>
    cond().then(result =>
        result || delay(time).then(() =>
            until(cond, delay)
        )
    );

而後 onUserLoggedIn 變的與流程控制邏輯不那麼緊密地耦合在一塊兒。

function onUserLoggedIn(id) {
  return until(() => {
    return ajax(`user/${id}`).then(user => {
      return user.state === 'logged_in';
    });
  }, 10 * 1000);
}

如今我更但願可以在未來輕鬆的閱讀和理解 onUserLoggedIn。只要我記得接口的 until 函數,就不用每次從新梳理它的邏輯。我能夠把它扔到一個 promise-utils 文件中並忽略它是如何執行的,最重要的是能夠把注意力集中在本身的應用邏輯。

是的,咱們討論的是 async/await,對吧?嗯,今天是咱們的幸運日,因爲 async/await 和 Promise 是徹底能共同使用的,咱們剛剛無心中創造了一個能夠繼續使用的輔助函數,甚至具備 async 功能:

async function onUserLoggedIn(id) {
  return await until(async () => {
    let user = await ajax(`user/${id}`);
    return user.state === 'logged_in';
  }, 10 * 1000);
}

因此不管代碼是基於 Promise 仍是基於 async/await,規則都是同樣的。若是你發現並行邏輯陷入你的 async 業務函數中,必定要考慮是否能夠抽出一點。固然要在合理範圍內。

這裏有一個至關大的抽象集合,可能有點幫助。

因此,若是你想從這篇文章中有所收貨,就是這些:若是你正在編寫 async/await 代碼,你不只應該理解 Promise 是如何工做的,並且在必要時你還應該使用它們來構建你的 async/await 代碼。單獨的 async/await 不會給你足夠的功能來徹底避免 Promise 思惟。

Thanks!

— Daniel

相關文章
相關標籤/搜索