[轉] ES6中的async函式
一、概述
async 函式是 Generator 函式的語法糖
使用Generator 函式,依次讀取兩個檔案程式碼如下
var fs = require('fs'); var readFile = function (fileName) { return new Promise(function (resolve, reject) { fs.readFile(fileName, function(error, data) { if (error) return reject(error); resolve(data); }); }); }; var gen = function* () { var f1 = yield readFile('/etc/fstab'); var f2 = yield readFile('/etc/shells'); console.log(f1.toString()); console.log(f2.toString()); };
寫成async
函式,就是下面這樣
var asyncReadFile = async function () { var f1 = await readFile('/etc/fstab'); var f2 = await readFile('/etc/shells'); console.log(f1.toString()); console.log(f2.toString()); };
async
函式就是將 Generator 函式的星號(*
)替換成async
,將yield
替換成await
,僅此而已
async
函式對 Generator 函式的改進,體現在以下四點
1、內建執行器
Generator 函式的執行必須靠執行器,所以才有了co
模組,而async
函式自帶執行器。也就是說,async
函式的執行,與普通函式一模一樣,只要一行
var result = asyncReadFile();
上面的程式碼呼叫了asyncReadFile
函式,然後它就會自動執行,輸出最後結果。這完全不像 Generator 函式,需要呼叫next
方法,或者用co
模組,才能真正執行,得到最後結果
2、更好的語義
async
和await
,比起星號和yield
,語義更清楚了。async
表示函式裡有非同步操作,await
表示緊跟在後面的表示式需要等待結果
3、更廣的適用性
co
模組約定,yield
命令後面只能是 Thunk 函式或 Promise 物件,而async
函式的await
命令後面,可以是Promise 物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)
4、返回值是 Promise
async
函式的返回值是 Promise 物件,這比 Generator 函式的返回值是 Iterator 物件方便多了。可以用then
方法指定下一步的操作。
進一步說,async
函式完全可以看作多個非同步操作,包裝成的一個 Promise 物件,而await
命令就是內部then
命令的語法糖
二、基本用法
async
函式返回一個 Promise 物件,可以使用then
方法添加回調函式。當函式執行的時候,一旦遇到await
就會先返回,等到非同步操作完成,再接著執行函式體內後面的語句
async function getStockPriceByName(name) { var symbol = await getStockSymbol(name); var stockPrice = await getStockPrice(symbol); return stockPrice; } getStockPriceByName('goog').then(function (result) { console.log(result); });
上面程式碼是一個獲取股票報價的函式,函式前面的async
關鍵字,表明該函式內部有非同步操作。呼叫該函式時,會立即返回一個Promise
物件
下面是另一個例子,指定多少毫秒後輸出一個值
function timeout(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function asyncPrint(value, ms) { await timeout(ms); console.log(value); } asyncPrint('hello world', 2000);
上面程式碼指定2秒以後,輸出hello world
。也就是說asyncPrint執行時候遇到await就先返回出來一個promise,等await的函式timeout執行完成之後,也就是2s後,再接著執行console.log(value),列印'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 () => {};
三、語法
1、返回 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: 出錯了
2、Promise 物件的狀態變化
async
函式返回的 Promise 物件,必須等到內部所有await
命令後面的 Promise 物件執行完,才會發生狀態改變,除非遇到return
語句或者丟擲錯誤。也就是說,只有async
函式內部的非同步操作執行完,才會執行then
方法指定的回撥函式
async function getTitle(url) { let response = await fetch(url); let html = await response.text(); return html.match(/<title>([\s\S]+)<\/title>/i)[1]; } getTitle('https://tc39.github.io/ecma262/').then(console.log)
上面程式碼中,函式getTitle
內部有三個操作:抓取網頁、取出文字、匹配頁面標題。只有這三個操作全部完成,才會執行then
方法裡面的console.log
3、await
命令
正常情況下,await
命令後面是一個 Promise 物件。如果不是,會被轉成一個立即resolve
的 Promise 物件
async function f() { return await 123; } f().then(v => console.log(v)) // 123
上面程式碼中,await
命令的引數是數值123
,它被轉成 Promise 物件,並立即resolve
。
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
4、錯誤處理
如果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 { var val1 = await firstStep(); var val2 = await secondStep(val1); var 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語句捕捉,然後進入下一輪迴圈
5、注意事項
(1)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); }); }
(2)多個await
命令後面的非同步操作,如果不存在繼發關係,最好讓它們同時觸發
let foo = await getFoo(); let bar = await getBar();
上面程式碼中,getFoo
和getBar
是兩個獨立的非同步操作(即互不依賴),被寫成繼發關係。這樣比較耗時,因為只有getFoo
完成以後,才會執行getBar
,完全可以讓它們同時觸發(注意:獨立的非同步操作讓同時觸發,使用Promise.all())縮短程式的執行時間
// 寫法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]); // 寫法二 let fooPromise = getFoo(); let barPromise = getBar(); let foo = await fooPromise; let bar = await barPromise;
上面兩種寫法,getFoo
和getBar
都是同時觸發,這樣就會縮短程式的執行時間
(3)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 函式的實現原理,就是將 Generator 函式和自動執行器,包裝在一個函式裡
async function fn(args) { // ... } // 等同於 function fn(args) { return spawn(function* () { // ... }); }
所有的async
函式都可以寫成上面的第二種形式,其中的spawn
函式就是自動執行器。
下面給出spawn
函式的實現,基本就是前文自動執行器的翻版
function spawn(genF) { return new Promise(function(resolve, reject) { var gen = genF(); function step(nextF) { try { var 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); }); }); }
五、非同步比較
通過一個例子,來看 async 函式與 Promise、Generator 函式的比較。
假定某個 DOM 元素上面,部署了一系列的動畫,前一個動畫結束,才能開始後一個。如果當中有一個動畫出錯,就不再往下執行,返回上一個成功執行的動畫的返回值
1、Promise
首先是 Promise 的寫法
function chainAnimationsPromise(elem, animations) { // 變數ret用來儲存上一個動畫的返回值 var ret = null; // 新建一個空的Promise var p = Promise.resolve(); // 使用then方法,新增所有動畫 for(var 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
等等),操作本身的語義反而不容易看出來
2、Generator
接著是 Generator 函式的寫法
function chainAnimationsGenerator(elem, animations) { return spawn(function*() { var ret = null; try { for(var anim of animations) { ret = yield anim(elem); } } catch(e) { /* 忽略錯誤,繼續執行 */ } return ret; }); }
上面程式碼使用 Generator 函式遍歷了每個動畫,語義比 Promise 寫法更清晰,使用者定義的操作全部都出現在spawn
函式的內部。這個寫法的問題在於,必須有一個任務執行器,自動執行 Generator 函式,上面程式碼的spawn
函式就是自動執行器,它返回一個 Promise 物件,而且必須保證yield
語句後面的表示式,必須返回一個 Promise
3、async
最後是 async 函式的寫法
async function chainAnimationsAsync(elem, animations) { var ret = null; try { for(var anim of animations) { ret = await anim(elem); } } catch(e) { /* 忽略錯誤,繼續執行 */ } return ret; }
可以看到Async函式的實現最簡潔,最符合語義,幾乎沒有語義不相關的程式碼。它將Generator寫法中的自動執行器,改在語言層面提供,不暴露給使用者,因此程式碼量最少。如果使用Generator寫法,自動執行器需要使用者自己提供
六、例項
實際開發中,經常遇到一組非同步操作,需要按照順序完成。比如,依次遠端讀取一組 URL,然後按照讀取的順序輸出結果。
Promise 的寫法如下
function logInOrder(urls) { // 遠端讀取所有URL const textPromises = urls.map(url => { return fetch(url).then(response => response.text()); }); // 按次序輸出 textPromises.reduce((chain, textPromise) => { return chain.then(() => textPromise) .then(text => console.log(text)); }, Promise.resolve()); }
上面程式碼使用fetch
方法,同時遠端讀取一組 URL。每個fetch
操作都返回一個 Promise 物件,放入textPromises
陣列。然後,reduce
方法依次處理每個 Promise 物件,然後使用then
,將所有 Promise 物件連起來,因此就可以依次輸出結果。
上面這種寫法不太直觀,可讀性比較差。下面是 async 函式實現
async function logInOrder(urls) { for (const url of urls) { const response = await fetch(url); console.log(await response.text()); } }
上面程式碼確實大大簡化,問題是所有遠端操作都是繼發。只有前一個URL返回結果,才會去讀取下一個URL,這樣做效率很差,非常浪費時間。我們需要的是併發發出遠端請求
async function logInOrder(urls) { // 併發讀取遠端URL const textPromises = urls.map(async url => { const response = await fetch(url); return response.text(); }); // 按次序輸出 for (const textPromise of textPromises) { console.log(await textPromise); } }
上面程式碼中,雖然map
方法的引數是async
函式,但它是併發執行的,因為只有async
函式內部是繼發執行,外部不受影響。後面的for..of
迴圈內部使用了await
,因此實現了按順序輸出