async 函數
含義
ES2017 標準引入了 async 函數,使得異步操作變得更加方便。
async 函數是什麼?一句話,它就是 Generator 函數的語法糖。
const gen = function* () {
const f1 = yield console.log('1');
const f2 = yield console.log('2');
};
// 上面代碼的函數`gen`可以寫成`async`函數,就是下面這樣。
const asyncReadFile = async function () {
const f1 = await console.log('1');
const f2 = await console.log('2');
};
一比較就會發現,async
函數就是將 Generator 函數的星號(*
)替換成async
,將yield
替換成await
,僅此而已。
async
函數對 Generator 函數的改進,體現在以下四點。
(1)內置執行器。
async
函數自帶執行器。也就是說,async
函數的執行,與普通函數一模一樣,只要一行。
asyncReadFile();
上面的代碼調用了asyncReadFile
函數,然後它就會自動執行,輸出最後結果。這完全不像 Generator 函數,需要調用next
方法,才能真正執行,得到最後結果。
(2)更好的語義。
async
和await
,比起星號和yield
,語義更清楚了。async
表示函數裏有異步操作,await
表示緊跟在後面的表達式需要等待結果。
(3)返回值是 Promise。
async
函數的返回值是 Promise 對象,這比 Generator 函數的返回值是 Iterator 對象方便多了。你可以用then
方法指定下一步的操作。
進一步說,async
函數完全可以看作多個異步操作,包裝成的一個 Promise 對象,而await
命令就是內部then
命令的語法糖。
基本用法
async
函數返回一個 Promise 對象,可以使用then
方法添加回調函數。當函數執行的時候,一旦遇到await
就會先返回,等到異步操作完成,再接着執行函數體內後面的語句。
下面是一個例子,指定多少毫秒後輸出一個值。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}
asyncPrint('hello world', 50);
上面代碼指定 50 毫秒以後,輸出hello world
。
由於async
函數返回的是 Promise 對象,可以作爲await
命令的參數。所以,上面的例子也可以寫成下面的形式。
async function timeout(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}
asyncPrint('hello world', 50);
async 函數有多種使用形式。
// 函數聲明
async function foo() {}
// 函數表達式
const foo = async function () {};
// 對象的方法
let obj = { async foo() {} };
obj.foo().then(...)
// Class 的方法
class Storage {
constructor() {
this.cachePromise = caches.open('avatars');
}
async getAvatar(name) {
const cache = await this.cachePromise;
return cache.match(`/avatars/${name}.jpg`);
}
}
const storage = new Storage();
storage.getAvatar('jake').then(…);
// 箭頭函數
const foo = async () => {};
語法
async
函數的語法規則總體上比較簡單,難點是錯誤處理機制。
返回 Promise 對象
async
函數返回一個 Promise 對象。
async
函數內部return
語句返回的值,會成爲then
方法回調函數的參數。
async function f() {
return 'hello world';
}
f().then(v => console.log(v))
// "hello world"
上面代碼中,函數f
內部return
命令返回的值,會被then
方法回調函數接收到。
async
函數內部拋出錯誤,會導致返回的 Promise 對象變爲reject
狀態。拋出的錯誤對象會被catch
方法回調函數接收到。
async function f() {
throw new Error('出錯了');
}
f().then(
v => console.log(v),
e => console.log(e)
)
// Error: 出錯了
Promise 對象的狀態變化
async
函數返回的 Promise 對象,必須等到內部所有await
命令後面的 Promise 對象執行完,纔會發生狀態改變,除非遇到return
語句或者拋出錯誤。也就是說,只有async
函數內部的異步操作執行完,纔會執行then
方法指定的回調函數。
await 命令
正常情況下,await
命令後面是一個 Promise 對象,返回該對象的結果。如果不是 Promise 對象,就直接返回對應的值。
async function f() {
// 等同於
// return 123;
return await 123;
}
f().then(v => console.log(v))
// 123
上面代碼中,await
命令的參數是數值123
,這時等同於return 123
。
另一種情況是,await
命令後面是一個thenable
對象(即定義then
方法的對象),那麼await
會將其等同於 Promise 對象。
JavaScript 一直沒有休眠的語法,但是藉助await
命令就可以讓程序停頓指定的時間。下面給出了一個簡化的sleep
實現。
function sleep(interval) {
return new Promise(resolve => {
setTimeout(resolve, interval);
})
}
// 用法
async function one2FiveInAsync() {
for(let i = 1; i <= 5; i++) {
console.log(i);
await sleep(1000);
}
}
one2FiveInAsync();
await
命令後面的 Promise 對象如果變爲reject
狀態,則reject
的參數會被catch
方法的回調函數接收到。
async function f() {
await Promise.reject('出錯了');
}
f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出錯了
注意,上面代碼中,await
語句前面沒有return
,但是reject
方法的參數依然傳入了catch
方法的回調函數。這裏如果在await
前面加上return
,效果是一樣的。
任何一個await
語句後面的 Promise 對象變爲reject
狀態,那麼整個async
函數都會中斷執行。
async function f() {
await Promise.reject('出錯了');
await Promise.resolve('hello world'); // 不會執行
}
上面代碼中,第二個await
語句是不會執行的,因爲第一個await
語句狀態變成了reject
。
有時,我們希望即使前一個異步操作失敗,也不要中斷後面的異步操作。這時可以將第一個await
放在try...catch
結構裏面,這樣不管這個異步操作是否成功,第二個await
都會執行。
async function f() {
try {
await Promise.reject('出錯了');
} catch(e) {
}
return await Promise.resolve('hello world');
}
f()
.then(v => console.log(v))
// hello world
另一種方法是await
後面的 Promise 對象再跟一個catch
方法,處理前面可能出現的錯誤。
async function f() {
await Promise.reject('出錯了')
.catch(e => console.log(e));
return await Promise.resolve('hello world');
}
f()
.then(v => console.log(v))
// 出錯了
// hello world
錯誤處理
如果await
後面的異步操作出錯,那麼等同於async
函數返回的 Promise 對象被reject
。
async function f() {
await new Promise(function (resolve, reject) {
throw new Error('出錯了');
});
}
f()
.then(v => console.log(v))
.catch(e => console.log(e))
// Error:出錯了
上面代碼中,async
函數f
執行後,await
後面的 Promise 對象會拋出一個錯誤對象,導致catch
方法的回調函數被調用,它的參數就是拋出的錯誤對象。
防止出錯的方法,也是將其放在try...catch
代碼塊之中。
async function f() {
try {
await new Promise(function (resolve, reject) {
throw new Error('出錯了');
});
} catch(e) {
}
return await('hello world');
}
如果有多個await
命令,可以統一放在try...catch
結構中。
async function main() {
try {
const val1 = await firstStep();
const val2 = await secondStep(val1);
const val3 = await thirdStep(val1, val2);
console.log('Final: ', val3);
}
catch (err) {
console.error(err);
}
}
下面的例子使用try...catch
結構,實現多次重複嘗試。
const superagent = require('superagent');
const NUM_RETRIES = 3;
async function test() {
let i;
for (i = 0; i < NUM_RETRIES; ++i) {
try {
await superagent.get('http://google.com/this-throws-an-error');
break;
} catch(err) {}
}
console.log(i); // 3
}
test();
上面代碼中,如果await
操作成功,就會使用break
語句退出循環;如果失敗,會被catch
語句捕捉,然後進入下一輪循環。
使用注意點
第一點,前面已經說過,await
命令後面的Promise
對象,運行結果可能是rejected
,所以最好把await
命令放在try...catch
代碼塊中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一種寫法
async function myFunction() {
await somethingThatReturnsAPromise()
.catch(function (err) {
console.log(err);
});
}
第二點,多個await
命令後面的異步操作,如果不存在繼發關係,最好讓它們同時觸發。
let foo = await getFoo();
let bar = await getBar();
上面代碼中,getFoo
和getBar
是兩個獨立的異步操作(即互不依賴),被寫成繼發關係。這樣比較耗時,因爲只有getFoo
完成以後,纔會執行getBar
,完全可以讓它們同時觸發。
// 寫法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
// 寫法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
上面兩種寫法,getFoo
和getBar
都是同時觸發,這樣就會縮短程序的執行時間。
第三點,await
命令只能用在async
函數之中,如果用在普通函數,就會報錯。
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 報錯
docs.forEach(function (doc) {
await db.post(doc);
});
}
上面代碼會報錯,因爲await
用在普通函數之中了。但是,如果將forEach
方法的參數改成async
函數,也有問題。
function dbFuc(db) { //這裏不需要 async
let docs = [{}, {}, {}];
// 可能得到錯誤結果
docs.forEach(async function (doc) {
await db.post(doc);
});
}
上面代碼可能不會正常工作,原因是這時三個db.post
操作將是併發執行,也就是同時執行,而不是繼發執行。正確的寫法是採用for
循環。
async function dbFuc(db) {
let docs = [{}, {}, {}];
for (let doc of docs) {
await db.post(doc);
}
}
如果確實希望多個請求併發執行,可以使用Promise.all
方法。當三個請求都會resolved
時,下面兩種寫法效果相同。
async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));
let results = await Promise.all(promises);
console.log(results);
}
// 或者使用下面的寫法
async function dbFuc(db) {
let docs = [{}, {}, {}];
let promises = docs.map((doc) => db.post(doc));
let results = [];
for (let promise of promises) {
results.push(await promise);
}
console.log(results);
}
與其他異步處理方法的比較
我們通過一個例子,來看 async 函數與 Promise、Generator 函數的比較。
假定某個 DOM 元素上面,部署了一系列的動畫,前一個動畫結束,才能開始後一個。如果當中有一個動畫出錯,就不再往下執行,返回上一個成功執行的動畫的返回值。
首先是 Promise 的寫法。
function chainAnimationsPromise(elem, animations) {
// 變量ret用來保存上一個動畫的返回值
let ret = null;
// 新建一個空的Promise
let p = Promise.resolve();
// 使用then方法,添加所有動畫
for(let anim of animations) {
p = p.then(function(val) {
ret = val;
return anim(elem);
});
}
// 返回一個部署了錯誤捕捉機制的Promise
return p.catch(function(e) {
/* 忽略錯誤,繼續執行 */
}).then(function() {
return ret;
});
}
雖然 Promise 的寫法比回調函數的寫法大大改進,但是一眼看上去,代碼完全都是 Promise 的 API(then
、catch
等等),操作本身的語義反而不容易看出來。
然後是 async 函數的寫法。
async function chainAnimationsAsync(elem, animations) {
let ret = null;
try {
for(let anim of animations) {
ret = await anim(elem);
}
} catch(e) {
/* 忽略錯誤,繼續執行 */
}
return ret;
}
可以看到 Async 函數的實現最簡潔,最符合語義,幾乎沒有語義不相關的代碼。它將 Generator 寫法中的自動執行器,改在語言層面提供,不暴露給用戶,因此代碼量最少。如果使用 Generator 寫法,自動執行器需要用戶自己提供。