1. 程式人生 > >ES6中的Promise和Generator詳解

ES6中的Promise和Generator詳解

[toc] # 簡介 ES6中除了上篇文章講過的語法新特性和一些新的API之外,還有兩個非常重要的新特性就是Promise和Generator,今天我們將會詳細講解一下這兩個新特性。 # Promise ## 什麼是Promise Promise 是非同步程式設計的一種解決方案,比傳統的解決方案“回撥函式和事件”更合理和更強大。 所謂Promise,簡單說就是一個容器,裡面儲存著某個未來才會結束的事件(通常是一個非同步操作)的結果。 從語法上說,Promise 是一個物件,從它可以獲取非同步操作的訊息。 ## Promise的特點 Promise有兩個特點: 1. 物件的狀態不受外界影響。 Promise物件代表一個非同步操作,有三種狀態:Pending(進行中)、Resolved(已完成,又稱 Fulfilled)和Rejected(已失敗)。 只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。 2. 一旦狀態改變,就不會再變,任何時候都可以得到這個結果。 Promise物件的狀態改變,只有兩種可能:從Pending變為Resolved和從Pending變為Rejected。 這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。 ## Promise的優點 Promise將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。 Promise物件提供統一的介面,使得控制非同步操作更加容易。 ## Promise的缺點 1. 無法取消Promise,一旦新建它就會立即執行,無法中途取消。 2. 如果不設定回撥函式,Promise內部丟擲的錯誤,不會反應到外部。 3. 當處於Pending狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。 ## Promise的用法 Promise物件是一個建構函式,用來生成Promise例項: ~~~js var promise = new Promise(function(resolve, reject) { // ... some code if (/* 非同步操作成功 */){ resolve(value); } else { reject(error); } } ); ~~~ promise可以接then操作,then操作可以接兩個function引數,第一個function的引數就是構建Promise的時候resolve的value,第二個function的引數就是構建Promise的reject的error。 ~~~js promise.then(function(value) { // success }, function(error) { // failure } ); ~~~ 我們看一個具體的例子: ~~~js function timeout(ms){ return new Promise(((resolve, reject) => { setTimeout(resolve,ms,'done'); })) } timeout(100).then(value => console.log(value)); ~~~ Promise中呼叫了一個setTimeout方法,並會定時觸發resolve方法,並傳入引數done。 最後程式輸出done。 ## Promise的執行順序 Promise一經建立就會立馬執行。但是Promise.then中的方法,則會等到一個呼叫週期過後再次呼叫,我們看下面的例子: ~~~js let promise = new Promise(((resolve, reject) => { console.log('Step1'); resolve(); })); promise.then(() => { console.log('Step3'); }); console.log('Step2'); 輸出: Step1 Step2 Step3 ~~~ ## Promise.prototype.then() then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法. ~~~js getJSON("/users.json").then(function(json){ return json.name; }).then(function(name){ console.log(name); }); ~~~ 上面的程式碼使用then方法,依次指定了兩個回撥函式。第一個回撥函式完成以後,會將返回結果作為引數,傳入第二個回撥函式 ## Promise.prototype.catch() Promise.prototype.catch方法是.then(null, rejection)的別名,用於指定發生錯誤時的回撥函式。 ~~~js getJSON("/users.json").then(function(json){ return json.name; }).catch(function(error){ console.log(error); }); ~~~ Promise 物件的錯誤具有“冒泡”性質,會一直向後傳遞,直到被捕獲為止。也就是說,錯誤總是會被下一個catch語句捕獲 ~~~js getJSON("/users.json").then(function(json){ return json.name; }).then(function(name){ console.log(name); }).catch(function(error){ //處理前面所有產生的錯誤 console.log(error); }); ~~~ ## Promise.all() Promise.all方法用於將多個Promise例項,包裝成一個新的Promise例項 ~~~js var p = Promise.all([p1,p2,p3]); ~~~ 1. 只有p1、p2、p3的狀態都變成fulfilled,p的狀態才會變成fulfilled,此時p1、p2、p3的返回值組成一個數組,傳遞給p的回撥函式。 2. 只要p1、p2、p3之中有一個被rejected,p的狀態就變成rejected,此時第一個被reject的例項的返回值,會傳遞給p的回撥函式。 ## Promise.race() Promise.race方法同樣是將多個Promise例項,包裝成一個新的Promise例項 ~~~js var p = Promise.race([p1,p2,p3]); ~~~ 只要p1、p2、p3之中有一個例項率先改變狀態,p的狀態就跟著改變。那個率先改變的 Promise 例項的返回值,就傳遞給p的回撥函式. ## Promise.resolve() Promise.resolve()將現有物件轉為Promise物件. ~~~js Promise.resolve('js'); //等價於 new Promise(resolve => resolve('js')); ~~~ 那麼什麼樣的物件能夠轉化成為Promise物件呢? 1. 引數是一個Promise例項 2. 引數是一個thenable物件 3. 引數不是具有then方法的物件,或根本就不是物件 4. 不帶有任何引數 ## Promise.reject() Promise.reject(reason)方法也會返回一個新的 Promise 例項,該例項的狀態為rejected ~~~js var p = Promise.reject('error'); //等價於 var p = new Promise((resolve,reject) => reject('error')); ~~~ Promise.reject()方法的引數,會原封不動地作為reject的理由,變成後續方法的引數。這一點與Promise.resolve方法不一致 ## done() Promise物件的回撥鏈,不管以then方法或catch方法結尾,要是最後一個方法丟擲錯誤,都有可能無法捕捉到(因為Promise內部的錯誤不會冒泡到全域性)。因此,我們可以提供一個done方法,總是處於回撥鏈的尾端,保證丟擲任何可能出現的錯誤 ~~~js asyncFunc().then(f1).catch(f2).then(f3).done(); ~~~ ## finally() finally方法用於指定不管Promise物件最後狀態如何,都會執行的操作。它與done方法的最大區別,它接受一個普通的回撥函式作為引數,該函式不管怎樣都必須執行. ~~~js server.listen(1000).then(function(){ //do something }.finally(server.stop); ~~~ # Generator ## 什麼是Generator Generator 函式是 ES6 提供的一種非同步程式設計解決方案 從語法上,首先可以把它理解成,Generator函式是一個狀態機,封裝了多個內部狀態 執行 Generator 函式會返回一個遍歷器物件. 形式上,Generator 函式是一個普通函式,但是有兩個特徵。一是,function關鍵字與函式名之間有一個星號;二是,函式體內部使用yield語句,定義不同的內部狀態。 舉個例子: ~~~js function * helloWorldGenerator(){ yield 'hello'; yield 'world'; return 'ending'; } var gen = helloWorldGenerator(); ~~~ 輸出結果: ~~~js console.log(gen.next()); console.log(gen.next()); console.log(gen.next()); { value: 'hello', done: false } { value: 'world', done: false } { value: 'ending', done: true } ~~~ ## yield 遍歷器物件的next方法的執行邏輯如下: (1)遇到yield語句,就暫停執行後面的操作,並將緊跟在yield後面的那個表示式的值,作為返回的物件的value屬性值。 (2)下一次呼叫next方法時,再繼續往下執行,直到遇到下一個yield語句。 (3)如果沒有再遇到新的yield語句,就一直執行到函式結束,直到return語句為止,並將return語句後面的表示式的值,作為返回的物件的value屬性值。 (4)如果該函式沒有return語句,則返回的物件的value屬性值為undefined。 > 注意,yield句本身沒有返回值,或者說總是返回undefined。 next方法可以帶一個引數,該引數就會被當作上一個yield語句的返回值。 ~~~js function * f() { for( let i =0; true; i++){ let reset = yield i; if(reset){ i = -1; } } } let g = f(); console.log(g.next()); console.log(g.next()); console.log(g.next(true)); ~~~ 輸出結果: ~~~js { value: 0, done: false } { value: 1, done: false } { value: 0, done: false } ~~~ 可以看到最後的一步,我們使用next傳入的true替代了i的值,最後導致i= -1 + 1 = 0. 我們再看一個例子: ~~~js function * f2(x){ var y = 2 * ( yield ( x + 1)); var z = yield (y / 3); return (x + y + z); } var r1= f2(5); console.log(r1.next()); console.log(r1.next()); console.log(r1.next()); var r2= f2(5); console.log(r2.next()); console.log(r2.next(12)); console.log(r2.next(13)); ~~~ 輸出結果: ~~~js { value: 6, done: false } { value: NaN, done: false } { value: NaN, done: true } { value: 6, done: false } { value: 8, done: false } { value: 42, done: true } ~~~ 如果next不傳值的話,yield本身是沒有返回值的,所以我們會得到NaN。 但是如果next傳入特定的值,則該值會替換該yield,成為真正的返回值。 ## yield * 如果在 Generator 函式內部,呼叫另一個 Generator 函式,預設情況下是沒有效果的 ~~~js function * a1(){ yield 'a'; yield 'b'; } function * b1(){ yield 'x'; a1(); yield 'y'; } for(let v of b1()){ console.log(v); } ~~~ 輸出結果: ~~~js x y ~~~ 可以看到,在b1中呼叫a1是沒有效果的。 將上面的例子修改一下: ~~~js function * a1(){ yield 'a'; yield 'b'; } function * b1(){ yield 'x'; yield * a1(); yield 'y'; } for(let v of b1()){ console.log(v); } ~~~ 輸出結果: ~~~js x a b y ~~~ ## 非同步操作的同步化表達 Generator函式的暫停執行的效果,意味著可以把非同步操作寫在yield語句裡面,等到呼叫next方法時再往後執行。這實際上等同於不需要寫回調函數了,因為非同步操作的後續操作可以放在yield語句下面,反正要等到呼叫next方法時再執行。所以,Generator函式的一個重要實際意義就是用來處理非同步操作,改寫回調函式。 我們看一個怎麼通過Generator來獲取一個Ajax的結果。 ~~~js function * ajaxCall(){ let result = yield request("http://www.flydean.com"); let resp = JSON.parse(result); console.log(resp.value); } function request(url){ makeAjaxCall(url, function(response){ it.next(response); }); } var it = ajaxCall(); it.next(); ~~~ 我們使用一個yield來獲取非同步執行的結果。但是我們如何將這個yield傳給result變數呢?要記住yield本身是沒有返回值的。 我們需要呼叫generator的next方法,將非同步執行的結果傳進去。這就是我們在request方法中做的事情。 # Generator 的非同步應用 什麼是非同步應用呢? 所謂"非同步",簡單說就是一個任務不是連續完成的,可以理解成該任務被人為分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。 比如,有一個任務是讀取檔案進行處理,任務的第一段是向作業系統發出請求,要求讀取檔案。然後,程式執行其他任務,等到作業系統返回檔案,再接著執行任務的第二段(處理檔案)。這種不連續的執行,就叫做非同步。 相應地,連續的執行就叫做同步。由於是連續執行,不能插入其他任務,所以作業系統從硬碟讀取檔案的這段時間,程式只能乾等著。 ES6誕生以前,非同步程式設計的方法,大概有下面四種。 回撥函式 事件監聽 釋出/訂閱 Promise 物件 ## 回撥函式 ~~~js fs.readFile(fileA, 'utf-8', function(error,data){ fs.readFile(fileB, 'utf-8', function(error,data){ } }) ~~~ 如果依次讀取兩個以上的檔案,就會出現多重巢狀。程式碼不是縱向發展,而是橫向發展,很快就會亂成一團,無法管理。因為多個非同步操作形成了強耦合,只要有一個操作需要修改,它的上層回撥函式和下層回撥函式,可能都要跟著修改。這種情況就稱為"回撥函式地獄"(callback hell)。 ## Promise Promise 物件就是為了解決這個問題而提出的。它不是新的語法功能,而是一種新的寫法,允許將回調函式的巢狀,改成鏈式呼叫。 ~~~js let readFile = require('fs-readfile-promise'); readFile(fileA).then(function(){ return readFile(fileB); }).then(function(data){ console.log(data); }) ~~~ ## Thunk函式和非同步函式自動執行 在講Thunk函式之前,我們講一下函式的呼叫有兩種方式,一種是傳值呼叫,一種是傳名呼叫。 "傳值呼叫"(call by value),即在進入函式體之前,就計算x + 5的值(等於6),再將這個值傳入函式f。C語言就採用這種策略。 “傳名呼叫”(call by name),即直接將表示式x + 5傳入函式體,只在用到它的時候求值。 編譯器的“傳名呼叫”實現,往往是將引數放到一個臨時函式之中,再將這個臨時函式傳入函式體。這個臨時函式就叫做 Thunk 函式。 舉個例子: ~~~js function f(m){ return m * 2; } f(x + 5); ~~~ 上面的程式碼等於: ~~~js var thunk = function () { return x + 5; } function f(thunk){ return thunk() * 2; } ~~~ 在 JavaScript 語言中,Thunk函式替換的不是表示式,而是多引數函式,將其替換成一個只接受回撥函式作為引數的單引數函式。 怎麼解釋呢? 比如nodejs中的: ~~~js fs.readFile(filename,[encoding],[callback(err,data)]) ~~~ readFile接收3個引數,其中encoding是可選的。我們就以兩個引數為例。 一般來說,我們這樣呼叫: ~~~js fs.readFile(fileA,callback); ~~~ 那麼有沒有辦法將其改寫成為單個引數的function的級聯呼叫呢? ~~~js var Thunk = function (fn){ return function (...args){ return functon (callback){ return fn.call(this,...args, callback); } } } var readFileThunk = Thunk(fs.readFile); readFileThunk(fileA)(callback); ~~~ 可以看到上面的Thunk將兩個引數的函式改寫成為了單個引數函式的級聯方式。或者說Thunk是接收一個callback並執行方法的函式。 這樣改寫有什麼用呢?Thunk函式現在可以用於 Generator 函式的自動流程管理。 之前在講Generator的時候,如果Generator中有多個yield的非同步方法,那麼我們需要在next方法中傳入這些非同步方法的執行結果。 手動傳入非同步執行結果當然是可以的。但是有沒有自動執行的辦法呢? ~~~js let fs = require('fs'); let thunkify = require('thunkify'); let readFileThunk = thunkify(fs.readFile); let gen = function * (){ let r1 = yield readFileThunk('/tmp/file1'); console.log(r1.toString()); let r2 = yield readFileThunk('/tmp/file2'); console.log(r2.toString()); } let g = gen(); function run(fn){ let gen = fn(); function next (err, data){ let result = gen.next(data); if(result.done) return; result.value(next); } next(); } run(g); ~~~ gen.next返回的是一個物件,物件的value就是Thunk函式,我們向Thunk函式再次傳入next callback,從而出發下一次的yield操作。 有了這個執行器,執行Generator函式方便多了。不管內部有多少個非同步操作,直接把 Generator 函式傳入run函式即可。當然,前提是每一個非同步操作,都要是Thunk函式,也就是說,跟在yield命令後面的必須是Thunk函式。 # 總結 Promise和Generator是ES6中引入的非常中要的語法,後面的koa框架就是Generator的一種具體的實現。我們會在後面的文章中詳細講解koa的使用,敬請期待。 > 本文作者:flydean程式那些事 > > 本文連結:[http://www.flydean.com/es6-promise-generator/](http://www.flydean.com/es6-promise-generator/) > > 本文來源:flydean的部落格 > > 歡迎關注我的公眾號:「程式那些事」最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!