原文連結: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 的。有兩個使用條件:
- 傳入一個唯一的非同步函式
- 傳入的函式希望是錯誤優先的(比如:(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);
});
複製程式碼
任意一個非同步函式 reject
,Promise.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 困擾了我很久。語法看起來比回撥要複雜,好多地方會出錯,除錯也成問題。可是,學習基礎還是很重要滴。
延伸閱讀:
- MDN Promise documentation
- JavaScript Promises: an Introduction
- JavaScript Promises … In Wicked Detail
- Promises for asynchronous programming
Async/Await
Promise 看起來有點複雜,所以 ES2017 引進了 async
和 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))
});
}
// 執行 connect 方法 (自執行方法)
(() => {
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;
}
}
// 執行 connect 方法 (自執行非同步函式)
(async () => { await connect(); })();
複製程式碼
await
使每個非同步呼叫看起來像是同步的,同時不耽誤 JavaScript 的單執行緒處理。此外,async
函式總是返回一個 Promise 物件,因此它可以被其他 async
函式呼叫。
async
/ await
可能不會讓程式碼變少,但是有很多優點:
- 語法更清晰。括號越來越少,出錯的可能性也越來越小。
- 除錯更容易。可以在任何
await
宣告處設定斷點。 - 錯誤處理尚佳。
try
/catch
可以與同步程式碼使用相同的處理方式。 - 支援良好。所有瀏覽器(除了 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);
}
}
複製程式碼
然而,在非同步迭代器實現之前,最好的方案是將陣列每項 map
到 async
函式,並用 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
了(感謝@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 - 如果沒接觸過的話。