現代JS中的流程控制:詳解Callbacks 、Promises 、Async/Await

JavaScript常常聲稱是_異步_。那是什麼意思?它如何影響發展?近年來這種方法有何變化?html

請思考如下代碼:node

result1 = doSomething1();
result2 = doSomething2(result1);

大多數語言都處理每一行同步。第一行運行並返回結果。第二行在第一行完成後運行不管須要多長時間。git

單線程處理

JavaScript在單個處理線程上運行。在瀏覽器選項卡中執行時,其餘全部內容都會中止,由於在並行線程上不會發生對頁面DOM的更改;將一個線程重定向到另外一個URL而另外一個線程嘗試追加子節點是危險的。es6

這對用戶來講是顯而易見。例如,JavaScript檢測到按鈕單擊,運行計算並更新DOM。完成後,瀏覽器能夠自由處理隊列中的下一個項目。github

(旁註:其餘語言如PHP也使用單個線程,但能夠由多線程服務器(如Apache)管理。同時對同一個PHP運行時頁面的兩個請求能夠啓動兩個運行隔離的實例的線程。)web

使用回調進行異步

單線程引起了一個問題。當JavaScript調用「慢」進程(例如瀏覽器中的Ajax請求或服務器上的數據庫操做)時會發生什麼?這個操做可能須要幾秒鐘 - 甚至幾分鐘。瀏覽器在等待響應時會被鎖定。在服務器上,Node.js應用程序將沒法進一步處理用戶請求。數據庫

解決方案是異步處理。而不是等待完成,一個進程被告知在結果準備好時調用另外一個函數。這稱爲callback,它做爲參數傳遞給任何異步函數。例如:編程

doSomethingAsync(callback1);
console.log('finished');

// call when doSomethingAsync completes
function callback1(error) {
  if (!error) console.log('doSomethingAsync complete');
}

doSomethingAsync()接受一個回調函數做爲參數(只傳遞對該函數的引用,所以幾乎沒有開銷)。doSomethingAsync()須要多長時間並不重要;咱們所知道的是callback1()將在將來的某個時刻執行。控制檯將顯示:api

finished
doSomethingAsync complete

回調地獄

一般,回調只能由一個異步函數調用。所以可使用簡潔的匿名內聯函數:數組

doSomethingAsync(error => {
  if (!error) console.log('doSomethingAsync complete');
});

經過嵌套回調函數,能夠串行完成一系列兩個或多個異步調用。例如:

async1((err, res) => {
  if (!err) async2(res, (err, res) => {
    if (!err) async3(res, (err, res) => {
      console.log('async1, async2, async3 complete.');
    });
  });
});

不幸的是,這引入了回調地獄 - 一個臭名昭着的概念(http://callbackhell.com/) !代碼難以閱讀,而且在添加錯誤處理邏輯時會變得更糟。

回調地獄在客戶端編碼中相對較少。若是您正在進行Ajax調用,更新DOM並等待動畫完成,它能夠深刻兩到三個級別,但它一般仍然能夠管理。

操做系統或服務器進程的狀況不一樣。Node.js API調用能夠接收文件上載,更新多個數據庫表,寫入日誌,並在發送響應以前進行進一步的API調用。

Promises

ES2015(ES6)推出了Promises。回調仍然可使用,但Promises提供了更清晰的語法chains異步命令,所以它們能夠串行運行(更多相關內容)。

要啓用基於Promise的執行,必須更改基於異步回調的函數,以便它們當即返回Promise對象。該promises對象在未來的某個時刻運行兩個函數之一(做爲參數傳遞):

  • resolve :處理成功完成時運行的回調函數
  • reject :發生故障時運行的可選回調函數。

在下面的示例中,數據庫API提供了一個接受回調函數的connect()方法。外部asyncDBconnect()函數當即返回一個新的Promise,並在創建鏈接或失敗後運行resolve()或reject():

const db = require('database');

// connect to database
function asyncDBconnect(param) {

  return new Promise((resolve, reject) => {

    db.connect(param, (err, connection) => {
      if (err) reject(err);
      else resolve(connection);
    });

  });

}

Node.js 8.0+提供了util.promisify()實用程序,將基於回調的函數轉換爲基於Promise的替代方法。有幾個條件:

  1. 將回調做爲最後一個參數傳遞給異步函數
  2. 回調函數必須指向一個錯誤,後跟一個值參數。

例子:

// Node.js: promisify fs.readFile
const
  util = require('util'),
  fs = require('fs'),
  readFileAsync = util.promisify(fs.readFile);

readFileAsync('file.txt');

各類客戶端庫也提供promisify選項,但您能夠本身建立幾個:

// promisify a callback function passed as the last parameter
// the callback function must accept (err, data) parameters
function promisify(fn) {
  return function() {
      return new Promise(
        (resolve, reject) => fn(
          ...Array.from(arguments),
        (err, data) => err ? reject(err) : resolve(data)
      )
    );
  }
}

// example
function wait(time, callback) {
  setTimeout(() => { callback(null, 'done'); }, time);
}

const asyncWait = promisify(wait);

ayscWait(1000);

異步鏈

任何返回Promise的東西均可以啓動.then()方法中定義的一系列異步函數調用。每一個都傳遞了上一個解決方案的結果:

asyncDBconnect('http://localhost:1234')
  .then(asyncGetSession)      // passed result of asyncDBconnect
  .then(asyncGetUser)         // passed result of asyncGetSession
  .then(asyncLogAccess)       // passed result of asyncGetUser
  .then(result => {           // non-asynchronous function
    console.log('complete');  //   (passed result of asyncLogAccess)
    return result;            //   (result passed to next .then())
  })
  .catch(err => {             // called on any reject
    console.log('error', err);
  });

同步函數也能夠在.then()塊中執行。返回的值將傳遞給下一個.then()(若是有)。

.catch()方法定義了在觸發任何先前拒絕時調用的函數。此時,不會再運行.then()方法。您能夠在整個鏈中使用多個.catch()方法來捕獲不一樣的錯誤。

ES2018引入了一個.finally()方法,不管結果如何都運行任何最終邏輯 - 例如,清理,關閉數據庫鏈接等。目前僅支持Chrome和Firefox,但技術委員會39已發佈了 .finally() polyfill.

function doSomething() {
  doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => {
    console.log(err);
  })
  .finally(() => {
    // tidy-up here!
  });
}

使用Promise.all()進行多個異步調用

Promise .then()方法一個接一個地運行異步函數。若是順序可有可無 - 例如,初始化不相關的組件 - 同時啓動全部異步函數並在最後(最慢)函數運行解析時結束更快。

這能夠經過Promise.all()來實現。它接受一組函數並返回另外一個Promise。例如:

Promise.all([ async1, async2, async3 ])
  .then(values => {           // array of resolved values
    console.log(values);      // (in same order as function array)
    return values;
  })
  .catch(err => {             // called on any reject
    console.log('error', err);
  });

若是任何一個異步函數調用失敗,則Promise.all()當即終止。

使用Promise.race的多個異步調用()

Promise.race()與Promise.all()相似,只是它會在first Promise解析或拒絕後當即解析或拒絕。只有最快的基於Promise的異步函數才能完成:

Promise.race([ async1, async2, async3 ])
  .then(value => {            // single value
    console.log(value);
    return value;
  })
  .catch(err => {             // called on any reject
    console.log('error', err);
  });

可是有什麼別的問題嗎?

Promises 減小了回調地獄但引入了別的問題。

教程常常沒有提到_整個Promise鏈是異步的。使用一系列promise的任何函數都應返回本身的Promise或在最終的.then(),. catch()或.finally()方法中運行回調函數。

學習基礎知識相當重要。

更多的關於Promises的資源:

Async/Await

Promises 可能使人生畏,所以ES2017引入了async and await。 雖然它可能只是語法糖,它使Promise更完善,你能夠徹底避免.then()鏈。 考慮下面的基於Promise的示例:

function connect() {

  return new Promise((resolve, reject) => {

    asyncDBconnect('http://localhost:1234')
      .then(asyncGetSession)
      .then(asyncGetUser)
      .then(asyncLogAccess)
      .then(result => resolve(result))
      .catch(err => reject(err))

  });
}

// run connect (self-executing function)
(() => {
  connect();
    .then(result => console.log(result))
    .catch(err => console.log(err))
})();

用這個重寫一下async/await:

  • 外部函數必須以async語句開頭
  • 對異步的基於Promise的函數的調用必須在await以前,以確保在下一個命令執行以前完成處理。
async function connect() {

  try {
    const
      connection = await asyncDBconnect('http://localhost:1234'),
      session = await asyncGetSession(connection),
      user = await asyncGetUser(session),
      log = await asyncLogAccess(user);

    return log;
  }
  catch (e) {
    console.log('error', err);
    return null;
  }

}

// run connect (self-executing async function)
(async () => { await connect(); })();

await有效地使每一個調用看起來好像是同步的,而不是阻止JavaScript的單個處理線程。 此外,異步函數老是返回一個Promise,所以它們能夠被其餘異步函數調用。

async/await 代碼可能不會更短,但有至關大的好處:

一、語法更清晰。括號更少,錯誤更少。

二、調試更容易。能夠在任何await語句上設置斷點。
三、錯誤處理更好。try / catch塊能夠與同步代碼同樣使用。

四、支持很好。它在全部瀏覽器(IE和Opera Mini除外)和Node 7.6+中都獲得了支持。

可是並不是全部都是完美的......

切勿濫用async/await

async / await仍然依賴於Promises,它最終依賴於回調。你須要瞭解Promises是如何工做的,而且沒有Promise.all()和Promise.race()的直接等價物。而且不要忘記Promise.all(),它比使用一系列不相關的await命令更有效。

同步循環中的異步等待

在某些時候,您將嘗試調用異步函數中的同步循環。例如:

async function process(array) {
  for (let i of array) {
    await doSomething(i);
  }
}

它不會起做用。這也不會:

async function process(array) {
  array.forEach(async i => {
    await doSomething(i);
  });
}

循環自己保持同步,而且老是在它們的內部異步操做以前完成。

ES2018引入了異步迭代器,它與常規迭代器同樣,但next()方法返回Promise。所以,await關鍵字能夠與for循環一塊兒用於串行運行異步操做。例如:

async function process(array) {
  for await (let i of array) {
    doSomething(i);
  }
}

可是,在實現異步迭代器以前,最好將數組項映射到異步函數並使用Promise.all()運行它們。例如:

const
  todo = ['a', 'b', 'c'],
  alltodo = todo.map(async (v, i) => {
    console.log('iteration', i);
    await processSomething(v);
});

await Promise.all(alltodo);

這具備並行運行任務的好處,可是不可能將一次迭代的結果傳遞給另外一次迭代,而且映射大型數組可能在性能消耗上是很昂貴。

try/catch 有哪些問題了?

若是省略任何await失敗的try / catch,async函數將以靜默方式退出。若是您有一組很長的異步await命令,則可能須要多個try / catch塊。

一種替代方案是高階函數,它捕獲錯誤,所以try / catch塊變得沒必要要(thanks to @wesbos for the suggestion):

async function connect() {

  const
    connection = await asyncDBconnect('http://localhost:1234'),
    session = await asyncGetSession(connection),
    user = await asyncGetUser(session),
    log = await asyncLogAccess(user);

  return true;
}

// higher-order function to catch errors
function catchErrors(fn) {
  return function (...args) {
    return fn(...args).catch(err => {
      console.log('ERROR', err);
    });
  }
}

(async () => {
  await catchErrors(connect)();
})();

可是,在應用程序必須以與其餘錯誤不一樣的方式對某些錯誤作出反應的狀況下,此選項可能不實用。

儘管有一些陷阱,async / await是JavaScript的一個優雅補充。更多資源:

JavaScript 旅程

異步編程是一項在JavaScript中沒法避免的挑戰。回調在大多數應用程序中都是必不可少的,但它很容易陷入深層嵌套的函數中。

Promises 抽象回調,但有許多語法陷阱。 轉換現有函數多是一件苦差事,而.then()鏈仍然看起來很混亂。

幸運的是,async / await提供了清晰度。代碼看起來是同步的,但它不能獨佔單個處理線程。它將改變你編寫JavaScript的方式!

(譯者注:Craig Buckler講解JavaScript的文章都還不錯,基本是用一些比較通俗的語言和代碼事例講解了JavaScript的一些特性和一些語法可能出現的問題。感興趣的朋友能夠看一下(https://www.sitepoint.com/aut...))

相關文章
相關標籤/搜索