ES6 系列之我們來聊聊 Promise

前言

Promise 的基本使用可以看阮一峯老師的 《ECMAScript 6 入門》

我們來聊點其他的。

回調

說起 Promise,我們一般都會從回調或者回調地獄說起,那麼使用回調到底會導致哪些不好的地方呢?

1. 回調嵌套

使用回調,我們很有可能會將業務代碼寫成如下這種形式:

doA( function(){
    doB();

    doC( function(){
        doD();
    } )

    doE();
} );

doF();

當然這是一種簡化的形式,經過一番簡單的思考,我們可以判斷出執行的順序爲:

doA()
doF()
doB()
doC()
doE()
doD()

然而在實際的項目中,代碼會更加雜亂,爲了排查問題,我們需要繞過很多礙眼的內容,不斷的在函數間進行跳轉,使得排查問題的難度也在成倍增加。

當然之所以導致這個問題,其實是因爲這種嵌套的書寫方式跟人線性的思考方式相違和,以至於我們要多花一些精力去思考真正的執行順序,嵌套和縮進只是這個思考過程中轉移注意力的細枝末節而已。

當然了,與人線性的思考方式相違和,還不是最糟糕的,實際上,我們還會在代碼中加入各種各樣的邏輯判斷,就比如在上面這個例子中,doD() 必須在 doC() 完成後才能完成,萬一 doC() 執行失敗了呢?我們是要重試 doC() 嗎?還是直接轉到其他錯誤處理函數中?當我們將這些判斷都加入到這個流程中,很快代碼就會變得非常複雜,以至於無法維護和更新。

2. 控制反轉

正常書寫代碼的時候,我們理所當然可以控制自己的代碼,然而當我們使用回調的時候,這個回調函數是否能接着執行,其實取決於使用回調的那個 API,就比如:

// 回調函數是否被執行取決於 buy 模塊
import {buy} from './buy.js';

buy(itemData, function(res) {
    console.log(res)
});

對於我們經常會使用的 fetch 這種 API,一般是沒有什麼問題的,但是如果我們使用的是第三方的 API 呢?

當你調用了第三方的 API,對方是否會因爲某個錯誤導致你傳入的回調函數執行了多次呢?

爲了避免出現這樣的問題,你可以在自己的回調函數中加入判斷,可是萬一又因爲某個錯誤這個回調函數沒有執行呢?
萬一這個回調函數有時同步執行有時異步執行呢?

我們總結一下這些情況:

  1. 回調函數執行多次
  2. 回調函數沒有執行
  3. 回調函數有時同步執行有時異步執行

對於這些情況,你可能都要在回調函數中做些處理,並且每次執行回調函數的時候都要做些處理,這就帶來了很多重複的代碼。

回調地獄

我們先看一個簡單的回調地獄的示例。

現在要找出一個目錄中最大的文件,處理步驟應該是:

  1. fs.readdir 獲取目錄中的文件列表;
  2. 循環遍歷文件,使用 fs.stat 獲取文件信息
  3. 比較找出最大文件;
  4. 以最大文件的文件名爲參數調用回調。

代碼爲:

var fs = require('fs');
var path = require('path');

function findLargest(dir, cb) {
    // 讀取目錄下的所有文件
    fs.readdir(dir, function(er, files) {
        if (er) return cb(er);

        var counter = files.length;
        var errored = false;
        var stats = [];

        files.forEach(function(file, index) {
            // 讀取文件信息
            fs.stat(path.join(dir, file), function(er, stat) {

                if (errored) return;

                if (er) {
                    errored = true;
                    return cb(er);
                }

                stats[index] = stat;

                // 事先算好有多少個文件,讀完 1 個文件信息,計數減 1,當爲 0 時,說明讀取完畢,此時執行最終的比較操作
                if (--counter == 0) {

                    var largest = stats
                        .filter(function(stat) { return stat.isFile() })
                        .reduce(function(prev, next) {
                            if (prev.size > next.size) return prev
                            return next
                        })

                    cb(null, files[stats.indexOf(largest)])
                }
            })
        })
    })
}

使用方式爲:

// 查找當前目錄最大的文件
findLargest('./', function(er, filename) {
    if (er) return console.error(er)
    console.log('largest file was:', filename)
});

你可以將以上代碼複製到一個比如 index.js 文件,然後執行 node index.js 就可以打印出最大的文件的名稱。

看完這個例子,我們再來聊聊回調地獄的其他問題:

1.難以複用

回調的順序確定下來之後,想對其中的某些環節進行復用也很困難,牽一髮而動全身。

舉個例子,如果你想對 fs.stat 讀取文件信息這段代碼複用,因爲回調中引用了外層的變量,提取出來後還需要對外層的代碼進行修改。

2.堆棧信息被斷開

我們知道,JavaScript 引擎維護了一個執行上下文棧,當函數執行的時候,會創建該函數的執行上下文壓入棧中,當函數執行完畢後,會將該執行上下文出棧。

如果 A 函數中調用了 B 函數,JavaScript 會先將 A 函數的執行上下文壓入棧中,再將 B 函數的執行上下文壓入棧中,當 B 函數執行完畢,將 B 函數執行上下文出棧,當 A 函數執行完畢後,將 A 函數執行上下文出棧。

這樣的好處在於,我們如果中斷代碼執行,可以檢索完整的堆棧信息,從中獲取任何我們想獲取的信息。

可是異步回調函數並非如此,比如執行 fs.readdir 的時候,其實是將回調函數加入任務隊列中,代碼繼續執行,直至主線程完成後,纔會從任務隊列中選擇已經完成的任務,並將其加入棧中,此時棧中只有這一個執行上下文,如果回調報錯,也無法獲取調用該異步操作時的棧中的信息,不容易判定哪裏出現了錯誤。

此外,因爲是異步的緣故,使用 try catch 語句也無法直接捕獲錯誤。

(不過 Promise 並沒有解決這個問題)

3.藉助外層變量

當多個異步計算同時進行,比如這裏遍歷讀取文件信息,由於無法預期完成順序,必須藉助外層作用域的變量,比如這裏的 count、errored、stats 等,不僅寫起來麻煩,而且如果你忽略了文件讀取錯誤時的情況,不記錄錯誤狀態,就會接着讀取其他文件,造成無謂的浪費。此外外層的變量,也可能被其它同一作用域的函數訪問並且修改,容易造成誤操作。

之所以單獨講講回調地獄,其實是想說嵌套和縮進只是回調地獄的一個梗而已,它導致的問題遠非嵌套導致的可讀性降低而已。

Promise

Promise 使得以上絕大部分的問題都得到了解決。

1. 嵌套問題

舉個例子:

request(url, function(err, res, body) {
    if (err) handleError(err);
    fs.writeFile('1.txt', body, function(err) {
        request(url2, function(err, res, body) {
            if (err) handleError(err)
        })
    })
});

使用 Promise 後:

request(url)
.then(function(result) {
    return writeFileAsynv('1.txt', result)
})
.then(function(result) {
    return request(url2)
})
.catch(function(e){
    handleError(e)
});

而對於讀取最大文件的那個例子,我們使用 promise 可以簡化爲:

var fs = require('fs');
var path = require('path');

var readDir = function(dir) {
    return new Promise(function(resolve, reject) {
        fs.readdir(dir, function(err, files) {
            if (err) reject(err);
            resolve(files)
        })
    })
}

var stat = function(path) {
    return new Promise(function(resolve, reject) {
        fs.stat(path, function(err, stat) {
            if (err) reject(err)
            resolve(stat)
        })
    })
}

function findLargest(dir) {
    return readDir(dir)
        .then(function(files) {
            let promises = files.map(file => stat(path.join(dir, file)))
            return Promise.all(promises).then(function(stats) {
                return { stats, files }
            })
        })
        .then(data => {

            let largest = data.stats
                .filter(function(stat) { return stat.isFile() })
                .reduce((prev, next) => {
                    if (prev.size > next.size) return prev
                    return next
                })

            return data.files[data.stats.indexOf(largest)]
        })

}

2. 控制反轉再反轉

前面我們講到使用第三方回調 API 的時候,可能會遇到如下問題:

  1. 回調函數執行多次
  2. 回調函數沒有執行
  3. 回調函數有時同步執行有時異步執行

對於第一個問題,Promise 只能 resolve 一次,剩下的調用都會被忽略。

對於第二個問題,我們可以使用 Promise.race 函數來解決:

function timeoutPromise(delay) {
    return new Promise( function(resolve,reject){
        setTimeout( function(){
            reject( "Timeout!" );
        }, delay );
    } );
}

Promise.race( [
    foo(),
    timeoutPromise( 3000 )
] )
.then(function(){}, function(err){});

對於第三個問題,爲什麼有的時候會同步執行有的時候回異步執行呢?

我們來看個例子:

var cache = {...};
function downloadFile(url) {
      if(cache.has(url)) {
            // 如果存在cache,這裏爲同步調用
           return Promise.resolve(cache.get(url));
      }
     return fetch(url).then(file => cache.set(url, file)); // 這裏爲異步調用
}
console.log('1');
getValue.then(() => console.log('2'));
console.log('3');

在這個例子中,有 cahce 的情況下,打印結果爲 1 2 3,在沒有 cache 的時候,打印結果爲 1 3 2。

然而如果將這種同步和異步混用的代碼作爲內部實現,只暴露接口給外部調用,調用方由於無法判斷是到底是異步還是同步狀態,影響程序的可維護性和可測試性。

簡單來說就是同步和異步共存的情況無法保證程序邏輯的一致性。

然而 Promise 解決了這個問題,我們來看個例子:

var promise = new Promise(function (resolve){
    resolve();
    console.log(1);
});
promise.then(function(){
    console.log(2);
});
console.log(3);

// 1 3 2

即使 promise 對象立刻進入 resolved 狀態,即同步調用 resolve 函數,then 函數中指定的方法依然是異步進行的。

PromiseA+ 規範也有明確的規定:

實踐中要確保 onFulfilled 和 onRejected 方法異步執行,且應該在 then 方法被調用的那一輪事件循環之後的新執行棧中執行。

Promise 反模式

1.Promise 嵌套

// bad
loadSomething().then(function(something) {
    loadAnotherthing().then(function(another) {
        DoSomethingOnThem(something, another);
    });
});
// good
Promise.all([loadSomething(), loadAnotherthing()])
.then(function ([something, another]) {
    DoSomethingOnThem(...[something, another]);
});

2.斷開的 Promise 鏈

// bad
function anAsyncCall() {
    var promise = doSomethingAsync();
    promise.then(function() {
        somethingComplicated();
    });

    return promise;
}
// good
function anAsyncCall() {
    var promise = doSomethingAsync();
    return promise.then(function() {
        somethingComplicated()
    });
}

3.混亂的集合

// bad
function workMyCollection(arr) {
    var resultArr = [];
    function _recursive(idx) {
        if (idx >= resultArr.length) return resultArr;

        return doSomethingAsync(arr[idx]).then(function(res) {
            resultArr.push(res);
            return _recursive(idx + 1);
        });
    }

    return _recursive(0);
}

你可以寫成:

function workMyCollection(arr) {
    return Promise.all(arr.map(function(item) {
        return doSomethingAsync(item);
    }));
}

如果你非要以隊列的形式執行,你可以寫成:

function workMyCollection(arr) {
    return arr.reduce(function(promise, item) {
        return promise.then(function(result) {
            return doSomethingAsyncWithResult(item, result);
        });
    }, Promise.resolve());
}

4.catch

// bad
somethingAync.then(function() {
    return somethingElseAsync();
}, function(err) {
    handleMyError(err);
});

如果 somethingElseAsync 拋出錯誤,是無法被捕獲的。你可以寫成:

// good
somethingAsync
.then(function() {
    return somethingElseAsync()
})
.then(null, function(err) {
    handleMyError(err);
});
// good
somethingAsync()
.then(function() {
    return somethingElseAsync();
})
.catch(function(err) {
    handleMyError(err);
});

紅綠燈問題

題目:紅燈三秒亮一次,綠燈一秒亮一次,黃燈2秒亮一次;如何讓三個燈不斷交替重複亮燈?(用 Promse 實現)

三個亮燈函數已經存在:

function red(){
    console.log('red');
}
function green(){
    console.log('green');
}
function yellow(){
    console.log('yellow');
}

利用 then 和遞歸實現:

function red(){
    console.log('red');
}
function green(){
    console.log('green');
}
function yellow(){
    console.log('yellow');
}

var light = function(timmer, cb){
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            cb();
            resolve();
        }, timmer);
    });
};

var step = function() {
    Promise.resolve().then(function(){
        return light(3000, red);
    }).then(function(){
        return light(2000, green);
    }).then(function(){
        return light(1000, yellow);
    }).then(function(){
        step();
    });
}

step();

promisify

有的時候,我們需要將 callback 語法的 API 改造成 Promise 語法,爲此我們需要一個 promisify 的方法。

因爲 callback 語法傳參比較明確,最後一個參數傳入回調函數,回調函數的第一個參數是一個錯誤信息,如果沒有錯誤,就是 null,所以我們可以直接寫出一個簡單的 promisify 方法:

function promisify(original) {
    return function (...args) {
        return new Promise((resolve, reject) => {
            args.push(function callback(err, ...values) {
                if (err) {
                    return reject(err);
                }
                return resolve(...values)
            });
            original.call(this, ...args);
        });
    };
}

完整的可以參考 es6-promisif

Promise 的侷限性

1. 錯誤被吃掉

首先我們要理解,什麼是錯誤被吃掉,是指錯誤信息不被打印嗎?

並不是,舉個例子:

throw new Error('error');
console.log(233333);

在這種情況下,因爲 throw error 的緣故,代碼被阻斷執行,並不會打印 233333,再舉個例子:

const promise = new Promise(null);
console.log(233333);

以上代碼依然會被阻斷執行,這是因爲如果通過無效的方式使用 Promise,並且出現了一個錯誤阻礙了正常 Promise 的構造,結果會得到一個立刻跑出的異常,而不是一個被拒絕的 Promise。

然而再舉個例子:

let promise = new Promise(() => {
    throw new Error('error')
});
console.log(2333333);

這次會正常的打印 233333,說明 Promise 內部的錯誤不會影響到 Promise 外部的代碼,而這種情況我們就通常稱爲 “吃掉錯誤”。

其實這並不是 Promise 獨有的侷限性,try..catch 也是這樣,同樣會捕獲一個異常並簡單的吃掉錯誤。

而正是因爲錯誤被吃掉,Promise 鏈中的錯誤很容易被忽略掉,這也是爲什麼會一般推薦在 Promise 鏈的最後添加一個 catch 函數,因爲對於一個沒有錯誤處理函數的 Promise 鏈,任何錯誤都會在鏈中被傳播下去,直到你註冊了錯誤處理函數。

2. 單一值

Promise 只能有一個完成值或一個拒絕原因,然而在真實使用的時候,往往需要傳遞多個值,一般做法都是構造一個對象或數組,然後再傳遞,then 中獲得這個值後,又會進行取值賦值的操作,每次封裝和解封都無疑讓代碼變得笨重。

說真的,並沒有什麼好的方法,建議是使用 ES6 的解構賦值:

Promise.all([Promise.resolve(1), Promise.resolve(2)])
.then(([x, y]) => {
    console.log(x, y);
});

3. 無法取消

Promise 一旦新建它就會立即執行,無法中途取消。

4. 無法得知 pending 狀態

當處於 pending 狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。

參考

  1. 《你不知道的 JavaScript 中卷》
  2. Promise 的 N 種用法
  3. JavaScript Promise 迷你書
  4. Promises/A+規範
  5. Promise 如何使用
  6. Promise Anti-patterns
  7. 一道關於Promise應用的面試題

ES6 系列

ES6 系列目錄地址:https://github.com/mqyqingfeng/Blog

ES6 系列預計寫二十篇左右,旨在加深 ES6 部分知識點的理解,重點講解塊級作用域、標籤模板、箭頭函數、Symbol、Set、Map 以及 Promise 的模擬實現、模塊加載方案、異步處理等內容。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啓發,歡迎 star,對作者也是一種鼓勵。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章