ES6學習15(非同步操作和Async函式)
非同步程式設計對JavaScript語言太重要。Javascript語言的執行環境是“單執行緒”的,如果沒有非同步程式設計,根本沒法用,非卡死不可。
ES6誕生以前,非同步程式設計的方法,大概有下面四種。
- 回撥函式
- 事件監聽
- 釋出/訂閱
- Promise 物件
ES6將JavaScript非同步程式設計帶入了一個全新的階段,ES7的Async函式更是提出了非同步程式設計的終極解決方案。
基本概念
所謂”非同步”,簡單說就是一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。
比如,有一個任務是讀取檔案進行處理,任務的第一段是向作業系統發出請求,要求讀取檔案。然後,程式執行其他任務,等到作業系統返回檔案,再接著執行任務的第二段(處理檔案)。這種不連續的執行,就叫做非同步。
回撥函式
讀取檔案進行處理,是這樣寫的:
fs.readFile('/etc/passwd', function (err, data) {
if (err) throw err;
console.log(data);
});
為什麼Node.js約定,回撥函式的第一個引數,必須是錯誤物件err(如果沒有錯誤,該引數就是null)?原因是執行分成兩段,在這兩段之間丟擲的錯誤,程式無法捕捉,只能當作引數,傳入第二段。
Promise
回撥函式本身並沒有問題,它的問題出現在多個回撥函式巢狀。這個是個老問題了。
Promise就是為了解決這個問題而提出的。它不是新的語法功能,而是一種新的寫法,允許將回調函式的巢狀,改成鏈式呼叫。採用Promise,連續讀取多個檔案,寫法如下。
var readFile = require('fs-readfile-promise');
readFile(fileA)
.then(function(data){
console.log(data.toString());
})
.then(function(){
return readFile(fileB);
})
.then(function(data){
console.log(data.toString());
})
.catch(function(err) {
console.log(err);
});
Promise 的寫法只是回撥函式的改進,使用then方法以後,非同步任務的兩段執行看得更清楚了,除此以外,並無新意。
Promise 的最大問題是程式碼冗餘,原來的任務被Promise 包裝了一下,不管什麼操作,一眼看去都是一堆 then,原來的語義變得很不清楚。
Generator
協程
協程有點像函式,又有點像執行緒。它的執行流程大致如下。
- 第一步,協程A開始執行
- 第二步,協程A執行到一半,進入暫停,執行權轉移到協程B
- 第三步,(一段時間後)協程B交還執行權
- 第四步,協程A恢復執行。
Generator就是ES6中對協程的實現,比如讀取檔案:
function *asyncJob() {
// ...其他程式碼
//首次呼叫next執行到這裡,開始執行readFile函式,這個函式暫停執行
//在readFile中,完成讀取後再呼叫這個Generator的next並將返回的結果傳入
//這個結果就會作為yield的語句的返回值賦給f,然後繼續執行到下一個yield
var f = yield readFile(fileA);
// ...其他程式碼
}
非同步任務的封裝
var fetch = require('node-fetch');
function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}
var g = gen();
var result = g.next();
result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});
Thunk函式
在JavaScript語言中,Thunk函式替換多引數函式,將其替換成單引數的版本,且只接受回撥函式作為引數。
任何函式,只要引數有回撥函式,就能寫成Thunk函式的形式。下面是一個簡單的Thunk函式轉換器。
var fs = require('fs');
var Thunk = function(fn) {
return function (...args) {
return function (callback) {
return fn.call(this, ...args, callback);
}
};
};
var readFileThunk = Thunk(fs.readFile);
readFileThunk("./import.js")(function(err,data){
console.log(data);
});
有一個Thunkify模組,考慮的更加周到,基本思想是一樣的:
function thunkify(fn){
return function(){
var args = new Array(arguments.length);
var ctx = this;
for(var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}
return function(done){
var called;
args.push(function(){
//這裡利用閉包儲存了一個called變數,來標誌一個回撥函式是否已經被執行過
if (called) return;
called = true;
done.apply(null, arguments);
});
try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};
var read = thunkify(fs.readFile);
read('./import.js')(function(err, str){
console.log(str);
});
這個called變數可以保證callback只執行一次,如果在非同步過程中多次呼叫callback也只執行一次:
function f(a, b, callback){
var sum = a + b;
callback(sum);
callback(sum);
}
var ft = thunkify(f);
var print = console.log.bind(console);
ft(1, 2)(print);
// 3
Generator 函式的流程管理
你可能會問, Thunk函式有什麼用?回答是以前確實沒什麼用,但是ES6有了Generator函式,Thunk函式現在可以用於Generator函式的自動流程管理。
假設我們要依次讀取兩個檔案,我們的Generator可以這樣寫:
var fs = require('fs');
var readFile = thunkify(fs.readFile);
var gen = function* (){
var r1 = yield readFile('./import.js');
console.log(r1.toString());
var r2 = yield readFile('./import.js');
console.log(r2.toString());
};
手動執行時:
var g = gen();
var r1 = g.next();
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
if (err) throw err;
g.next(data);
});
});
Thunk函式的自動流程管理
Generator函式的執行過程,其實是將同一個回撥函式,反覆傳入next方法的value屬性。這使得我們可以用遞迴來自動完成這個過程。
function run(fn) {
//執行傳入的Generator,獲得遍歷器物件
var gen = fn();
//要傳入Trunk函式的回撥函式
//在這裡就相當於是readFile('./import.js')(next)
//首先要手動執行一下next(),此時呼叫gen.next(data)時無論data是什麼值Generator都會忽略的
//這時result/value獲得到的就是一個readFile('./import.js'),也就是一個trunk函式
//再把next傳進去,就可以開始遞迴執行了
function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}
next();
}
run(gen);
基於Promise的自動流程管理
var fs = require('fs');
//先把一個非同步操作封裝為一個Promise物件
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* (){
//每次執行next返回的就是一個Promise物件
var f1 = yield readFile('./import.js');
console.log(f1.toString());
var f2 = yield readFile('./import.js');
console.log(f2.toString());
};
手動執行:
var g = gen();
g.next().value.then(function(data){
g.next(data).value.then(function(data){
g.next(data);
});
});
手動執行其實就是用then方法,層層添加回調函式。理解了這一點,就可以寫出一個自動執行器,基本原理和Trunk函式的相同:
var run = function(gen) {
var g = gen();
function next(data){
var result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data){
next(data);
});
}
next();
}
run(gen);
co模組
co模組是著名程式設計師TJ Holowaychuk於2013年6月釋出的一個小工具,用於Generator函式的自動執行。
對於一個Generator,不需要再編寫Generator的執行器:
co(gen);
co函式返回一個Promise物件,因此可以用then方法添加回調函式。
co(gen).then(function (){
console.log('Generator 函式執行完成');
});
使用co的前提條件是,Generator函式的yield命令後面,只能是Thunk函式或Promise物件。
co支援併發的非同步操作,即允許某些操作同時進行,等到它們全部完成,才進行下一步。
這時,要把併發的操作都放在陣列或物件裡面,跟在yield語句後面。
// 陣列的寫法
co(function* () {
var res = yield [
Promise.resolve(1),
Promise.resolve(2)
];
console.log(res);
}).catch(onerror);
async函式
ES7提供了async函式,使得非同步操作變得更加方便。async函式是什麼?一句話,async函式就是Generator函式的語法糖。
定義:
var asyncReadFile = async function (){
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
執行:
var result = asyncReadFile();
async函式對 Generator 函式的改進,體現在以下四點。
- 內建執行器。Generator函式的執行必須靠執行器,所以才有了co模組,而async函式自帶執行器。也就是說,async函式的執行,與普通函式一模一樣,只要一行,然後它就會自動執行,輸出最後結果。這完全不像Generator函式,需要呼叫next方法,或者用co模組,才能得到真正執行,得到最後結果。
- 更好的語義。async和await,比起星號和yield,語義更清楚了。async表示函式裡有非同步操作,await表示緊跟在後面的表示式需要等待結果。
- 更廣的適用性。 co模組約定,yield命令後面只能是Thunk函式或Promise物件,而async函式的await命令後面,可以是Promise物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。
- 返回值是Promise。async函式的返回值是Promise物件,這比Generator函式的返回值是Iterator物件方便多了。你可以用then方法指定下一步的操作。
語法
async函式返回一個Promise物件。
async函式內部return語句返回的值,會成為then方法回撥函式的引數。
async函式內部丟擲錯誤,會導致返回的Promise物件變為reject狀態。丟擲的錯誤物件會被catch方法回撥函式接收到。
async function f() {
throw new Error('出錯了');
return 'hello world';
}
f().then(
v => console.log(v),
e => console.log(e)
)
// Error: 出錯了
async函式返回的Promise物件,必須等到內部所有await命令的Promise物件執行完,才會發生狀態改變。只有async函式內部的非同步操作執行完,才會執行then方法指定的回撥函式。
正常情況下,await命令後面是一個Promise物件。如果不是,會被轉成一個立即resolve的Promise物件。await命令後面的Promise物件如果變為reject狀態,則reject的引數會被catch方法的回撥函式接收到。
只要一個await語句後面的Promise變為reject,那麼整個async函式都會中斷執行。
如果await後面的非同步操作出錯,那麼等同於async函式返回的Promise物件被reject。
async 函式的用法
async函式返回一個Promise物件,可以使用then方法添加回調函式。當函式執行的時候,一旦遇到await就會先返回,等到觸發的非同步操作完成,再接著執行函式體內後面的語句。
function timeout(data, ms) {
return new Promise((resolve) => {
setTimeout(function(){
resolve(data);
}, ms);
});
}
async function asyncPrint(value, ms) {
//timeout會返回一個promise物件
//await會等待這個物件中的resolve方法執行
//並用其引數當做自己的返回值
//值得注意的是await命令後面的Promise物件
//執行結果可能是rejected
//所以最好把await命令放在try...catch程式碼塊中
//或者使用catch方法
var a = await timeout(value,ms)
.catch(function (err) {
console.log(err);
});
console.log('a:'+a);
return 'async over'
}
asyncPrint('hello world', 5000).then(v => console.log(v));
console.log('after async');
//after async
//a:hello world
//async over
注意事項
//多個await命令後面的非同步操作,如果不存在繼發關係,最好讓它們同時觸發
//這樣就不好,一個執行完再執行一個
//假設getFoo()和getBar()會返回Promise物件
let foo = await getFoo();
let bar = await getBar();
//可以這樣
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
//或者這樣
//由於在Promise生成時裡面的非同步程式碼立刻開始執行
//所以前兩行執行完兩個非同步操作就都已經開始了
//接下來在用await來等待這兩個非同步完成操作
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
非同步遍歷器
目前,有一個提案,為非同步操作提供原生的遍歷器介面,即value和done這兩個屬性都是非同步產生,這稱為”非同步遍歷器“(Async Iterator)。
非同步遍歷的介面
非同步遍歷器的最大的語法特點,就是呼叫遍歷器的next方法,返回的是一個Promise物件。
物件的非同步遍歷器介面,部署在Symbol.asyncIterator屬性上面。
const asyncIterable = createAsyncIterable(['a', 'b']);
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
asyncIterator.next()
.then(iterResult1 => {
console.log(iterResult1); // { value: 'a', done: false }
return asyncIterator.next();
}).then(iterResult2 => {
console.log(iterResult2); // { value: 'b', done: false }
return asyncIterator.next();
}).then(iterResult3 => {
console.log(iterResult3); // { value: undefined, done: true }
});
上面程式碼中,非同步遍歷器其實返回了兩次值。第一次呼叫的時候,返回一個Promise物件;等到Promise物件resolve了,再返回一個表示當前資料成員資訊的物件。這就是說,非同步遍歷器與同步遍歷器最終行為是一致的,只是會先返回Promise物件,作為中介。
由於非同步遍歷器的next方法,返回的是一個Promise物件。因此,可以把它放在await命令後面,await會等待這個Promise物件執行完,取它傳到resolve函式裡的值,就不用上面的then方法了。
async function f() {
const asyncIterable = createAsyncIterable(['a', 'b']);
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
console.log(await asyncIterator.next());
// { value: 'a', done: false }
console.log(await asyncIterator.next());
// { value: 'b', done: false }
console.log(await asyncIterator.next());
// { value: undefined, done: true }
}
注意,非同步遍歷器的next方法是可以連續呼叫的,不必等到上一步產生的Promise物件resolve以後再呼叫。這種情況下,next方法會累積起來,自動按照每一步的順序執行下去。下面是一個例子,把所有的next方法放在Promise.all方法裡面。
const asyncGenObj = createAsyncIterable(['a', 'b']);
const [{value: v1}, {value: v2}] = await Promise.all([
asyncGenObj.next(), asyncGenObj.next()
]);
console.log(v1, v2); // a b
for await…of
async function () {
try {
for await (const x of createAsyncIterable(['a', 'b'])) {
console.log(x);
}
} catch (e) {
console.error(e);
}
}
上面程式碼中,createAsyncIterable()返回一個非同步遍歷器,for…of迴圈自動呼叫這個遍歷器的next方法,會得到一個Promise物件。await用來處理這個Promise物件,一旦resolve,就把得到的值(x)傳入for…of的迴圈體。
如果next方法返回的Promise物件被reject,那麼就要用try…catch捕捉。
非同步Generator函式
非同步Generator函式的作用,是返回一個非同步遍歷器物件。
在語法上,非同步Generator函式就是async函式與Generator函式的結合。
async function* readLines(path) {
let file = await fileOpen(path);
try {
while (!file.EOF) {
yield await file.readLine();
}
} finally {
await file.close();
}
}
for await (const line of readLines(filePath)) {
console.log(line);
}
上面程式碼中,非同步操作前面使用await關鍵字標明,即await後面的操作,應該返回Promise物件。凡是使用yield關鍵字的地方,就是next方法的停下來的地方,它後面的表示式的值(即await file.readLine()的值),會作為next()返回物件的value屬性,這一點是於同步Generator函式一致的。
yield命令依然是立刻返回的,但是返回的是一個Promise物件。
async function* asyncGenerator() {
console.log('Start');
const result = await doSomethingAsync(); // (A)
yield 'Result: '+ result; // (B)
console.log('Done');
}
上面程式碼中,呼叫next方法以後,會在B處暫停執行,yield命令立刻返回一個Promise物件。這個Promise物件不同於A處await命令後面的那個Promise物件。主要有兩點不同,一是A處的Promise物件resolve以後產生的值,會放入result變數;二是B處的Promise物件resolve以後產生的值,是表示式’Result: ’ + result的值;三是A處的Promise物件一定先於B處的Promise物件resolve。
如果非同步Generator函式丟擲錯誤,會被Promise物件reject,然後丟擲的錯誤被catch方法捕獲。
async function* asyncGenerator() {
throw new Error('Problem!');
}
asyncGenerator()
.next()
.catch(err => console.log(err)); // Error: Problem!
注意,普通的async函式返回的是一個Promise物件,而非同步Generator函式返回的是一個非同步Iterator物件。async函式和非同步Generator函式,是封裝非同步操作的兩種方法,都用來達到同一種目的。區別在於,前者自帶執行器,後者通過for await…of執行,或者自己編寫執行器。
下面就是一個非同步Generator函式的執行器。
async function takeAsync(asyncIterable, count=Infinity) {
const result = [];
const iterator = asyncIterable[Symbol.asyncIterator]();
while (result.length < count) {
const {value,done} = await iterator.next();
if (done) break;
result.push(value);
}
return result;
}
yield* 語句
yield*語句也可以跟一個非同步遍歷器。
async function* gen1() {
yield 'a';
yield 'b';
return 2;
}
async function* gen2() {
const result = yield* gen1();
}
上面程式碼中,gen2函式裡面的result變數,最後的值是2。
與同步Generator函式一樣,for await…of迴圈會展開yield*。
(async function () {
for await (const x of gen2()) {
console.log(x);
}
})();
// a
// b