現代JS中的流程控制:詳解Callbacks 、Promises 、Async/Await
JavaScript經常聲稱是_非同步_。那是什麼意思?它如何影響發展?近年來這種方法有何變化?
請思考以下程式碼:
result1 = doSomething1();
result2 = doSomething2(result1);
大多數語言都處理每一行同步。第一行執行並返回結果。第二行在第一行完成後執行無論需要多長時間。
單執行緒處理
JavaScript在單個處理執行緒上執行。在瀏覽器選項卡中執行時,其他所有內容都會停止,因為在並行執行緒上不會發生對頁面DOM的更改;將一個執行緒重定向到另一個URL而另一個執行緒嘗試追加子節點是危險的。
這對使用者來說是顯而易見。例如,JavaScript檢測到按鈕單擊,執行計算並更新DOM。完成後,瀏覽器可以自由處理佇列中的下一個專案。
(旁註:其他語言如PHP也使用單個執行緒,但可以由多執行緒伺服器(如Apache)管理。同時對同一個PHP執行時頁面的兩個請求可以啟動兩個執行隔離的例項的執行緒。)
使用回撥進行非同步
單執行緒引發了一個問題。當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()將在未來的某個時刻執行。控制檯將顯示:
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的替代方法。有幾個條件:
- 將回調作為最後一個引數傳遞給非同步函式
- 回撥函式必須指向一個錯誤,後跟一個值引數。
例子:
// 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 程式碼可能不會更短,但有相當大的好處:
1、語法更清晰。括號更少,錯誤更少。
2、除錯更容易。可以在任何await語句上設定斷點。
3、錯誤處理更好。try / catch塊可以與同步程式碼一樣使用。
4、支援很好。它在所有瀏覽器(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...))
來源:https://segmentfault.com/a/1190000016143319