Javascript異步編程之三Promise: 像堆積木一樣組織你的異步流程


這篇有點長,不過干貨挺多,既分析promise的原理,也包含一些最佳實踐,亮點在最后:)

還記得上一節講回調函數的時候,第一件事就提到了異步函數不能用return返回值,其原因就是在return語句執行的時候異步代碼還沒有執行完畢,所以return的值不是期望的運算結果。

Promise卻恰恰要回過頭來重新利用這個return語句,只不過不是返回最終運算值,而是返回一個對象,promise對象,用它來幫你進行異步流程管理。

先舉個例子幫助理解。Promise對象可以想象成是工廠生產線上的一個工人,一條生產線由若干個工人組成,每個工人分工明確,自己做完了把產品傳遞給下一個工人繼續他的工作,以此類推到最后就完成一個成品。這條生產線的組織機制就相當於Promise的機制,每個工人的工作相當於一個異步函數。后面會繼續拿promise和這個例子進行類比。

 

 

Promise風格異步函數的基本寫法:

如果用setTimeout來模擬你要進行的異步操作,以下是讓異步函數返回promise的基本寫法。調用Promise構造函數,生成一個promise對象,然后return它。把你的代碼包裹在匿名函數function(resolve, reject){ … } 里面,作為參數傳給Promise構造函數。resolve和reject是promise機制內部已經定義好的函數,傳給你用來改變promise對象的狀態。在你的異步代碼結束的時候調用resolve來表示異步操作成功,並且把結果傳給resolve作為參數,這樣它可以傳給下一個異步操作。

function asyncFn1() {
    var promise = new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log('asyncFn1 is done');
            resolve('asyncFn1 value');
        }, 1000);
    });

   return promise;
}

 

在promise機制當中,resolve被調用后會把promise的狀態變成’resolved’。 如果reject被調用,則會把promise的狀態變成’rejected’,表示異步操作失敗。所以在上面的例子中如果你有一些邏輯判斷,可以在失敗的時候調用reject:

//偽代碼
function asyncFn1() {
    var promise = new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log('asyncFn1 is done');
            if(success) {
                resolve('asyncFn1 value');
            } else {
                reject('error info');
            }
        }, 1000);
    });

    return promise;
}

 

then()方法:

既然promise的用來做流程管理的,那肯定是多個異步函數要按某種順序執行,而每個都要return promise對象。怎樣把它們串起來呢?答案是調用promise對象最重要的方法promsie.then(),從它的字面意思就可以看出它的作用。而且then()方法也返回一個新的promise對象,注意是新的promise對象,而不是返回之前那個。

假如有三個異步函數:

function asyncFn1() {
    var promise = new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log('asyncFn1 is done');
            resolve('asyncFn1 value');
        }, 1000);
    });
    return promise;
}

function asyncFn2(arg) {
    var promise = new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log('asyncFn2 is done');
            resolve(arg + ' asyncFn2 value');
        }, 1000);
    });
    return promise;
}

function asyncFn3(arg) {
    var promise = new Promise(function(resolve, reject) {
        setTimeout(function() {
            console.log('asyncFn3 is done');
            resolve(arg + ' asyncFn3 value');
        }, 1000);
    });
    return promise;
}

 

可以用then方法這樣順序來組織它們:

var p1 = asyncFn1(),
    p2 = p1.then(asyncFn2),
    p3 = p2.then(asyncFn3);

p3.then(function(arg) {
    console.log(arg);
});

 

這樣組織起來后,就會按照順序一個一個執行:asyncFn1執行完成后p1變成resolved狀態並調用asyncFn2,asyncFn2運行完后p2變成resolved狀態並且調用asyncFn3,asyncFn3執行完成后p3編程resolved狀態並調用匿名函數打印輸出結果。這個過程中,如果任何一個promise被變成’rejected’,后續所有promise馬上跟着變成rejected,而不會繼續執行他們所登記的異步函數。

上面代碼可以更加簡化成這樣,看起來更清爽,用飄柔的感覺有沒有:

asyncFn1()
    .then(asyncFn2)
    .then(asyncFn3)
    .then(function(arg) {
        console.log(arg);
    });

 

怎么樣,比上一節講的回調嵌套代碼漂亮太多啦,多苗條。

現在跟工廠生產線的例子進行類比一下加深理解。你猜上面這段飄柔代碼在工廠生產線例子中相當於什么?你一定會說,你不是上面說了嘛,相當於一條順序執行的生產線。錯!!! 它相當於---------生產計划,或者生產圖紙。怕了沒?沒錯就是相當於生產計划,里面登記了每個工人的任務和他們的工作順序。如果把它當成生產線,就會誤以為asyncFn1()運行完了再調用then,當asyncFn2運行完了再調用下一個then,當asyncFn3運行完了再調用第三個then,這樣會造成是由then來調用這些異步函數的錯覺。實際上then的作用僅僅是登記當每個promise變成resolved狀態時要調用的下一個函數,僅僅是登記,而不是實際上調用它們,實際調用是發生在promise變成resolved的時候。(then可以用來登記生產計划的原因是它其實是個同步方法,所以這段飄柔代碼噌得一下就執行完了,計划就出來了,而不是跟着那些asyncFn函數們一個等一個的執行)。搞清楚這個對於新手來說非常重要,它可以讓你更好的來組織你的異步流程。后面會詳細說。另外,工作計划產生后,生產也同時開始了,即asyncFn函數們也開始執行了,按登記的順序。

 

catch()方法:

上面例子中then方法都是只接受一個異步函數作為參數,實際上then方法可以接受兩個函數作為參數。第一個函數是Promise對象的狀態變為Resolved時調用,第二個回調函數是Promise對象 的狀態變為Rejected時調用。其中,第二個函數是可選的,大部分情況下不需要提供。但是一種情況除外就是當你的異步流程結束的時候需要用第二個函數來捕獲異常。即:

asyncFn1()
    .then(asyncFn2)
    .then(asyncFn3)
    .then(null, function(error) {
        console.log(error);
    });

 

最后一步的異常捕獲通常會換一種寫法:

asyncFn1()
    .then(asyncFn2)
    .then(asyncFn3)
    .catch(function(error) {
        console.log(error);
    });

 

catch()是then()用來捕獲異常時的別名或語法糖。它可以捕獲前面任何promise對象變成rejected的狀態后,所傳遞下來的錯誤信息。如果不使用catch()方法,Promise對象拋出的錯誤就會石沉大海,讓你無法調試。

 

嵌套promise

Promise機制本身是為了解決回調嵌套的,但有意思的是promise本身也可以嵌套,示例如下:

//偽代碼
fn1()
    .then(fn2)
    .then(function(result) {
        return fn3(result)
                .then(fn31)
                .then(fn32)
                .then(fn33);
    })
    .then(fn4)
    .catch(function(err) {
        console.log(err);
    });

 

你怎么看?我個人觀點,任何事情都沒有絕對的對和錯,好和不好,就是個度的問題。

 

Promise.all()方法:

上一節在回調風格的異步中,最后留了一個思考題,怎樣在循環里面調用異步函數?現在揭曉答案。

var fs = require('fs');

function foo(dir, callback) {
    fs.readdir(dir, function(err, files) {
        var text = '',
        counter = files.length;
        for(var i=0, j=files.length; i<j; ++i) {
            void function(ii) {
                fs.readFile(files[ii], 'utf8', function(err, data) {
                    text += data;
                    --counter;
                    if(counter===0) {
                        callback(text);
                    }
                });
            } (i);
        }
    });
}

foo('./', function(data) {
    console.log(data);
});

 

上面代碼foo函數讀取當前目錄下所有文件然后合並到一起,由callback把內容傳出來。調用callback的時機也很清楚了,關鍵就是設個計數器(counter),必須當所有readFile回調都完成后再調用callback。順便提一下循環調用異步的時候循環本身必須使用一個匿名函數包裹,為什么?呵呵新手繞不過的坑,答案自行尋找。后面有時間再寫文探討一些javascript的坑坑吧。

怎樣循環回調風格的異步函數現在清楚了,那么問題來了,怎樣循環promise風格的函數呢?

var fs = require('fs');

//把fs.readdir()改造為promise風格
function readdirP(dir) {
    return newPromise(function(resolve, reject) {
        fs.readdir(dir, function(err, files) {
            if(err) {
                reject(err);
            } else {
                resolve(files);
            }
        });
    });
}

//把fs.readFile()改造為promise風格
function readFileP(file) {
    return new Promise(function(resolve, reject) {
        fs.readFile(file, 'utf8', function(err, data) {
            if(err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

function foo(dir) {
    return new Promise(function(resolve, reject) {
        var text = '';
        readdirP(dir).then(function(files) {
            return new Promise(function(resolve, reject) {
                var counter = files.length;
                console.log(counter);
                for(var i=0, j=files.length; i<j; ++i) {
                    void function(ii) {
                        readFileP(files[ii]).then(function(data) {
                            text += data;
                            --counter;
                            if(counter===0) {
                                resolve(text);
                            }
                        });
                    }(i);
                }
            });
        }).then(function(result) {
            resolve(result);
        });
    });
}

foo('./').then(function(data) {
    console.log(data);
});

 

我了個去,怎么看起來比回調風格的還復雜?沒錯的確是這樣,因為你還是在用回調思維寫promise風格的代碼,是個四不像。正宗的寫法應該是這樣的:

function foo(dir) {
    var promise = readdirP(dir)

        .then(function(files) {
            var arr=[];
            for(var i=0, j=files.length; i<j; ++i) {
                arr.push(readFileP(files[i]));
            }
            return Promise.all(arr);
        })

        .then(function(datas) {
            return datas.join('');
        });

    return promise;
}

foo('./').then(function(data) {
    console.log(data);
});

 

這里關鍵就在於Promise.all()的使用。Promise.all(arr)接受一組promise為參數,即promise數組。當所有promise都變成resolved的時候就完成了,輸出也是一個數組,即每個promise所resolve的值。如果任何一個promise變成rejected,則整個失敗,可以在后面用catch捕獲。標准寫法:

//偽代碼
var arr = [promise1, promise2, promise3];
Promise.all(arr)
    .then(function(resultArr) {
        使用resultArr;
    })
    .catch(function(error) {
        console.log(error);
    });

 

Promise.race()方法:

稍提一下Promise.race(arr)方法,用法跟Promise.all(arr)類似,只不過arr中任何一個promise變resolved/rejected的時候就結束,輸出這個resolve/reject的值。這個方法的功能從它的名字就可以看出來。

 

最佳實踐:

Promise流程最后一定要加個catch()捕獲可能發生的錯誤。

then(fn)方法只接受函數作為的參數,fn如果是異步的,則必須要return一個promise對象;如果是同步的,則可以直接return一個value

function foo(arg) {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(arg + 1);
        }, 1000);
    });
}


foo(0)
    .then(foo)
    .then(foo)
    .then(function(arg) {
        return arg +1;
    })
    .then(foo)
    .then(function(arg) {
        console.log(arg);
    });

 

猜猜上述代碼最后輸出多少?foo被調用了4次,並且中間有一次同步arg+1的代碼,所以最后輸出5。這里的同步代碼arg+1太簡單只是為了演示,如果你的同步代碼比較復雜而且中間可能拋出exception,那最好讓同步代碼也返回一個promise,這樣就可以在最后catch里面捕獲到,真是太爽了:

foo(0)
    .then(foo)
    .then(foo)
    .then(function(arg) {
        return Promise.resolve().then(function() {
            return arg +1;
        });
    })
    .then(foo)
    .catch(function(err) {
        console.log(err);
    });

 

即把同步代碼用Promise.resolve().then(function() { … } 進行包裹。Promise.resolve()是生成promise對象的快捷方法,不過它生成的promise對象初始狀態就是resolved的。Promise.resolve()方法還可以帶參數,這里不進行詳述,大家可以自行去了解一下。

用上述方法寫出來的流程,出錯幾率會大大減少。

說了這么久,該說重點了:)

 

堆積木:

返本溯源,promise是為了解決什么問題來着?對了,解決回調地獄,本質上是為了更加清晰的組織異步代碼。Promise的精髓用法就是把一個個異步函數像積木一樣按照它們的順序堆積自來,可以串行可以並行,這種堆積木方式的組織流程相當靈活,可以組織出任意你的業務中需要的流程。這樣說比較抽象,還是用例子吧:

(這是我實際項目中的一個真實例子)我有5個promise風格的異步函數fn1, fn2, fn3, fn4 和 fn5。fn3需要用到fn2的結果,fn4需要用到fn3的結果, fn5需要用到fn1, fn2, fn3和fn4的結果。是不是挺繞,應該怎么寫?時間關系就不賣關子了。

var p1 = fn1(),
    p2 = fn2();
    p3 = p2.then(fn3);
    p4 = p3.then(fn4);

var arr = [p1, p2, p3, p4];

Promise.all(arr).then(fn5);

怎么樣,是不是很神奇?發揮你的想象力,這些異步函數你可以隨意組合,串行並行。

切記:組合的過程中每個異步函數通常只出現一次,除非你業務需要它使用不同的數據運行多次,否則如果出現多次,極有可能你已經掉坑里了:

//錯誤代碼
var p1 = fn1(),
    p2 = fn2();
    p3 = fn2().then(fn3);
    p4 = fn2().then(fn3).then(fn4);

var arr = [p1, p2, p3, p4];

Promise.all(arr).then(fn5);

看起來兩組代碼似乎等價哦,呵呵,只不過錯誤代碼中fn2會跑3次,fn3會跑2次。好好對比清楚:)

我在還沒有領悟這種用法的時候是用這樣直腸子的做法:

fn1()
   .then(fn2)
   .then(fn3)
   .then(fn4)
   .then(fn5);

喲?這不是更簡單嗎?錯!因為fn1的輸出在fn2, fn3和fn4中根本沒用,但是還是必須捎帶在他們每一個的輸出結果里面; fn4根本不需要fn2的輸出,但又要捎帶在fn3里面以傳給fn4最后給fn5。這樣就造成這些函數深度耦合在一起,功能混亂。 所以記得promise不只能串行,也可以並行,就像堆積木一樣非常靈活的進行組合。不知誰這么聰明發明了這種方法:)

 

轉載請注明出處: http://www.cnblogs.com/chrischjh/p/4692743.html 

『本集完』


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM