JS非同步處理方案總結
程式碼最新內容請在github閱讀,也歡迎您star和issue
1.Generator與其他非同步處理方案
以前,非同步程式設計的方法,大概有下面四種。
1.1 回撥函式
JavaScript 語言對非同步程式設計的實現,就是回撥函式。所謂回撥函式,就是把任務的第二段單獨寫在一個函式裡面,等到重新執行這個任務的時候,就直接呼叫這個函式。它的英語名字 callback,直譯過來就是”重新呼叫”。
讀取檔案進行處理,是這樣寫的。
fs.readFile('/etc/passwd', function (err, data) {
if (err) throw err;
console.log(data);
});
上面程式碼中,readFile函式的第二個引數,就是回撥函式,也就是任務的第二段。等到作業系統返回了 /etc/passwd 這個檔案以後,回撥函式才會執行。
一個有趣的問題是,為什麼Node.js約定,回撥函式的第一個引數,必須是錯誤物件err(如果沒有錯誤,該引數就是 null)?原因是執行分成兩段,在這兩段之間丟擲的錯誤,程式無法捕捉,只能當作引數,傳入第二段。
1.2 事件監聽
在DOM監聽中比較常見。
1.3 釋出/訂閱
也就是常說的觀察者模式
1.4 Promise 物件
回撥函式本身並沒有問題,它的問題出現在多個回撥函式巢狀。假定讀取A檔案之後,再讀取B檔案,程式碼如下。
fs.readFile(fileA, function (err, data) {
fs.readFile(fileB, function (err, data) {
// ...
});
});
不難想象,如果依次讀取多個檔案,就會出現多重巢狀。程式碼不是縱向發展,而是橫向發展,很快就會亂成一團,無法管理。這種情況就稱為”回撥函式噩夢”(callback hell)。
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 的最大問題是程式碼冗餘,原來的任務被Promise 包裝了一下,不管什麼操作,一眼看去都是一堆 then,原來的語義變得很不清楚。
1.5 Generator的方式
ECMAScript 6 (簡稱 ES6 )作為下一代 JavaScript 語言,將 JavaScript 非同步程式設計帶入了一個全新的階段。關於非同步程式設計可以檢視下圖:
而下面這種連續的執行過程叫做同步的。
Generator 函式是協程在 ES6 的實現,最大特點就是可以交出函式的執行權(即暫停執行)。Generator 函式可以暫停執行和恢復執行,這是它能封裝非同步任務的根本原因。除此之外,它還有兩個特性,使它可以作為非同步程式設計的完整解決方案:函式體內外的資料交換和錯誤處理機制。
next 方法返回值的 value 屬性,是 Generator 函式向外輸出資料;next 方法還可以接受引數,這是向 Generator 函式體內輸入資料。如下例:
特性1:暫停執行與恢復執行
function* gen(x){
var y = yield x + 2;
return y;
}
也就是通過yield來暫停執行,通過next來恢復執行
特性2:函式體內外的資料交換
function* gen(x){
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }
通過呼叫next方法獲取到的value代表函式體向外輸出的資料,而呼叫next方法傳入的引數本身代表向Generator傳入資料。
特性3:錯誤處理機制
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
g.next();
g.throw('出錯了');
// 出錯了
上面程式碼的最後一行,Generator 函式體外,使用指標物件的 throw 方法丟擲的錯誤,可以被函式體內的 try … catch 程式碼塊捕獲。這意味著,出錯的程式碼與處理錯誤的程式碼,實現了時間和空間上的分離,這對於非同步程式設計無疑是很重要的。
下面是Generator處理實際任務的一個例子:
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);
});
2.thunk函式
2.1 thunk函式基本概念
編譯器的”傳名呼叫”實現,往往是將引數放到一個臨時函式之中,再將這個臨時函式傳入函式體。這個臨時函式就叫做 Thunk 函式。
function f(m){
return m * 2;
}
f(x + 5);
// 等同於
var thunk = function () {
return x + 5;
};
function f(thunk){
return thunk() * 2;
}
上面程式碼中,函式 f 的引數 x + 5 被一個函式替換了。凡是用到原引數的地方,對 Thunk 函式求值即可。這就是 Thunk 函式的定義,它是”傳名呼叫”的一種實現策略,用來替換某個表示式。
2.2 javascript中的thunk函式
JavaScript 語言是傳值呼叫,它的 Thunk 函式含義有所不同。在 JavaScript 語言中,Thunk 函式替換的不是表示式,而是多引數函式,將其替換成單引數的版本,且只接受回撥函式作為引數。
thunk函式的實現機制還是通過閉包來完成的。其呼叫分為三步,首先是傳入一個函式,接著是傳入該函式的所有除了callback以外的引數,最後是傳入回撥函式callback
function thunkify(fn){
//第一步:傳入函式
return function(){
//第二步:傳入除了callback以外的引數
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(){
if (called) return;
//回撥函式只會執行一次
called = true;
done.apply(null, arguments);
});
try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};
3.thunk與Generator強強聯手將程式執行權交還給Generator函式
3.1 Generator的yield返回的必須是thunkify的函式才能遞迴
你可能會問, Thunk 函式有什麼用?回答是以前確實沒什麼用,但是 ES6 有了 Generator 函式,Thunk 函式現在可以用於 Generator 函式的自動流程管理。
以讀取檔案為例。下面的 Generator 函式封裝了兩個非同步操作。
var fs = require('fs');
var thunkify = require('thunkify');
var readFile = thunkify(fs.readFile);
var gen = function* (){
var r1 = yield readFile('/etc/fstab');
//1.交出執行權
console.log(r1.toString());
var r2 = yield readFile('/etc/shells');
//1.交出執行權
console.log(r2.toString());
};
上面程式碼中,yield 命令用於將程式的執行權移出 Generator 函式,那麼就需要一種方法,將執行權再交還給 Generator 函式。這種方法就是 Thunk 函式,因為它可以在回撥函式裡,將執行權交還給 Generator 函式。為了便於理解,我們先看如何手動執行上面這個 Generator 函式。
var g = gen();
var r1 = g.next();
//2.檢視這裡的程式你可以清楚的看到,這裡是將同一個回撥函式反覆的傳入到g.next返回的value中。但是這個返回的value必須是thunkify過後的函式,這樣它只會接受一個引數,那麼就滿足這裡的定義了
r1.value(function(err, data){
if (err) throw err;
var r2 = g.next(data);
r2.value(function(err, data){
//2.value必須是thunkify的函式才會只接受一個callback引數
if (err) throw err;
g.next(data);
});
});
上面程式碼中,變數 g 是 Generator 函式的內部指標,表示目前執行到哪一步。next 方法負責將指標移動到下一步,並返回該步的資訊(value 屬性和 done 屬性)。
仔細檢視上面的程式碼,可以發現 Generator 函式的執行過程,其實是將同一個回撥函式,反覆傳入 next 方法的 value 屬性。這使得我們可以用遞迴
來自動完成這個過程。
3.2 使用thunkify來自動執行Generator函式從而將執行權交還給Generator
function run(fn) {
var gen = fn();
//獲取到generator內部指標,這裡的next就是thunk函式的回撥函式
function next(err, data) {
var result = gen.next(data);
//獲取generator內部狀態
if (result.done) return;
result.value(next);
//Gnerator的value必須是thunkify函式,此時才會只接受一個回撥函式
}
next();
}
run(gen);
下面是一個讀取多個檔案的例子:
var gen = function* (){
var f1 = yield readFile('fileA');
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};
run(gen);
上面程式碼中,函式 gen 封裝了 n 個非同步的讀取檔案操作,只要執行 run 函式,這些操作就會自動完成。這樣一來,非同步操作不僅可以寫得像同步操作,而且一行程式碼就可以執行。
Thunk 函式並不是 Generator 函式自動執行的唯一方案。因為自動執行的關鍵是,必須有一種機制,自動控制 Generator 函式的流程,接收和交還程式的執行權。回撥函式可以做到這一點,Promise 物件也可以做到這一點。
4.co函式庫實現Generator函式自動執行
co 函式庫是著名程式設計師 TJ Holowaychuk 於2013年6月釋出的一個小工具,用於 Generator 函式的自動執行。
4.1 co函式庫自動執行Generator,但是yield後必須是promise或者thunk函式
比如,有一個 Generator 函式,用於依次讀取兩個檔案。
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
co 函式庫可以讓你不用編寫 Generator 函式的執行器。
var co = require('co');
co(gen);
上面程式碼中,Generator 函式只要傳入 co 函式,就會自動執行。co 函式返回一個 Promise 物件
,因此可以用 then 方法添加回調函式。
co(gen).then(function (){
console.log('Generator 函式執行完成');
})
上面程式碼中,等到 Generator 函式執行結束,就會輸出一行提示。相對於thunkify,我們的co的yeild後可以是promise或者thunk函式,其中後者是通過遞迴來實現的
4.2 co函式庫自動執行Generator的原理
為什麼 co 可以自動執行 Generator 函式?
前面文章說過,Generator 函式就是一個非同步操作的容器。它的自動執行需要一種機制,當非同步操作有了結果,能夠自動交回執行權。
兩種方法可以做到這一點。
(1)回撥函式。將非同步操作包裝成 Thunk 函式,在回撥函式裡面交回執行權(見第3部分)。
(2)Promise 物件。將非同步操作包裝成 Promise 物件,用 then 方法交回執行權。
co 函式庫其實就是將兩種自動執行器(Thunk 函式和 Promise 物件),包裝成一個庫
。使用 co 的前提條件是,Generator 函式的 yield 命令後面,只能是 Thunk 函式或 Promise 物件。
下面展示如何使用Promise來交還執行權:
var fs = require('fs');
var readFile = function (fileName){
//這裡new Promise導致我們的readFile本身返回的是一個Promise
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) reject(error);
resolve(data);
//then方法的回撥函式中會得到這裡的data資料
});
});
};
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
//這裡返回的是一個Promise物件,所以通過g.next().value獲取到的物件可以繼續呼叫then方法
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
下面我們手動執行上面這個Generator函式
var g = gen();
//g.next()開始執行第一個readFile方法,g.next().value表示執行第一個readFile返回的promise物件
g.next().value.then(function(data){
//g.next(data)表示將上一個yield的執行結果交還給Generator,相當於將結果賦值給變數f1
g.next(data).value.then(function(data){
g.next(data);
});
})
注意:如果是下面這樣,那麼f1最後將會是undefined(toString報錯),因為第一個yield執行結果並沒有交還給Generator,所以無法獲取到內容:
var g = gen();
g.next().value.then(function(data){
//下面不是g.next(data),所以第一個讀取檔案的結果沒有交還給Generator的f1
g.next().value.then(function(data){
g.next(data);
});
})
而下面展示的就是一個通過Promise來自動執行Generator的例項:
function run(gen){
var g = gen();
//獲取指標
function next(data){
var result = g.next(data);
if (result.done) return result.value;
//result.value此處返回的是Promise物件
result.value.then(function(data){
next(data);
//將data交給上一個yield執行結果
});
}
next();
}
run(gen);
5.async對於非同步的終極解決方案
5.1 Generator函式的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) 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,僅此而已。
5.2 async函式的優點
async 函式對 Generator 函式的改進,體現在以下三點。
(1)內建執行器。 Generator 函式的執行必須靠執行器,所以才有了 co 函式庫,而 async 函式自帶執行器。也就是說,async 函式的執行,與普通函式一模一樣,只要一行。
var result = asyncReadFile();
(2)更好的語義。 async 和 await,比起星號和 yield,語義更清楚了。async 表示函式裡有非同步操作,await 表示緊跟在後面的表示式需要等待結果。
(3)更廣的適用性。 co 函式庫約定,yield 命令後面只能是 Thunk 函式或 Promise 物件,而 async 函式的 await 命令後面,可以跟 Promise 物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。
5.3 async的用法
同 Generator 函式一樣,async 函式返回一個 Promise 物件,可以使用 then 方法添加回調函式
。當函式執行的時候,一旦遇到 await 就會先返回,等到觸發的非同步操作完成,再接著執行函式體內後面的語句。
下面的例子,指定多少毫秒後輸出一個值。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
//遇到await了,所有先返回,得到非同步操作完成,執行後面的程式碼
console.log(value)
}
asyncPrint('hello world', 50);
上面程式碼指定50毫秒以後,輸出”hello world”。
5.4 async自動執行器的實現
//genF是Generator函式
function spawn(genF) {
//返回promise和co一樣,但是co只能是promise和thunk函式
return new Promise(function(resolve, reject) {
var gen = genF();
//得到Generator內部指標
function step(nextF) {
try {
var next = nextF();
//next獲取到第一個await返回的結果
} catch(e) {
return reject(e);
}
if(next.done) {
return resolve(next.value);
}
//如果done為true那麼我們直接resolve
Promise.resolve(next.value).then(function(v) {
//第一個await返回的物件的value表示結果{value:'',done:false}
step(function() { return gen.next(v); });
//呼叫gen.next()獲取到下一個await的結果並傳入上一次的await呼叫後得到的value
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
//首次執行的時候傳入第一個await的data為undefined
});
}
參考資料: