1. 程式人生 > >JS異步編程

JS異步編程

all 提交 github console 不變 技術 過程 屬於 維護

Javascript語言的執行環境是"單線程"(single thread)。

所謂"單線程",就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。

這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是只要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。

為了解決以上問題,就可以使用異步編程。

Javascript異步編程方法

ES 6以前:

* 回調函數
* 事件監聽(事件發布/訂閱)
* Promise對象

ES 6:

* Generator函數(協程coroutine)

ES 7:

* async和await

PS:如要運行以下例子,請安裝node v0.11以上版本,在命令行下使用 node [文件名.js] 的形式來運行,有部分代碼需要開啟特殊選項,會在具體例子裏說明。

1.回調函數

回調函數在Javascript中非常常見,一般是需要在一個耗時操作之後執行某個操作時可以使用回調函數。

example 1:

技術分享圖片
 1 //一個定時器
 2 function timer(time, callback){
 3     setTimeout(function(){
 4         callback();
 5     }, time);
 6 }
 7 
 8 timer(3000, function(){
 9     console.log(123);
10 })
技術分享圖片

example 2:

技術分享圖片
1 //讀文件後輸出文件內容
2 var fs = require(‘fs‘);
3 
4 fs.readFile(‘./text1.txt‘, ‘utf8‘, function(err, data){
5     if (err){
6         throw err;
7     }
8     console.log(data);
9 });
技術分享圖片

example 3:

技術分享圖片
1 //嵌套回調,讀一個文件後輸出,再讀另一個文件,註意文件是有序被輸出的,先text1.txt後text2.txt
2 var fs = require(‘fs‘);
3 
4 fs.readFile(‘./text1.txt‘, ‘utf8‘, function(err, data){
5     console.log("text1 file content: " + data);
6     fs.readFile(‘./text2.txt‘, ‘utf8‘, function(err, data){
7         console.log("text2 file content: " + data);
8     });
9 });
技術分享圖片

example 4:

技術分享圖片
 1 //callback hell
 2 
 3 doSomethingAsync1(function(){
 4     doSomethingAsync2(function(){
 5         doSomethingAsync3(function(){
 6             doSomethingAsync4(function(){
 7                 doSomethingAsync5(function(){
 8                     // code...
 9                 });
10             });
11         });
12     });
13 });
技術分享圖片

通過觀察以上4個例子,可以發現一個問題,在回調函數嵌套層數不深的情況下,代碼還算容易理解和維護,一旦嵌套層數加深,就會出現“回調金字塔”的問題,就像example 4那樣,如果這裏面的每個回調函數中又包含了很多業務邏輯的話,整個代碼塊就會變得非常復雜。從邏輯正確性的角度來說,上面這幾種回調函數的寫法沒有任何問題,但是隨著業務邏輯的增加和趨於復雜,這種寫法的缺點馬上就會暴露出來,想要維護它們實在是太痛苦了,這就是“回調地獄(callback hell)”。
一個衡量回調層次復雜度的方法是,在example 4中,假設doSomethingAsync2要發生在doSomethingAsync1之前,我們需要忍受多少重構的痛苦。

回調函數還有一個問題就是我們在回調函數之外無法捕獲到回調函數中的異常,我們以前在處理異常時一般這麽做:

example 5:

技術分享圖片
1 try{
2     //do something may cause exception..
3 }
4 catch(e){
5     //handle exception...
6 }
技術分享圖片

在同步代碼中,這沒有問題。現在思考一下下面代碼的執行情況:

example 6:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 
 3 try{
 4     fs.readFile(‘not_exist_file‘, ‘utf8‘, function(err, data){
 5         console.log(data);
 6     });
 7 }
 8 catch(e){
 9     console.log("error caught: " + e);
10 }
技術分享圖片

你覺得會輸出什麽?答案是undefined。我們嘗試讀取一個不存在的文件,這當然會引發異常,但是最外層的try/catch語句卻無法捕獲這個異常。這是異步代碼的執行機制導致的。

Tips: 為什麽異步代碼回調函數中的異常無法被最外層的try/catch語句捕獲?

異步調用一般分為兩個階段,提交請求和處理結果,這兩個階段之間有事件循環的調用,它們屬於兩個不同的事件循環(tick),彼此沒有關聯。

異步調用一般以傳入callback的方式來指定異步操作完成後要執行的動作。而異步調用本體和callback屬於不同的事件循環。

try/catch語句只能捕獲當次事件循環的異常,對callback無能為力。

也就是說,一旦我們在異步調用函數中扔出一個異步I/O請求,異步調用函數立即返回,此時,這個異步調用函數和這個異步I/O請求沒有任何關系。

2.事件監聽(事件發布/訂閱)

事件監聽是一種非常常見的異步編程模式,它是一種典型的邏輯分離方式,對代碼解耦很有用處。通常情況下,我們需要考慮哪些部分是不變的,哪些是容易變化的,把不變的部分封裝在組件內部,供外部調用,需要自定義的部分暴露在外部處理。從某種意義上說,事件的設計就是組件的接口設計。

example 7:

技術分享圖片
 1 //發布和訂閱事件
 2 
 3 var events = require(‘events‘);
 4 var emitter = new events.EventEmitter();
 5 
 6 emitter.on(‘event1‘, function(message){
 7     console.log(message);
 8 });
 9 
10 emitter.emit(‘event1‘, "message for you");
技術分享圖片

這種使用事件監聽處理的異步編程方式很適合一些需要高度解耦的場景。例如在之前一個遊戲服務端項目中,當人物屬性變化時,需要寫入到持久層。解決方案是先寫一個訂閱方,訂閱‘save‘事件,在需要保存數據時讓發布方對象(這裏就是人物對象)上直接用emit發出一個事件名並攜帶相應參數,訂閱方收到這個事件信息並處理。

3.Promise對象

ES 6中原生提供了Promise對象,Promise對象代表了某個未來才會知道結果的事件(一般是一個異步操作),並且這個事件對外提供了統一的API,可供進一步處理。
使用Promise對象可以用同步操作的流程寫法來表達異步操作,避免了層層嵌套的異步回調,代碼也更加清晰易懂,方便維護。

3.Generator函數

Generator函數是協程在ES 6中的實現,最大特點就是可以交出函數的執行權(暫停執行)。
註意:在node中需要開啟--harmony選項來啟用Generator函數。
整個Generator函數就是一個封裝的異步任務,或者說是異步任務的容器。異步操作需要暫停的地方,都用yield語句註明。

協程的運行方式如下:

第一步:協程A開始運行。
第二步:協程A執行到一半,暫停,執行權轉移到協程B。
第三步:一段時間後,協程B交還執行權。
第四步:協程A恢復執行。

上面的協程A就是異步任務,因為分為兩步執行。

比如一個讀取文件的例子:

example 16:

1 function asnycJob() {
2     // ...其他代碼
3     var f = yield readFile(fileA);
4     // ...其他代碼
5 }

asnycJob函數是一個協程,yield語句表示,執行到此處執行權就交給其他協程,也就是說,yield是兩個階段的分界線。協程遇到yield語句就暫停執行,直到執行權返回,再從暫停處繼續執行。這種寫法的優點是,可以把異步代碼寫得像同步一樣。

看一個簡單的Generator函數例子:

example 17:

技術分享圖片
 1 function* gen(x){
 2     var y = yield x + 2;
 3     return y;
 4 }
 5 
 6 var g = gen(1);
 7 var r1 = g.next(); // { value: 3, done: false }
 8 console.log(r1);
 9 var r2 = g.next() // { value: undefined, done: true }
10 console.log(r2);
技術分享圖片

需要註意的是Generator函數的函數名前面有一個"*"。
上述代碼中,調用Generator函數,會返回一個內部指針(即遍歷器)g,這是Generator函數和一般函數不同的地方,調用它不會返回結果,而是一個指針對象。調用指針g的next方法,會移動內部指針,指向第一個遇到的yield語句,上例就是執行到x+2為止。
換言之,next方法的作用是分階段執行Generator函數。每次調用next方法,會返回一個對象,表示當前階段的信息(value屬性和done屬性)。value屬性是yield語句後面表達式的值,表示當前階段的值;done屬性是一個布爾值,表示Generator函數是否執行完畢,即是否還有下一個階段。

Generator函數的數據交換和錯誤處理

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

example 18:

技術分享圖片
 1 function* gen(x){
 2     var y = yield x + 2;
 3     return y;
 4 }
 5 
 6 var g = gen(1);
 7 var r1 = g.next(); // { value: 3, done: false }
 8 console.log(r1);
 9 var r2 = g.next(2) // { value: 2, done: true }
10 console.log(r2);
技術分享圖片

第一個next的value值,返回了表達式x+2的值(3),第二個next帶有參數2,這個參數傳入Generator函數,作為上個階段異步任務的返回結果,被函數體內的變量y接收,因此這一階段的value值就是2。

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

example 19:

技術分享圖片
 1 function* gen(x){
 2     try {
 3         var y = yield x + 2;
 4     }
 5     catch (e){
 6         console.log(e);
 7     }
 8     return y;
 9 }
10 
11 var g = gen(1);
12 g.next();
13 g.throw(‘error!‘); //error!
技術分享圖片

下面是一個讀取文件的真實異步操作的例子。

example 20:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 var thunkify = require(‘thunkify‘);
 3 var readFile = thunkify(fs.readFile);
 4 
 5 var gen = function* (){
 6     var r1 = yield readFile(‘./text1.txt‘, ‘utf8‘);
 7     console.log(r1);
 8     var r2 = yield readFile(‘./text2.txt‘, ‘utf8‘);
 9     console.log(r2);
10 };
11 
12 //開始執行上面的代碼
13 var g = gen();
14 
15 var r1 = g.next();
16 r1.value(function(err, data){
17     if (err) throw err;
18     var r2 = g.next(data);
19     r2.value(function(err, data){
20         if (err) throw err;
21         g.next(data);
22     });
23 });
技術分享圖片

這就是一個基本的Generator函數定義和執行的流程。可以看到,雖然這裏的Generator函數寫的很簡潔,和同步方法的寫法很像,但是執行起來卻很麻煩,流程管理比較繁瑣。

在深入討論Generator函數之前我們先要知道Thunk函數這個概念。

求值策略(即函數的參數到底應該何時求值)

(1) 傳值調用
(2) 傳名調用

Javascript是傳值調用的,Thunk函數是編譯器“傳名調用”的實現,就是將參數放入一個臨時函數中,再將這個臨時函數放入函數體,這個臨時函數就叫做Thunk函數。
舉個栗子就好懂了:

example 21:

技術分享圖片
 1 function f(m){
 2     return m * 2;
 3 }
 4 var x = 1;
 5 f(x + 5);
 6 
 7 //等同於
 8 var thunk = function (x) {
 9     return x + 5;
10 };
11 
12 function f(thunk){
13     return thunk() * 2;
14 }
技術分享圖片

Thunk函數本質上是函數柯裏化(currying),柯裏化進行參數復用和惰性求值,這個是函數式編程的一些技巧,在js中,我們可以利用**高階函數**實現函數柯裏化。

JavaScript語言的Thunk函數

在JavaScript語言中,Thunk函數替換的不是表達式,而是多參數函數,將其替換成單參數的版本,且只接受回調函數作為參數。

example 22:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 
 3 // 正常版本的readFile(多參數版本)
 4 fs.readFile(fileName, callback);
 5 
 6 // Thunk版本的readFile(單參數版本)
 7 var readFileThunk = Thunk(fileName);
 8 readFileThunk(callback);
 9 
10 var Thunk = function (fileName){
11     return function (callback){
12         return fs.readFile(fileName, callback);
13     };
14 };
技術分享圖片

任何函數,只要參數有回調函數,就能寫成Thunk函數的形式。以下是一個簡單的Thunk函數轉換器:

example 23:

技術分享圖片
1 var Thunk = function(fn){
2     return function (){
3         var args = Array.prototype.slice.call(arguments);
4         return function (callback){
5             args.push(callback);
6             return fn.apply(this, args);
7         }
8     };
9 };
技術分享圖片

從本質上說,我們借助了Javascript高階函數來抽象了異步執行流程。

使用上面的轉換器,生成fs.readFile的Thunk函數。

example 24:

1 var readFileThunk = Thunk(fs.readFile);
2     readFileThunk(‘./text1.txt‘, ‘utf8‘)(function(err, data){
3     console.log(data);
4 });

可以使用thunkify模塊來Thunk化任何帶有callback的函數。

我們需要借助Thunk函數的能力來自動執行Generator函數。

下面是一個基於Thunk函數的Generator函數執行器。

example 25:

技術分享圖片
 1 //Generator函數執行器
 2 
 3 function run(fn) {
 4     var gen = fn();
 5 
 6     function next(err, data) {
 7         var result = gen.next(data);
 8         if (result.done) return;
 9         result.value(next);
10     }
11 
12     next();
13 }
14 
15 run(gen);
技術分享圖片

我們馬上拿這個執行器來做點事情。

example 26:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 var thunkify = require(‘thunkify‘);
 3 var readFile = thunkify(fs.readFile);
 4 
 5 var gen = function* (){
 6     var f1 = yield readFile(‘./text1.txt‘, ‘utf8‘);
 7     console.log(f1);
 8     var f2 = yield readFile(‘./text2.txt‘, ‘utf8‘);
 9     console.log(f2);
10     var f3 = yield readFile(‘./text3.txt‘, ‘utf8‘);
11     console.log(f3);
12 };
13 
14 function run(fn) {
15 var gen = fn();
16 
17 function next(err, data) {
18     var result = gen.next(data);
19     if (result.done) return;
20     result.value(next);
21 }
22 
23 next();
24 }
25 
26 run(gen); //自動執行
技術分享圖片

現在異步操作代碼的寫法就和同步的寫法一樣了。實際上,Thunk函數並不是自動控制Generator函數執行的唯一方案,要自動控制Generator函數的執行過程,需要有一種機制,自動接收和交還程序的執行權,回調函數和Promise都可以做到(利用調用自身的一些特性)。

yield *語句

普通的yield語句後面跟一個異步操作,yield *語句後面需要跟一個遍歷器,可以理解為yield *後面要跟另一個Generator函數,講起來比較抽象,看一個實例。

example 27:

技術分享圖片
 1 //嵌套異步操作流
 2 var fs = require(‘fs‘);
 3 var thunkify = require(‘thunkify‘);
 4 var readFile = thunkify(fs.readFile);
 5 
 6 var gen = function* (){
 7     var f1 = yield readFile(‘./text1.txt‘, ‘utf8‘);
 8     console.log(f1);
 9 
10     var f_ = yield * gen1(); //此處插入了另外一個異步流程
11 
12     var f2 = yield readFile(‘./text2.txt‘, ‘utf8‘);
13     console.log(f2);
14 
15     var f3 = yield readFile(‘./text3.txt‘, ‘utf8‘);
16     console.log(f3);
17 };
18 
19 var gen1 = function* (){
20     var f4 = yield readFile(‘./text4.txt‘, ‘utf8‘);
21     console.log(f4);
22     var f5 = yield readFile(‘./text5.txt‘, ‘utf8‘);
23     console.log(f5);
24 }
25 
26 function run(fn) {
27     var gen = fn();
28 
29     function next(err, data) {
30     var result = gen.next(data);
31     if (result.done) return;
32     result.value(next);
33 }
34 
35 next();
36 }
37 
38 run(gen); //自動執行
技術分享圖片

上面這個例子會輸出
1
4
5
2
3
也就是說,使用yield *可以在一個異步操作流程中直接插入另一個異步操作流程,我們可以據此構造可嵌套的異步操作流,更為重要的是,寫這些代碼完全是同步風格的。

目前業界比較流行的Generator函數自動執行的解決方案是co庫,此處也只給出co的例子。順帶一提node-fibers也是一種解決方案。

順序執行3個異步讀取文件的操作,並依次輸出文件內容:

example 28:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 var co = require(‘co‘);
 3 var thunkify = require(‘thunkify‘);
 4 var readFile = thunkify(fs.readFile);
 5 
 6 co(function*(){
 7     var files=[
 8     ‘./text1.txt‘,
 9     ‘./text2.txt‘,
10     ‘./text3.txt‘
11     ];
12 
13     var p1 = yield readFile(files[0]);
14     console.log(files[0] + ‘ ->‘ + p1);
15 
16     var p2 = yield readFile(files[1]);
17     console.log(files[1] + ‘ ->‘ + p2);
18 
19     var p3 = yield readFile(files[2]);
20     console.log(files[2] + ‘ ->‘ + p3);
21 
22     return ‘done‘;
23 });
技術分享圖片

並發執行3個異步讀取文件的操作,並存儲在一個數組中輸出(順序和文件名相同):

example 29:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 var co = require(‘co‘);
 3 var thunkify = require(‘thunkify‘);
 4 var readFile = thunkify(fs.readFile);
 5 
 6 co(function* () {
 7     var files = [‘./text1.txt‘, ‘./text2.txt‘, ‘./text3.txt‘];
 8     var contents = yield files.map(readFileAsync);
 9 
10     console.log(contents);
11 });
12 
13 function readFileAsync(filename) {
14     return readFile(filename, ‘utf8‘);
15 }
技術分享圖片

co庫和我們剛才的run函數有點類似,都是自動控制Generator函數的流程。


ES 7中的async和await

async和await是ES 7中的新語法,新到連ES 6都不支持,但是可以通過Babel一類的預編譯器處理成ES 5的代碼。目前比較一致的看法是async和await是js對異步的終極解決方案。

async函數實際上是Generator函數的語法糖(js最喜歡搞語法糖,包括ES 6中新增的“類”支持其實也是語法糖)。

配置Babel可以看:配置Babel

如果想嘗個鮮,簡單一點做法是執行:

1 sudo npm install --global babel-cli

async_await.js代碼如下:

技術分享圖片
 1 var fs = require(‘fs‘);
 2 
 3 var readFile = function (fileName){
 4     return new Promise(function (resolve, reject){
 5         fs.readFile(fileName, function(error, data){
 6             if (error){
 7                 reject(error);
 8             }
 9             else {
10                 resolve(data);
11             }
12         });
13     });
14 };
15 
16 var asyncReadFile = async function (){
17     var f1 = await readFile(‘./text1.txt‘);
18     var f2 = await readFile(‘./text2.txt‘);
19     console.log(f1.toString());
20     console.log(f2.toString());
21 };
22 
23 asyncReadFile();
技術分享圖片

接著執行 babel-node async_await.js

輸出:

1

2

轉載自https://nullcc.github.io/

JS異步編程