JavaScript進階之路——認識和使用Promise,重構你的Js程式碼

雲霏霏發表於2015-07-10

  一轉眼,這2015年上半年就過去了,差不多一個月沒有寫部落格了,"罪過罪過"啊~~。進入了七月份,也就意味著我們上半年苦逼的單身生活結束了,從此刻起,我們要打起十二分的精神,開始下半年的單身生活。大家一起加油~~

  一直以來,JavaScript處理非同步都是以callback的方式,在前端開發領域callback機制幾乎深入人心。在設計API的時候,不管是瀏覽器廠商還是SDK開發商亦或是各種類庫的作者,基本上都已經遵循著callback的套路。近幾年隨著JavaScript開發模式的逐漸成熟,CommonJS規範順勢而生,其中就包括提出了Promise規範,Promise完全改變了js非同步程式設計的寫法,讓非同步程式設計變得十分的易於理解。今天我們就來了解一下Promise~~

 

 一 、初識Promise

1、什麼是promise?

  Promise可能大家都不陌生,因為Promise規範已經出來好一段時間了,同時Promise也已經納入了ES6,而且高版本的chrome、firefox瀏覽器都已經原生實現了Promise,只不過和現如今流行的類Promise類庫相比少些API。

  所謂Promise,字面上可以理解為“承諾”,就是說A呼叫B,B返回一個“承諾”給A,然後A就可以在寫計劃的時候這麼寫:當B返回結果給我的時候,A執行方案S1,反之如果B因為什麼原因沒有給到A想要的結果,那麼A執行應急方案S2,這樣一來,所有的潛在風險都在A的可控範圍之內了。

Promise規範如下:

  • 一個promise可能有三種狀態:等待(pending)、已完成(fulfilled)、已拒絕(rejected)
  • 一個promise的狀態只可能從“等待”轉到“完成”態或者“拒絕”態,不能逆向轉換,同時“完成”態和“拒絕”態不能相互轉換
  • promise必須實現then方法(可以說,then就是promise的核心),而且then必須返回一個promise,同一個promise的then可以呼叫多次,並且回撥的執行順序跟它們被定義時的順序一致
  • then方法接受兩個引數,第一個引數是成功時的回撥,在promise由“等待”態轉換到“完成”態時呼叫,另一個是失敗時的回撥,在promise由“等待”態轉換到“拒絕”態時呼叫。同時,then可以接受另一個promise傳入,也接受一個“類then”的物件或方法,即thenable物件。

2.promise原理分析

  可以看到promise的規範並不是很多,下面我們一邊分析promise一邊自己寫一個promise的實現。Promise實現的大致思路如下:

  建構函式Promise接受一個函式resolver,可以理解為傳入一個非同步任務,resolver接受兩個引數,一個是成功時的回撥,一個是失敗時的回撥,這兩引數和通過then傳入的引數是對等的。

其次是then的實現,由於Promise要求then必須返回一個promise,所以在then呼叫的時候會新生成一個promise,掛在當前promise的_next上,同一個promise多次呼叫都只會返回之前生成的_next。

由於then方法接受的兩個引數都是可選的,而且型別也沒限制,可以是函式,也可以是一個具體的值,還可以是另一個promise。下面是then的具體實現:

Promise.prototype.then = function(resolve, reject) {  
    var next = this._next || (this._next = Promise());  
    var status = this.status;  
    var x;  
  
    if('pending' === status) {  
        isFn(resolve) && this._resolves.push(resolve);  
        isFn(reject) && this._rejects.push(reject);  
        return next;  
    }  
  
    if('resolved' === status) {  
        if(!isFn(resolve)) {  
            next.resolve(resolve);  
        } else {  
            try {  
                x = resolve(this.value);  
                resolveX(next, x);  
            } catch(e) {  
                this.reject(e);  
            }  
        }  
        return next;  
    }  
  
    if('rejected' === status) {  
        if(!isFn(reject)) {  
            next.reject(reject);  
        } else {  
            try {  
                x = reject(this.reason);  
                resolveX(next, x);  
            } catch(e) {  
                this.reject(e);  
            }  
        }  
        return next;  
    }  
};  

這裡,then做了簡化,其他promise類庫的實現比這個要複雜得多,同時功能也更多,比如還有第三個引數——notify,表示promise當前的進度,這在設計檔案上傳等時很有用。對then的各種引數的處理是最複雜的部分,有興趣的同學可以參看其他類Promise庫的實現。

在then的基礎上,應該還需要至少兩個方法,分別是完成promise的狀態從pending到resolved或rejected的轉換,同時執行相應的回撥佇列,即resolve()reject()方法。

到此,一個簡單的promise就設計完成了,下面簡單實現下兩個promise化的函式:

function sleep(ms) {  
    return function(v) {  
        var p = Promise();  
  
        setTimeout(function() {  
            p.resolve(v);  
        });  
  
        return p;  
    };  
};  
  
function getImg(url) {  
    var p = Promise();  
    var img = new Image();  
  
    img.onload = function() {  
        p.resolve(this);  
    };  
  
    img.onerror = function(err) {  
        p.reject(err);  
    };  
  
    img.url = url;  
  
    return p;  
};  

由於Promise建構函式接受一個非同步任務作為引數,所以getImg還可以這樣呼叫:

function getImg(url) {  
    return Promise(function(resolve, reject) {  
        var img = new Image();  
  
        img.onload = function() {  
            resolve(this);  
        };  
  
        img.onerror = function(err) {  
            reject(err);  
        };  
  
        img.url = url;  
    });  
};  

接下來(見證奇蹟的時刻),假設有一個BT的需求要這麼實現:非同步獲取一個json配置,解析json資料拿到裡邊的圖片,然後按順序佇列載入圖片,每張圖片載入時給個loading效果,

 

function addImg(img) {  
    $('#list').find('> li:last-child').html('').append(img);  
};  
  
function prepend() {  
    $('<li>')  
        .html('loading...')  
        .appendTo($('#list'));  
};  
  
function run() {  
    $('#done').hide();  
    getData('map.json')  
        .then(function(data) {  
            $('h4').html(data.name);  
  
            return data.list.reduce(function(promise, item) {  
                return promise  
                    .then(prepend)  
                    .then(sleep(1000))  
                    .then(function() {  
                        return getImg(item.url);  
                    })  
                    .then(addImg);  
            }, Promise.resolve());  
        })  
        .then(sleep(300))  
        .then(function() {  
            $('#done').show();  
        });  
};  
  
$('#run').on('click', run)

這裡的sleep只是為了看效果加的,可猛擊檢視demo

在這裡,Promise.resolve(v)靜態方法只是簡單返回一個以v為肯定結果的promise,v可不傳入,也可以是一個函式或者是一個包含then方法的物件或函式(即thenable)。

類似的靜態方法還有Promise.cast(promise),生成一個以promise為肯定結果的promise;Promise.reject(reason),生成一個以reason為否定結果的promise。

我們實際的使用場景可能很複雜,往往需要多個非同步的任務穿插執行,並行或者序列同在。這時候,可以對Promise進行各種擴充套件,比如實現Promise.all(),接受promises佇列並等待他們完成再繼續,再比如Promise.any(),promises佇列中有任何一個處於完成態時即觸發下一步操作。

 

3.標準的Promise

  可參考html5rocks的這篇文章JavaScript Promises,目前高階瀏覽器如Chrome、Firefox都已經內建了Promise物件,提供更多的操作介面,比如Promise.all(),支援傳入一個promises陣列,當所有promises都完成時執行then,還有就是更加友好強大的異常捕獲,應對日常的非同步程式設計,應該足夠了。

  現今流行的各大js庫,幾乎都不同程度的實現了Promise,如dojo,jQuery、Zepto、when.js、Q等,只是暴露出來的大都是Deferred物件,當然還有angularJs中的$q.這裡以jQuery為例,說一下:

// animate  
$('.box')  
    .animate({'opacity': 0}, 1000)  
    .promise()  
    .then(function() {  
        console.log('done');  
    });  
  
// ajax  
$.ajax(options).then(success, fail);  
$.ajax(options).done(success).fail(fail);  
  
// ajax queue  
$.when($.ajax(options1), $.ajax(options2))  
    .then(function() {  
        console.log('all done.');  
    }, function() {  
        console.error('There something wrong.');  
    });  

 

 二 、用Promise組織你的JavaScript程式碼

  上面我們瞭解了Promise,相信大家對Promise有了一定的認識。下面我們開始動手來寫程式碼,通過幾個簡單的例子,來加深理解。這裡我們使用瀏覽器自帶的Promise,首先我們要先檢測一些瀏覽器是否支援Promise,其實很簡單,如果是谷歌瀏覽器,按下F12,開啟控制檯,如圖:

這裡我們可以看到Promise的type是function,也就是說谷歌瀏覽器是支援promise的。以此為原理,我們可以寫一段JavaScript程式碼來檢測,程式碼如下:

if(typeof(Promise) === "function"){
    alert("支援Promise");
}
else{
    alert("不支援Promise");
}

經過檢測,發現IE11竟然不支援promise.建議大家用谷歌瀏覽器來進行測試吧。

我們首先來寫一個等待的方法,如下:

function wait(duration){
    return new Promise(function(resolve, reject) {
        setTimeout(resolve,duration);
    })
}

測試這個方法的程式碼如下:wait(5000).then(function(){alert('hello')}),這段程式碼很簡單,就是等待5秒以後執行一個回撥,彈出一個訊息。當然,你還可以這樣寫:

wait(5000).then(function(){alert('hello')}).then(function(){console.log('world')})

怎麼樣?很簡單吧~~

下面來看一些我從網上收集的一些常用的JavaScript的promise的寫法:

function get(uri){
    return http(uri, 'GET', null);
}

function post(uri,data){
    if(typeof data === 'object' && !(data instanceof String || (FormData && data instanceof FormData))) {
        var params = [];
        for(var p in data) {
            if(data[p] instanceof Array) {
                for(var i = 0; i < data[p].length; i++) {
                    params.push(encodeURIComponent(p) + '[]=' + encodeURIComponent(data[p][i]));
                }
            } else {
                params.push(encodeURIComponent(p) + '=' + encodeURIComponent(data[p]));
            }
        }
        data = params.join('&');
    }


    return http(uri, 'POST', data || null, {
        "Content-type":"application/x-www-form-urlencoded"
    });
}

function http(uri,method,data,headers){
    return new Promise(function(resolve, reject) {
        var xhr = new XMLHttpRequest();
        xhr.open(method,uri,true);
        if(headers) {
            for(var p in headers) {
                xhr.setRequestHeader(p, headers[p]);
            }
        }
        xhr.addEventListener('readystatechange',function(e){
            if(xhr.readyState === 4) {
                if(String(xhr.status).match(/^2\d\d$/)) {
                    resolve(xhr.responseText);
                } else {
                    reject(xhr);
                }
            }
        });
        xhr.send(data);
    })
}

function wait(duration){
    return new Promise(function(resolve, reject) {
        setTimeout(resolve,duration);
    })
}

function waitFor(element,event,useCapture){
    return new Promise(function(resolve, reject) {
        element.addEventListener(event,function listener(event){
            resolve(event)
            this.removeEventListener(event, listener, useCapture);
        },useCapture)
    })
}

function loadImage(src) {
    return new Promise(function(resolve, reject) {
        var image = new Image;
        image.addEventListener('load',function listener() {
            resolve(image);
            this.removeEventListener('load', listener, useCapture);
        });
        image.src = src;
        image.addEventListener('error',reject);
    })
}

function runScript(src) {
    return new Promise(function(resolve, reject) {
        var script = document.createElement('script');
        script.src = src;
        script.addEventListener('load',resolve);
        script.addEventListener('error',reject);
        (document.getElementsByTagName('head')[0] || document.body || document.documentElement).appendChild(script);
    })
}

function domReady() {
    return new Promise(function(resolve, reject) {
        if(document.readyState === 'complete') {
            resolve();
        } else {
            document.addEventListener('DOMContentLoaded',resolve);
        }
    })
}

 看到了吧,Promise風格API跟回撥風格的API不同,它的引數跟同步的API是一致的,但是它的返回值是個Promise物件,要想得到真正的結果,需要在then的回撥裡面拿到。

 

 三、用Promise組織JavaScript非同步程式碼

   在比較複雜的頁面中,我們會使用到大量的非同步操作。我們來看看使用Promise會帶來怎樣的便利吧~~

1、多個非同步呼叫,同步/並行

   例如我們頁面呼叫了好幾個非同步函式,我們要等待所有的非同步函式執行完成後,做一些操作,如彈出一個訊息框提示使用者操作成功。下面我們拿一個例子來說明一下:

Promise.all跟then的配合,可以視為呼叫部分引數為Promise提供的函式。譬如,我們現在有一個接受三個引數的函式:

function print(a, b, c) {
    console.log(a + b + c);
}

現在我們呼叫print函式,其中a和b是需要非同步獲取的:

var c = 10;

print(geta(), getb(), 10); //這是同步的寫法

Promise.all([geta(), getb(), 10]).then(print); //這是 primise 的非同步寫法

如果用callback的話,我們就只能一個一個呼叫了,呼叫完了geta,然後在其回撥函式裡面呼叫getb,最後在getb的回撥函式中呼叫print方法。序列和並行哪個更快,大家很清楚吧~~

 

 2.競爭

   如果說Primise.all是promise物件之間的“與”關係,那麼Promise.race就是promise物件之間的“或”關係。比如,我要實現“點選按鈕或者5秒鐘之後執行”:

var btn = document.getElementsByTagName('button');

Promise.race(wait(5000), waitFor(btn, click)).then(function(){
    console.log('run!')
})

 

3.異常處理

   異常處理一直是回撥的難題,而promise提供了非常方便的catch方法:在一次promise呼叫中,任何的環節發生reject,都可以在最終的catch中捕獲到:

Promise.resolve().then(function(){
    return loadImage(img1);
}).then(function(){
    return loadImage(img2);
}).then(function(){
    return loadImage(img3);
}).catch(function(err){
    //錯誤處理
})

 

4.複雜流程

接下來,我們來看比較複雜的情況。

promise有一種非常重要的特性:then的引數,理論上應該是一個promise函式,而如果你傳遞的是普通函式,那麼預設會把它當做已經resolve了的promise函式。

這樣的特性讓我們非常容易把promise風格的函式跟已有程式碼結合起來。

為了方便傳引數,我們編寫一個currying函式,這是函數語言程式設計裡面的基本特性,在這裡跟promise非常搭,所以就實現一下:

function currying(){
    var f = arguments[0];
    var args = Array.prototype.slice.call(arguments,1);
    return function(){
        args.push.apply(args,arguments);
        return f.apply(this,args);
    }
}

currying會給某個函式"固化"幾個引數,並且返回接受剩餘引數的函式。比如之前的函式,可以這麼玩:

var print2 = currying(print,11);

print2(2, 3); //得到 11 + 2 + 3 的結果,16

var wait1s = currying(wait,1000);

wait1s().then(function(){
    console.log('after 1s!');
})

有了currying,我們就可以愉快地來玩鏈式呼叫了,比如以下程式碼:

Promise.race([
    domReady().then(currying(wait,5000)), 
    waitFor(btn, click)])
    .then(currying(runScript,'a.js'))
    .then(function(){
        console.log('loaded');
        return Promise.resolve();
    });

 

 四 、總結

   我們看到,不管Promise實現怎麼複雜,但是它的用法卻很簡單,組織的程式碼很清晰,從此不用再受callback的折磨了。promise作為一個新的API,它的API本身沒有什麼特別的功能,但是它背後代表的程式設計思路是很有價值的。

最後,Promise是如此的優雅!但Promise也只是解決了回撥的深層巢狀的問題,真正簡化JavaScript非同步程式設計的還是Generator,在Node.js端,建議考慮Generator。

 

 五 、參考資料

JavaScript Promise迷你書(中文版)   http://liubin.github.io/promises-book/

JavaScript Promise啟示錄       http://www.csdn.net/article/2014-05-28/2819979-JavaScript-Promise

用Promise組織程式                 http://www.w3ctech.com/topic/721

 

 作者:雲霏霏

QQ交流群:243633526

 部落格地址:http://www.cnblogs.com/yunfeifei/

 宣告:本部落格原創文字只代表本人工作中在某一時間內總結的觀點或結論,與本人所在單位沒有直接利益關係。非商業,未授權,貼子請以現狀保留,轉載時必須保留此段宣告,且在文章頁面明顯位置給出原文連線。

如果大家感覺我的博文對大家有幫助,請推薦支援一把,給我寫作的動力。

 

相關文章