Async:簡潔優雅的非同步之道
前言
在非同步處理方案中,目前最為簡潔優雅的便是async
函式(以下簡稱A函式)。經過必要的分塊包裝後,A函式能使多個相關的非同步操作如同同步操作一樣聚合起來,使其相互間的關係更為清晰、過程更為簡潔、除錯更為方便。它本質是Generator
函式的語法糖,通俗的說法是使用G函式進行非同步處理的增強版。
嘗試
學習A函式必須有Promise
基礎,最好還了解Generator
函式,有需要的可檢視延伸小節。
為了直觀的感受A函式的魅力,下面使用Promise
和A函式進行了相同的非同步操作。該非同步的目的是獲取使用者的留言列表,需要分頁,分頁由後臺控制。具體的操作是:先獲取到留言的總條數,再更正當前需要顯示的頁數(每次切換到不同頁時,總數目可能會發生變化),最後傳遞引數並獲取到相應的資料。
let totalNum = 0; // Total comments number. let curPage = 1; // Current page index. let pageSize = 10; // The number of comment displayed in one page. // 使用A函式的主程式碼。 async function dealWithAsync() { totalNum = await getListCount(); console.log('Get count', totalNum); if (pageSize * (curPage - 1) > totalNum) { curPage = 1; } return getListData(); } // 使用Promise的主程式碼。 function dealWithPromise() { return new Promise((resolve, reject) => { getListCount().then(res => { totalNum = res; console.log('Get count', res); if (pageSize * (curPage - 1) > totalNum) { curPage = 1; } return getListData() }).then(resolve).catch(reject); }); } // 開始執行dealWithAsync函式。 // dealWithAsync().then(res => { // console.log('Get Data', res) // }).catch(err => { // console.log(err); // }); // 開始執行dealWithPromise函式。 // dealWithPromise().then(res => { // console.log('Get Data', res) // }).catch(err => { // console.log(err); // }); function getListCount() { return createPromise(100).catch(() => { throw 'Get list count error'; }); } function getListData() { return createPromise([], { curPage: curPage, pageSize: pageSize, }).catch(() => { throw 'Get list data error'; }); } function createPromise( data, // Reback data params = null, // Request params isSucceed = true, timeout = 1000, ) { return new Promise((resolve, reject) => { setTimeout(() => { isSucceed ? resolve(data) : reject(data); }, timeout); }); }
對比dealWithAsync
和dealWithPromise
兩個簡單的函式,能直觀的發現:使用A函式,除了有await
關鍵字外,與同步程式碼無異。而使用Promise
則需要根據規則增加很多包裹性的鏈式操作,產生了太多回調函式,不夠簡約。另外,這裡分開了每個非同步操作,並規定好各自成功或失敗時傳遞出來的資料,近乎實際開發。
1 登堂
1.1 形式
A函式也是函式,所以具有普通函式該有的性質。不過形式上有兩點不同:一是定義A函式時,function
關鍵字前需要有async
關鍵字(意為非同步),表示這是個A函式。二是在A函式內部可以使用await
關鍵字(意為等待),表示會將其後面跟隨的結果當成非同步操作並等待其完成。
以下是它的幾種定義方式。
// 宣告式
async function A() {}
// 表示式
let A = async function () {};
// 作為物件屬性
let o = {
A: async function () {}
};
// 作為物件屬性的簡寫式
let o = {
async A() {}
};
// 箭頭函式
let o = {
A: async () => {}
};
1.2 返回值
執行A函式,會固定的返回一個Promise
物件。
得到該物件後便可監設定成功或失敗時的回撥函式進行監聽。如果函式執行順利並結束,返回的P物件的狀態會從等待轉變成成功,並輸出return
命令的返回結果(沒有則為undefined
)。如果函式執行途中失敗,JS會認為A函式已經完成執行,返回的P物件的狀態會從等待轉變成失敗,並輸出錯誤資訊。
// 成功執行案例
A1().then(res => {
console.log('執行成功', res); // 10
});
async function A1() {
let n = 1 * 10;
return n;
}
// 失敗執行案例
A2().catch(err => {
console.log('執行失敗', err); // i is not defined.
});
async function A2() {
let n = 1 * i;
return n;
}
1.3 await
只有在A函式內部才可以使用await
命令,存在於A函式內部的普通函式也不行。
引擎會統一將await
後面的跟隨值視為一個Promise
,對於不是Promise
物件的值會呼叫Promise.resolve()
進行轉化。即便此值為一個Error
例項,經過轉化後,引擎依然視其為一個成功的Promise
,其資料為Error
的例項。
當函式執行到await
命令時,會暫停執行並等待其後的Promise
結束。如果該P物件最終成功,則會返回成功的返回值,相當將await xxx
替換成返回值
。如果該P物件最終失敗,且錯誤沒有被捕獲,引擎會直接停止執行A函式並將其返回物件的狀態更改為失敗,輸出錯誤資訊。
最後,A函式中的return x
表示式,相當於return await x
的簡寫。
// 成功執行案例
A1().then(res => {
console.log('執行成功', res); // 約兩秒後輸出100。
});
async function A1() {
let n1 = await 10;
let n2 = await new Promise(resolve => {
setTimeout(() => {
resolve(10);
}, 2000);
});
return n1 * n2;
}
// 失敗執行案例
A2().catch(err => {
console.log('執行失敗', err); // 約兩秒後輸出10。
});
async function A2() {
let n1 = await 10;
let n2 = await new Promise((resolve, reject) => {
setTimeout(() => {
reject(10);
}, 2000);
});
return n1 * n2;
}
2 入室
2.1 繼發與併發
對於存在於JS語句(for
, while
等)的await
命令,引擎遇到時也會暫停執行。這意味著可以直接使用迴圈語句處理多個非同步。
以下是處理繼發的兩個例子。A函式處理相繼發生的非同步尤為簡潔,整體上與同步程式碼無異。
// 兩個方法A1和A2的行為結果相同,都是每隔一秒輸出10,輸出三次。
async function A1() {
let n1 = await createPromise();
console.log('N1', n1);
let n2 = await createPromise();
console.log('N2', n2);
let n3 = await createPromise();
console.log('N3', n3);
}
async function A2() {
for (let i = 0; i< 3; i++) {
let n = await createPromise();
console.log('N' + (i + 1), n);
}
}
function createPromise() {
return new Promise(resolve => {
setTimeout(() => {
resolve(10);
}, 1000);
});
}
接下來是處理併發的三個例子。A1函式使用了Promise.all
生成一個聚合非同步,雖然簡單但靈活性降低了,只有都成功和失敗兩種情況。A3函式相對A2僅僅為了說明應該怎樣配合陣列的遍歷方法使用async
函式。重點在A2函式的理解上。
A2函式使用了迴圈語句,實際是繼發的獲取到各個非同步值,但在總體的時間上相當併發(這裡需要好好理解一番)。因為一開始建立reqs
陣列時,就已經開始執行了各個非同步,之後雖然是逐一繼發獲取,但總花費時間與遍歷順序無關,恆等於耗時最多的非同步所花費的時間(不考慮遍歷、執行等其它的時間消耗)。
// 三個方法A1, A2和A3的行為結果相同,都是在約一秒後輸出[10, 10, 10]。
async function A1() {
let res = await Promise.all([createPromise(), createPromise(), createPromise()]);
console.log('Data', res);
}
async function A2() {
let res = [];
let reqs = [createPromise(), createPromise(), createPromise()];
for (let i = 0; i< reqs.length; i++) {
res[i] = await reqs[i];
}
console.log('Data', res);
}
async function A3() {
let res = [];
let reqs = [9, 9, 9].map(async (item) => {
let n = await createPromise(item);
return n + 1;
});
for (let i = 0; i< reqs.length; i++) {
res[i] = await reqs[i];
}
console.log('Data', res);
}
function createPromise(n = 10) {
return new Promise(resolve => {
setTimeout(() => {
resolve(n);
}, 1000);
});
}
2.2 錯誤處理
一旦await
後面的Promise
轉變成rejected
,整個async
函式便會終止。然而很多時候我們不希望因為某個非同步操作的失敗,就終止整個函式,因此需要進行合理錯誤處理。注意,這裡所說的錯誤不包括引擎解析或執行的錯誤,僅僅是狀態變為rejected
的Promise
物件。
處理的方式有兩種:一是先行包裝Promise
物件,使其始終返回一個成功的Promise
。二是使用try.catch
捕獲錯誤。
// A1和A2都執行成,且返回值為10。
A1().then(console.log);
A2().then(console.log);
async function A1() {
let n;
n = await createPromise(true);
return n;
}
async function A2() {
let n;
try {
n = await createPromise(false);
} catch (e) {
n = e;
}
return n;
}
function createPromise(needCatch) {
let p = new Promise((resolve, reject) => {
reject(10);
});
return needCatch ? p.catch(err => err) : p;
}
2.3 實現原理
前言中已經提及,A函式是使用G函式進行非同步處理的增強版。既然如此,我們就從其改進的方面入手,來看看其基於G函式的實現原理。A函式相對G函式的改進體現在這幾個方面:更好的語義,內建執行器和返回值是Promise
。
更好的語義。G函式通過在function
後使用*
來標識此為G函式,而A函式則是在function
前加上async
關鍵字。在G函式中可以使用yield
命令暫停執行和交出執行權,而A函式是使用await
來等待非同步返回結果。很明顯,async
和await
更為語義化。
// G函式
function* request() {
let n = yield createPromise();
}
// A函式
async function request() {
let n = await createPromise();
}
function createPromise() {
return new Promise(resolve => {
setTimeout(() => {
resolve(10);
}, 1000);
});
}
內建執行器。呼叫A函式便會一步步自動執行和等待非同步操作,直到結束。如果需要使用G函式來自動執行非同步操作,需要為其建立一個自執行器。通過自執行器來自動化G函式的執行,其行為與A函式基本相同。可以說,A函式相對G函式最大改進便是內建了自執行器。
// 兩者都是每隔一秒鐘打印出10,重複兩次。
// A函式
A();
async function A() {
let n1 = await createPromise();
console.log(n1);
let n2 = await createPromise();
console.log(n2);
}
// G函式,使用自執行器執行。
spawn(G);
function* G() {
let n1 = yield createPromise();
console.log(n1);
let n2 = yield createPromise();
console.log(n2);
}
function spawn(genF) {
return new Promise(function(resolve, reject) {
const gen = genF();
function step(nextF) {
let next;
try {
next = nextF();
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
});
}
function createPromise() {
return new Promise(resolve => {
setTimeout(() => {
resolve(10);
}, 1000);
});
}
2.4 執行順序
在瞭解A函式內部與包含它外部間的執行順序前,需要明白兩點:一為Promise
的例項方法是推遲到本輪事件末尾才執行的後執行操作,詳情請檢視連結。二為Generator
函式是通過呼叫例項方法來切換執行權進而控制程式執行順序,詳情請檢視連結。理解好A函式的執行順序,能更加清楚的把握此三者的存在。
先看以下程式碼,對比A1、A2和A3方法的結果。
F(A1); // 接連打印出:1 3 4 2 5。
F(A2); // 接連打印出:1 3 2 4 5。
F(A3); // 先打印出:1 3 2,隔兩秒後打印出:4 9。
function F(A) {
console.log(1);
A().then(console.log);
console.log(2);
}
async function A1() {
console.log(3);
console.log(4);
return 5;
}
async function A2() {
console.log(3);
let n = await 5;
console.log(4);
return n;
}
async function A3() {
console.log(3);
let n = await createPromise();
console.log(4);
return n;
}
function createPromise() {
return new Promise(resolve => {
setTimeout(() => {
resolve(9);
}, 2000);
});
}
從結果上可歸納出一些表面形態。執行A函式,會即刻執行其函式體,直到遇到await
命令。遇到await
命令後,執行權會轉向A函式外部,即不管A函式內部執行而開始執行外部程式碼。執行完外部程式碼(本輪事件)後,才繼續執行之前await
命令後面的程式碼。
歸納到此已成功一半,之後著手分析其成因。如果客官您對本樓有所瞭解,那一定不會忘記‘自執行器’這位大嬸吧?估計是忘記了。A函式的本質就是帶有自執行器的G函式,所以探究A函式的執行原理就是探究使用自執行器的G函式的執行原理。想起了?
再看下面程式碼,使用相同邏輯的G函式會得到與A函式相同的結果。
F(A); // 先打印出:1 3 2,隔兩秒後打印出:4 9。
F(() => {
return spawn(G);
}); // 先打印出:1 3 2,隔兩秒後打印出:4 9。
function F(A) {
console.log(1);
A().then(console.log);
console.log(2);
}
async function A() {
console.log(3);
let n = await createPromise();
console.log(4);
return n;
}
function* G() {
console.log(3);
let n = yield createPromise();
console.log(4);
return n;
}
function createPromise() {
return new Promise(resolve => {
setTimeout(() => {
resolve(9);
}, 2000);
});
}
function spawn(genF) {
return new Promise(function(resolve, reject) {
const gen = genF();
function step(nextF) {
let next;
try {
next = nextF();
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
});
}
自動執行G函式時,遇到yield
命令後會使用Promise.resolve
包裹其後的表示式,併為其設定回撥函式。無論該Promise
是立刻有了結果還是過某段時間之後,其回撥函式都會被推遲到在本輪事件末尾執行。之後再是下一步,再下一步。同樣的道理適用於A函式,當遇到await
命令時(此處略去三五字),所以有了如此這般的執行順序。謝幕。