js 非同步轉同步之Promise
原文連結:點選開啟連結
深入解析Javascript非同步程式設計
這裡深入探討下Javascript的非同步程式設計技術。(P.S. 本文較長,請準備好瓜子可樂 :D)
一. Javascript非同步程式設計簡介
至少在語言級別上,Javascript是單執行緒的,因此非同步程式設計對其尤為重要。
拿nodejs來說,外殼是一層js語言,這是使用者操作的層面,在這個層次上它是單執行緒執行的,也就是說我們不能像Java、Python這類語言在語言級別使用多執行緒能力。取而代之的是,nodejs程式設計中大量使用了非同步程式設計技術,這是為了高效使用硬體,同時也可以不造成同步阻塞。不過nodejs在底層實現其實還是用了多執行緒技術,只是這一層使用者對使用者來說是透明的,nodejs幫我們做了幾乎全部的管理工作,我們不用擔心鎖或者其他多執行緒程式設計會遇到的問題,只管寫我們的非同步程式碼就好。
二. 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'); 34 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物件可以用同步操作的流程寫法來表達非同步操作,避免了層層巢狀的非同步回撥,程式碼也更加清晰易懂,方便維護。
Promise.prototype.then()
Promise.prototype.then()方法返回的是一個新的Promise物件,因此可以採用鏈式寫法,即一個then後面再呼叫另一個then。如果前一個回撥函式返回的是一個Promise物件,此時後一個回撥函式會等待第一個Promise物件有了結果,才會進一步呼叫。
example 8:
1 //ES 6原生Promise示例 2 var fs = require('fs') 3 4 var read = function (filename){ 5 var promise = new Promise(function(resolve, reject){ 6 fs.readFile(filename, 'utf8', function(err, data){ 7 if (err){ 8 reject(err); 9 } 10 resolve(data); 11 }) 12 }); 13 return promise; 14 } 15 16 read('./text1.txt') 17 .then(function(data){ 18 console.log(data); 19 }, function(err){ 20 console.log("err: " + err); 21 });
以上程式碼中,read函式是Promise化的,在read函式中,例項化了一個Promise物件,Promise的建構函式接受一個函式作為引數,這個函式的兩個引數分別是resolve方法和reject方法。如果非同步操作成功,就是用resolve方法將Promise物件的狀態從“未完成”變為“完成”(即從pending變為resolved),如果非同步操作出錯,則是用reject方法把Promise物件的狀態從“未完成”變為“失敗”(即從pending變為rejected),read函式返回了這個Promise物件。Promise例項生成以後,可以用then方法分別指定resolve方法和reject方法的回撥函式。
上面這個例子,Promise建構函式的引數是一個函式,在這個函式中我們寫非同步操作的程式碼,在非同步操作的回撥中,我們根據err變數來選擇是執行resolve方法還是reject方法,一般來說呼叫resolve方法的引數是非同步操作獲取到的資料(如果有的話),但還可能是另一個Promise物件,表示非同步操作的結果有可能是一個值,也有可能是另一個非同步操作,呼叫reject方法的引數是非同步回撥用的err引數。
呼叫read函式時,實際上返回的是一個Promise物件,通過在這個Promise物件上呼叫then方法並傳入resolve方法和reject方法來指定非同步操作成功和失敗後的操作。
example 9:
1 //原生Primose順序巢狀回撥示例 2 var fs = require('fs') 3 4 var read = function (filename){ 5 var promise = new Promise(function(resolve, reject){ 6 fs.readFile(filename, 'utf8', function(err, data){ 7 if (err){ 8 reject(err); 9 } 10 resolve(data); 11 }) 12 }); 13 return promise; 14 } 15 16 read('./text1.txt') 17 .then(function(data){ 18 console.log(data); 19 return read('./text2.txt'); 20 }) 21 .then(function(data){ 22 console.log(data); 23 });
在Promise的順序巢狀回撥中,第一個then方法先輸出text1.txt的內容後返回read('./text2.txt'),注意這裡很關鍵,這裡實際上返回了一個新的Promise例項,第二個then方法指定了非同步讀取text2.txt檔案的回撥函式。這種形似同步呼叫的Promise順序巢狀回撥的特點就是有一大堆的then方法,程式碼冗餘略多。
異常處理
Promise.prototype.catch()
Promise.prototype.catch方法是Promise.prototype.then(null, rejection)的別名,用於指定發生錯誤時的回撥函式。
example 9:
1 var fs = require('fs') 2 3 var read = function (filename){ 4 var promise = new Promise(function(resolve, reject){ 5 fs.readFile(filename, 'utf8', function(err, data){ 6 if (err){ 7 reject(err); 8 } 9 resolve(data); 10 }) 11 }); 12 return promise; 13 } 14 15 read('./text1.txt') 16 .then(function(data){ 17 console.log(data); 18 return read('not_exist_file'); 19 }) 20 .then(function(data){ 21 console.log(data); 22 }) 23 .catch(function(err){ 24 console.log("error caught: " + err); 25 }) 26 .then(function(data){ 27 console.log("completed"); 28 })
使用Promise物件的catch方法可以捕獲非同步呼叫鏈中callback的異常,Promise物件的catch方法返回的也是一個Promise物件,因此,在catch方法後還可以繼續寫非同步呼叫方法。這是一個非常強大的能力。
如果在catch方法中發生了異常:
example 10:
1 var fs = require('fs') 2 3 var read = function (filename){ 4 var promise = new Promise(function(resolve, reject){ 5 fs.readFile(filename, 'utf8', function(err, data){ 6 if (err){ 7 reject(err); 8 } 9 resolve(data); 10 }) 11 }); 12 return promise; 13 } 14 15 read('./text1.txt') 16 .then(function(data){ 17 console.log(data); 18 return read('not_exist_file'); 19 }) 20 .then(function(data){ 21 console.log(data); 22 }) 23 .catch(function(err){ 24 console.log("error caught: " + err); 25 x+1; 26 }) 27 .then(function(data){ 28 console.log("completed"); 29 })
在上述程式碼中,x+1會丟擲一個異常,但是由於後面沒有catch方法了,導致這個異常不會被捕獲,而且也不會傳遞到外層去,也就是說這個異常就默默發生了,沒有驚動任何人。
我們可以在catch方法後再加catch方法來捕獲這個x+1的異常:
example 11:
1 var fs = require('fs') 2 3 var read = function (filename){ 4 var promise = new Promise(function(resolve, reject){ 5 fs.readFile(filename, 'utf8', function(err, data){ 6 if (err){ 7 reject(err); 8 } 9 resolve(data); 10 }) 11 }); 12 return promise; 13 } 14 15 read('./text1.txt') 16 .then(function(data){ 17 console.log(data); 18 return read('not_exist_file'); 19 }) 20 .then(function(data){ 21 console.log(data); 22 }) 23 .catch(function(err){ 24 console.log("error caught: " + err); 25 x+1; 26 }) 27 .catch(function(err){ 28 console.log("error caught: " + err); 29 }) 30 .then(function(data){ 31 console.log("completed"); 32 })
Promise非同步併發
如果幾個非同步呼叫有關聯,但它們不是順序式的,是可以同時進行的,我們很直觀地會希望它們能夠併發執行(這裡要注意區分“併發”和“並行”的概念,不要搞混)。
Promise.all()
Promise.all方法用於將多個Promise例項,包裝成一個新的Promise例項。
var p = Promise.all([p1,p2,p3]);
Promise.all方法接受一個數組作為引數,p1、p2、p3都是Promise物件例項。
p的狀態由p1、p2、p3決定,分兩種情況:
(1)只有p1、p2、p3的狀態都變成fulfilled,p的狀態才會變成fulfilled,此時p1、p2、p3的返回值組成一個數組,傳遞給p的回撥函式。
(2)只要p1、p2、p3之中有一個被rejected,p的狀態就變成rejected,此時第一個被reject的例項的返回值,會傳遞給p的回撥函式。
一個具體的例子:
example 12:
1 var fs = require('fs') 2 3 var read = function (filename){ 4 var promise = new Promise(function(resolve, reject){ 5 fs.readFile(filename, 'utf8', function(err, data){ 6 if (err){ 7 reject(err); 8 } 9 resolve(data); 10 }) 11 }); 12 return promise; 13 } 14 15 var promises = [1, 2].map(function(fileno){ 16 return read('./text' + fileno + '.txt'); 17 }); 18 19 Promise.all(promises) 20 .then(function(contents){ 21 console.log(contents); 22 }) 23 .catch(function(err){ 24 console.log("error caught: " + err); 25 })
上述程式碼會併發執行讀取text1.txt和text2.txt的操作,當兩個檔案都讀取完畢時,輸出它們的內容,contents是一個數組,每個元素對應promises陣列的執行結果 (順序完全一致),在這裡就是text1.txt和text2.txt的內容。
Promise.race()
Promise.race()也是將多個Promise例項包裝成一個新的Promise例項:
var p = Promise.race([p1,p2,p3]);
上述程式碼中,p1、p2、p3只要有一個例項率先改變狀態,p的狀態就會跟著改變,那個率先改變的Promise例項的返回值,就傳遞給p的返回值。如果Promise.all方法和Promise.race方法的引數不是Promise例項,就會先呼叫下面講到的Promise.resolve方法,將引數轉為Promise例項,再進一步處理。
example 13:
1 var http = require('http'); 2 var qs = require('querystring'); 3 4 var requester = function(options, postData){ 5 var promise = new Promise(function(resolve, reject){ 6 var content = ""; 7 var req = http.request(options, function (res) { 8 res.setEncoding('utf8'); 9 10 res.on('data', function (data) { 11 onData(data); 12 }); 13 14 res.on('end', function () { 15 resolve(content); 16 }); 17 18 function onData(data){ 19 content += data; 20 } 21 }); 22 23 req.on('error', function(err) { 24 reject(err); 25 }); 26 27 req.write(postData); 28 req.end(); 29 }); 30 31 return promise; 32 } 33 34 var promises = ["檸檬", "蘋果"].map(function(keyword){ 35 var options = { 36 hostname: 'localhost', 37 port: 9200, 38 path: '/meiqu/tips/_search', 39 method: 'POST' 40 }; 41 42 var data = { 43 'query' : { 44 'match' : { 45 'summary' : keyword 46 } 47 } 48 }; 49 data = JSON.stringify(data); 50 return requester(options, data); 51 }); 52 53 Promise.race(promises) 54 .then(function(contents) { 55 var obj = JSON.parse(contents); 56 console.log(obj.hits.hits[0]._source.summary); 57 }) 58 .catch(function(err){ 59 console.log(err); 60 });
Promise.resolve()
有時候需將現有物件轉換成Promise物件,可以使用Promise.resolve()。
如果Promise.resolve方法的引數,不是具有then方法的物件(又稱thenable物件),則返回一個新的Promise物件,且它的狀態為fulfilled。
如果Promise.resolve方法的引數是一個Promise物件的例項,則會被原封不動地返回。
example 14:
1 var p = Promise.resolve('Hello'); 2 3 p.then(function (s){ 4 console.log(s) 5 });
Promise.reject()
Promise.reject(reason)方法也會返回一個新的Promise例項,該例項的狀態為rejected。Promise.reject方法的引數reason,會被傳遞給例項的回撥函式。
example 15:
1 var p = Promise.reject('出錯了'); 2 3 p.then(null, function (s){ 4 console.log(s) 5 });
上面程式碼生成一個Promise物件的例項p,狀態為rejected,回撥函式會立即執行。
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中,我們可以利用**高階函式**實現函式柯里化。