ES6 系列之我們來聊聊 Promise
前言
Promise 的基本使用可以看阮一峰老師的 《ECMAScript 6 入門》。
我們來聊點其他的。
回撥
說起 Promise,我們一般都會從回撥或者回調地獄說起,那麼使用回撥到底會導致哪些不好的地方呢?
1. 回撥巢狀
使用回撥,我們很有可能會將業務程式碼寫成如下這種形式:
doA( function(){
doB();
doC( function(){
doD();
} )
doE();
} );
doF();
複製程式碼
當然這是一種簡化的形式,經過一番簡單的思考,我們可以判斷出執行的順序為:
doA()
do F()
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,對方是否會因為某個錯誤導致你傳入的回撥函式執行了多次呢?
為了避免出現這樣的問題,你可以在自己的回撥函式中加入判斷,可是萬一又因為某個錯誤這個回撥函式沒有執行呢? 萬一這個回撥函式有時同步執行有時非同步執行呢?
我們總結一下這些情況:
- 回撥函式執行多次
- 回撥函式沒有執行
- 回撥函式有時同步執行有時非同步執行
對於這些情況,你可能都要在回撥函式中做些處理,並且每次執行回撥函式的時候都要做些處理,這就帶來了很多重複的程式碼。
回撥地獄
我們先看一個簡單的回撥地獄的示例。
現在要找出一個目錄中最大的檔案,處理步驟應該是:
- 用
fs.readdir
獲取目錄中的檔案列表; - 迴圈遍歷檔案,使用
fs.stat
獲取檔案資訊 - 比較找出最大檔案;
- 以最大檔案的檔名為引數呼叫回撥。
程式碼為:
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 的時候,可能會遇到如下問題:
- 回撥函式執行多次
- 回撥函式沒有執行
- 回撥函式有時同步執行有時非同步執行
對於第一個問題,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 狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。
參考
- 《你不知道的 JavaScript 中卷》
- Promise 的 N 種用法
- JavaScript Promise 迷你書
- Promises/A+規範
- Promise 如何使用
- Promise Anti-patterns
- 一道關於Promise應用的面試題
ES6 系列
ES6 系列目錄地址:github.com/mqyqingfeng…
ES6 系列預計寫二十篇左右,旨在加深 ES6 部分知識點的理解,重點講解塊級作用域、標籤模板、箭頭函式、Symbol、Set、Map 以及 Promise 的模擬實現、模組載入方案、非同步處理等內容。
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。