1. 程式人生 > >es6 javascript 非同步操作

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 函式將非同步操作表示得很簡潔, 但是流程管理卻不方便( 即何時執行第一階段、 何時執行第二階段)。