現代 JS 流程控制:從回撥函式到 Promises 再到 Async/Await

OFED發表於2018-08-27

原文連結:Flow Control in Modern JS: Callbacks to Promises to Async/Await

譯者:OFED

JavaScript 通常被認為是非同步的。這意味著什麼?對開發有什麼影響呢?近年來,它又發生了怎樣的變化?

看看以下程式碼:

result1 = doSomething1();
result2 = doSomething2(result1);
複製程式碼

大多數程式語言同步執行每行程式碼。第一行執行完畢返回一個結果。無論第一行程式碼執行多久,只有執行完成第二行程式碼才會執行。

單執行緒處理程式

JavaScript 是單執行緒的。當瀏覽器選項卡執行指令碼時,其他所有操作都會停止。這是必然的,因為對頁面 DOM 的更改不能併發執行;一個執行緒 重定向 URL 的同時,另一個執行緒正要新增子節點,這麼做是危險的。

使用者不容易察覺,因為處理程式會以組塊的形式快速執行。例如,JavaScript 檢測到按鈕點選,執行計算,並更新 DOM。一旦完成,瀏覽器就可以自由處理佇列中的下一個專案。

(附註: 其它語言比如 PHP 也是單執行緒,但是通過多執行緒的伺服器比如 Apache 管理。同一 PHP 頁面同時發起的兩個請求,可以啟動兩個執行緒執行,它們是彼此隔離的 PHP 例項。)

通過回撥實現非同步

單執行緒產生了一個問題。當 JavaScript 執行一個“緩慢”的處理程式,比如瀏覽器中的 Ajax 請求或者伺服器上的資料庫操作時,會發生什麼?這些操作可能需要幾秒鐘 - 甚至幾分鐘。瀏覽器在等待響應時會被鎖定。在伺服器上,Node.js 應用將無法處理其它的使用者請求。

解決方案是非同步處理。當結果就緒時,一個程式被告知呼叫另一個函式,而不是等待完成。這稱之為回撥,它作為引數傳遞給任何非同步函式。例如:

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

// 當 doSomethingAsync 完成時呼叫
function callback1(error) {
  if (!error) console.log('doSomethingAsync complete');
}
複製程式碼

doSomethingAsync() 接收回撥函式作為引數(只傳遞該函式的引用,因此開銷很小)。doSomethingAsync() 執行多長時間並不重要;我們所知道的是,callback1() 將在未來某個時刻執行。控制檯將顯示:

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.');
    });
  });
});
複製程式碼

不幸的是,這引入了回撥地獄 —— 一個臭名昭著的概念,甚至有專門的網頁介紹!程式碼很難讀,並且在新增錯誤處理邏輯時變得更糟。

回撥地獄在客戶端編碼中相對少見。如果你呼叫 Ajax 請求、更新 DOM 並等待動畫完成,可能需要巢狀兩到三層,但是通常還算可管理。

作業系統或伺服器程式的情況就不同了。一個 Node.js API 可以接收檔案上傳,更新多個資料庫表,寫入日誌,並在傳送響應之前進行下一步的 API 呼叫。

Promises

ES2015(ES6) 引入了 Promises。回撥函式依然有用,但是 Promises 提供了更清晰的鏈式非同步命令語法,因此可以串聯執行(下個章節會講)。

打算基於 Promise 封裝,非同步回撥函式必須返回一個 Promise 物件。Promise 物件會執行以下兩個函式(作為引數傳遞的)其中之一:

  • resolve:執行成功回撥
  • reject:執行失敗回撥

以下例子,database API 提供了一個 connect() 方法,接收一個回撥函式。外部的 asyncDBconnect() 函式立即返回了一個新的 Promise,一旦連線建立成功或失敗,resolve()reject() 便會執行:

const db = require('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. 傳入的函式希望是錯誤優先的(比如:(err, value) => ...),error 引數在前,value 隨後

舉例:

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

readFileAsync('file.txt');
複製程式碼

各種庫都會提供自己的 promisify 方法,寥寥幾行也可以自己擼一個:

// promisify 只接收一個函式引數
// 傳入的函式接收 (err, data) 引數
function promisify(fn) {
  return function() {
      return new Promise(
        (resolve, reject) => fn(
          ...Array.from(arguments),
        (err, data) => err ? reject(err) : resolve(data)
      )
    );
  }
}

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

const asyncWait = promisify(wait);

ayscWait(1000);
複製程式碼

非同步鏈式呼叫

任何返回 Promise 的函式都可以通過 .then() 鏈式呼叫。前一個 resolve 的結果會傳遞給後一個:

asyncDBconnect('http://localhost:1234')
  .then(asyncGetSession)      // 傳遞 asyncDBconnect 的結果
  .then(asyncGetUser)         // 傳遞 asyncGetSession 的結果
  .then(asyncLogAccess)       // 傳遞 asyncGetUser 的結果
  .then(result => {           // 同步函式
    console.log('complete');  //   (傳遞 asyncLogAccess 的結果)
    return result;            //   (結果傳給下一個 .then())
  })
  .catch(err => {             // 任何一個 reject 觸發
    console.log('error', err);
  });
複製程式碼

同步函式也可以執行 .then(),返回的值傳遞給下一個 .then()(如果有)。

當任何一個前面的 reject 觸發時,.catch() 函式會被呼叫。觸發 reject 的函式後面的 .then() 也不再執行。貫穿整個鏈條可以存在多個 .catch() 方法,從而捕獲不同的錯誤。

ES2018 引入了 .finally() 方法,它不管返回結果如何,都會執行最終邏輯 - 例如,清理操作,關閉資料庫連線等等。當前僅有 Chrome 和 Firefox 支援,但是 TC39 技術委員會已經發布了 .finally() 補丁

function doSomething() {
  doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => {
    console.log(err);
  })
  .finally(() => {
    // 清理操作放這兒!
  });
}
複製程式碼

使用 Promise.all() 處理多個非同步操作

Promise .then() 方法用於相繼執行的非同步函式。如果不關心順序 - 比如,初始化不相關的元件 - 所有非同步函式同時啟動,直到最慢的函式執行 resolve,整個流程結束。

Promise.all() 適用於這種場景,它接收一個函式陣列並且返回另一個 Promise。舉例:

Promise.all([ async1, async2, async3 ])
  .then(values => {           // 返回值的陣列
    console.log(values);      // (與函式陣列順序一致)
    return values;
  })
  .catch(err => {             // 任一 reject 被觸發
    console.log('error', err);
  });
複製程式碼

任意一個非同步函式 rejectPromise.all() 會立即結束。

使用 Promise.race() 處理多個非同步操作

Promise.race()Promise.all() 極其相似,不同之處在於,當首個 Promise resolve 或者 reject 時,它將會 resolve 或者 reject。僅有最快的非同步函式會被執行:

Promise.race([ async1, async2, async3 ])
  .then(value => {            // 單一值
    console.log(value);
    return value;
  })
  .catch(err => {             // 任一 reject 被觸發
    console.log('error', err);
  });
複製程式碼

前途光明嗎?

Promise 減少了回撥地獄,但是引入了其他的問題。

教程常常不提,整個 Promise 鏈條是非同步的,一系列的 Promise 函式都得返回自己的 Promise 或者在最終的 .then().catch() 或者 .finally() 方法裡面執行回撥。

我也承認:Promise 困擾了我很久。語法看起來比回撥要複雜,好多地方會出錯,除錯也成問題。可是,學習基礎還是很重要滴。

延伸閱讀:

Async/Await

Promise 看起來有點複雜,所以 ES2017 引進了 asyncawait。雖然只是語法糖,卻使 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))

  });
}

// 執行 connect 方法 (自執行方法)
(() => {
  connect();
    .then(result => console.log(result))
    .catch(err => console.log(err))
})();
複製程式碼

使用 async / await 重寫上面的程式碼:

  1. 外部方法用 async 宣告
  2. 基於 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;
  }

}

// 執行 connect 方法 (自執行非同步函式)
(async () => { await connect(); })();
複製程式碼

await 使每個非同步呼叫看起來像是同步的,同時不耽誤 JavaScript 的單執行緒處理。此外,async 函式總是返回一個 Promise 物件,因此它可以被其他 async 函式呼叫。

async / await 可能不會讓程式碼變少,但是有很多優點:

  1. 語法更清晰。括號越來越少,出錯的可能性也越來越小。
  2. 除錯更容易。可以在任何 await 宣告處設定斷點。
  3. 錯誤處理尚佳。try / catch 可以與同步程式碼使用相同的處理方式。
  4. 支援良好。所有瀏覽器(除了 IE 和 Opera Mini )和 Node7.6+ 均已實現。

如是說,沒有完美的...

Promises, Promises

async / await 仍然依賴 Promise 物件,最終依賴回撥。你需要理解 Promise 的工作原理,它也並不等同於 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 ... of 迴圈一起使用,以序列方式執行非同步操作。例如:

async function process(array) {
  for await (let i of array) {
    doSomething(i);
  }
}
複製程式碼

然而,在非同步迭代器實現之前,最好的方案是將陣列每項 mapasync 函式,並用 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 / catchasync 函式將靜默退出。如果有一長串非同步 await 命令,需要多個 try / catch 包裹。

替代方案是使用高階函式來捕捉錯誤,不再需要 try / catch 了(感謝@wesbos的建議):

async function connect() {

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

  return true;
}

// 使用高階函式捕獲錯誤
function catchErrors(fn) {
  return function (...args) {
    return fn(...args).catch(err => {
      console.log('ERROR', err);
    });
  }
}

(async () => {
  await catchErrors(connect)();
})();
複製程式碼

當應用必須返回區別於其它的錯誤時,這種作法就不太實用了。

儘管有一些缺陷,async/await 還是 JavaScript 非常有用的補充。更多資源:

JavaScript 之旅

非同步程式設計是 JavaScript 無法避免的挑戰。回撥在大多數應用中是必不可少的,但是容易陷入深度巢狀的函式中。

Promise 抽象了回撥,但是有許多句法陷阱。轉換已有函式可能是一件苦差事,·then() 鏈式呼叫看起來很凌亂。

很幸運,async/await 表達清晰。程式碼看起來是同步的,但是又不獨佔單個處理執行緒。它將改變你書寫 JavaScript 的方式,甚至讓你更賞識 Promise - 如果沒接觸過的話。

相關文章