天天看點

es6 javascript 異步操作

異步程式設計對 JavaScript 語言太重要。 Javascript 語言的執行環境是“ 單線程” 的, 如果沒有異步程式設計, 根本沒法用, 非卡死不可。

ES6 誕生以前, 異步程式設計的方法, 大概有下面四種。

回調函數

事件監聽

釋出 / 訂閱

Promise 對象

ES6 将 JavaScript 異步程式設計帶入了一個全新的階段, ES7 的Async函數更是提出了異步程式設計的終極解決方案。

1 基本概念

1.1 異步

所謂 " 異步 ",簡單說就是一個任務分成兩段, 先執行第一段, 然後轉而執行其他任務, 等做好了準備, 再回過頭執行第二段。

比如, 有一個任務是讀取檔案進行處理, 任務的第一段是向作業系統送出請求, 要求讀取檔案。 然後, 程式執行其他任務, 等到作業系統傳回檔案,再接着執行任務的第二段( 處理檔案)。 這種不連續的執行, 就叫做異步。

相應地, 連續的執行就叫做同步。 由于是連續執行, 不能插入其他任務, 是以作業系統從硬碟讀取檔案的這段時間, 程式隻能幹等着。

1.2 回調函數

JavaScript 語言對異步程式設計的實作, 就是回調函數。 所謂回調函數, 就是把任務的第二段單獨寫在一個函數裡面, 等到重新執行這個任務的時候, 就直接調用這個函數。 它的英語名字 callback, 直譯過來就是 " 重新調用 "。

讀取檔案進行處理, 是這樣寫的。

fs.readFile('/etc/passwd', function(err, data) {
	if(err) throw err;
	console.log(data);
});
           

上面代碼中, readFile 函數的第二個參數, 就是回調函數, 也就是任務的第二段。 等到作業系統傳回了 / etc / passwd這個檔案以後, 回調函數才會執行。

一個有趣的問題是, 為什麼 Node.js 約定, 回調函數的第一個參數, 必須是錯誤對象 err( 如果沒有錯誤, 該參數就是 null)? 原因是執行分成兩段, 在這兩段之間抛出的錯誤, 程式無法捕捉, 隻能當作參數, 傳入第二段。

1.3 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);
	});
           

上面代碼中, 我使用了 fs - readfile - promise 子產品, 它的作用就是傳回一個 Promise 版本的 readFile 函數。 Promise 提供 then 方法加載回調函數,catch 方法捕捉執行過程中抛出的錯誤。

可以看到, Promise 的寫法隻是回調函數的改進, 使用 then 方法以後, 異步任務的兩段執行看得更清楚了, 除此以外, 并無新意。

Promise 的最大問題是代碼備援, 原來的任務被 Promise 包裝了一下, 不管什麼操作, 一眼看去都是一堆 then, 原來的語義變得很不清楚。

那麼, 有沒有更好的寫法呢?

2 Generator 函數

2.1 協程

傳統的程式設計語言, 早有異步程式設計的解決方案( 其實是多任務的解決方案)。 其中有一種叫做 " 協程 "(coroutine), 意思是多個線程互相協作, 完成異步任務。

協程有點像函數, 又有點像線程。 它的運作流程大緻如下。

第一步, 協程 A 開始執行。

第二步, 協程 A 執行到一半, 進入暫停, 執行權轉移到協程 B。

第三步,( 一段時間後) 協程 B 交還執行權。

第四步, 協程 A 恢複執行。

上面流程的協程 A, 就是異步任務, 因為它分成兩段( 或多段) 執行。

舉例來說, 讀取檔案的協程寫法如下。

function* asyncJob() {
	// ... 其他代碼
	var f = yield readFile(fileA);
	// ... 其他代碼
}
           

上面代碼的函數asyncJob是一個協程, 它的奧妙就在其中的yield指令。 它表示執行到此處, 執行權将交給其他協程。 也就是說, yield指令是異步兩個階段的分界線。

協程遇到yield指令就暫停, 等到執行權傳回, 再從暫停的地方繼續往後執行。 它的最大優點, 就是代碼的寫法非常像同步操作, 如果去除 yield 指令,簡直一模一樣。

2.3 Generator 函數的概念

enerator 函數是協程在 ES6 的實作, 最大特點就是可以交出函數的執行權( 即暫停執行)。

整個 Generator 函數就是一個封裝的異步任務, 或者說是異步任務的容器。 異步操作需要暫停的地方, 都用yield語句注明。 Generator 函數的執行方法

如下。

function* gen(x) {
	var y = yield x + 2;
	return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
           

上面代碼中, 調用 Generator 函數, 會傳回一個内部指針( 即周遊器) g。 這是 Generator 函數不同于普通函數的另一個地方, 即執行它不會傳回結果, 傳回的是指針對象。 調用指針 g 的 next 方法, 會移動内部指針( 即執行異步任務的第一段), 指向第一個遇到的 yield 語句, 上例是執行到x + 2 為止。

換言之, next 方法的作用是分階段執行 Generator 函數。 每次調用 next 方法, 會傳回一個對象, 表示目前階段的資訊( value 屬性和 done 屬性)。 value屬性是 yield 語句後面表達式的值, 表示目前階段的值; done 屬性是一個布爾值, 表示 Generator 函數是否執行完畢, 即是否還有下一個階段。

2.4 Generator 函數的資料交換和錯誤處理

Generator 函數可以暫停執行和恢複執行, 這是它能封裝異步任務的根本原因。 除此之外, 它還有兩個特性, 使它可以作為異步程式設計的完整解決方案:函數體内外的資料交換和錯誤處理機制。

next 方法傳回值的 value 屬性, 是 Generator 函數向外輸出資料; next 方法還可以接受參數, 這是向 Generator 函數體内輸入資料。

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 屬性, 傳回表達式x + 2 的值( 3)。 第二個 next 方法帶有參數 2, 這個參數可以傳入 Generator 函數, 作為上個階段異步任務的傳回結果, 被函數體内的變量 y 接收。 是以, 這一步的 value 屬性, 傳回的就是 2( 變量 y 的值)。

Generator 函數内部還可以部署錯誤處理代碼, 捕獲函數體外抛出的錯誤。

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 代碼塊捕獲。 這意味着, 出錯的代碼與處理錯誤的代碼, 實作了時間和空間上的分離, 這對于異步程式設計無疑是很重要的。

2.5 異步任務的封裝

下面看看如何使用 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);
}
           

上面代碼中, Generator 函數封裝了一個異步操作, 該操作先讀取一個遠端接口, 然後從 JSON 格式的資料解析資訊。 就像前面說過的, 這段代碼非常像同步操作, 除了加上了 yield 指令。

執行這段代碼的方法如下。

var g = gen();
var result = g.next();
result.value.then(function(data) {
	return data.json();
}).then(function(data) {
	g.next(data);
});
           

上面代碼中, 首先執行 Generator 函數, 擷取周遊器對象, 然後使用 next 方法( 第二行), 執行異步任務的第一階段。 由于 Fetch 子產品傳回的是一個Promise 對象, 是以要用 then 方法調用下一個 next 方法。

可以看到, 雖然 Generator 函數将異步操作表示得很簡潔, 但是流程管理卻不友善( 即何時執行第一階段、 何時執行第二階段)。

繼續閱讀