本文主要會談到 promise 是什麼?promise 的錯誤處理、模式與限制。
...
...
promise 就是承諾(真的)。
callback 不能解決的信任與 callback hell 問題都即將在此得到解答 d(`・∀・)b
...
...
我們都有這個經驗...在午餐時間、人滿為患的餐廳裡排隊等候點餐,點餐完畢後,服務生給我們一個小圓盤,告知當小圓盤開始發光震動的時候,就可以來取餐了。
Promise 可說是這個小圓盤,當先前承諾的工作完成時,就來通知我們「工作完成了!來進行下一步的任務吧」。
...
...
那跟我們的程式碼有什麼關係呢?先來看個兩數相加的例子,函式 add 傳入兩個參數 x 與 y,回傳得到其相加的結果,如範例所示,1 + 2
得到 3。
function add(x, y) {
return x + y;
}
console.log(add(1, 2)); // 3
但若輸入的兩數,可能要經過冗長計算過程或從伺服器取得,而無法立即做運算呢?
var a = fetchA(); // a 此時尚未取得,值是 undefined
var b = fetchB(); // 2
console.log(add(a, b)); // NaN
這樣就會得到 NaN 倒地
備註:做加法運算時,非數值的部份會被強制轉型,意即 Number(undefined)
得到 NaN,點此複習強制轉型。
那...我們可以改為,等兩數都取到值了,再做相加運算嗎?當然可以。
function fetchA(cb) {
setTimeout(function() { // 模擬冗長運算
return cb(1);
}, 3000);
}
function fetchB(cb) {
return cb(2);
}
function add(getX, getY, cb) {
var x, y;
getX(function(xVal) {
x = xVal; // 得到 x
y && cb(x, y); // 若 y 也取到了,就執行加法運算
});
getY(function(yVal) {
y = yVal; // 得到 y
x && cb(x, y); // 若 x 也取到了,就執行加法運算
});
}
add(fetchA, fetchB, function(a, b) {
console.log(a + b); // 加法運算,印出相加結果
});
函式 fetchA 模擬了必須經過冗長運算才能得到結果的狀況,函式 add 等待兩數皆取得結果時,就執行加法運算。
程式碼是不是有點複雜?要檢查 x 又要檢查 y 的!的確,在沒有 promise 的 恐龍 年代 ,我們是這樣解決等候的問題。
...
...
有 promise 之後,改寫上面的例子...
function fetchA() {
return new Promise(function(resolve, reject) {
setTimeout(function() { // 模擬冗長運算
resolve(1);
}, 3000);
})
}
function fetchB(cb) {
return new Promise(function(resolve, reject) {
resolve(2);
});
}
function add(xPromise, yPromise) {
// x 與 y 都取到了
return Promise.all([xPromise, yPromise]).then(function(values) {
return values[0] + values[1]; // 執行加法運算
});
}
add(fetchA(), fetchB()).then(function(sum) {
console.log(sum); // 印出相加結果
});
依舊使用函式 fetchA 模擬必須經過冗長運算才能得到結果的狀況,函式 add 彷彿拿了兩個小圓盤(xPromise 與 yPromise)等著取餐,等兩個小圓盤都發光震動了才去領餐點,也就是執行加法運算。
...
...
有 promise 的世界是不是文明多了!?比之前來得方便許多。
...
...
承上,then 可接受兩個函式作為參數,第一個函式用於成功(resolve)時要執行的任務,第二個函式用於失敗(reject)時要執行的任務,失敗的原因可能是因為取得值的過程出錯或加法運算失敗等,當然還有一種遲遲未得到結果的延遲狀態(pending),稍後再討論。
add(fetchA(), fetchB())
.then(
function(sum) { // fulfillment handler
console.log(sum);
},
function(err) { // rejection handler
console.error(err); // 印出錯誤原因
}
);
如同在前面 callback 的分離回呼提過的,用於錯誤通知的 callback 通常是 optional,不設定即默認忽略。
而這樣的捕捉錯誤的方式真的可靠嗎?來看另一個例子。
var p = Promise.reject('Oops');
p.then(
function fulfilled() {
// ...
},
function rejected(err) {
console.log(err);
}
);
// Oops
好像還可以?有正確補捉到錯誤喔!但如果是在 callback 內發生錯誤,要怎麼辦?
var p = Promise.resolve(42);
p.then(
function fulfilled() {
console.log(x);
},
function rejected(err) {
console.log(err);
}
);
// Uncaught (in promise) ReferenceError: x is not defined
直接報錯,並沒有進入 rejected 這個 callback!也就是說,若在 callback 內發生錯誤,是不會被捕捉到的。
加上一個 catch 如何?
var p = Promise.resolve(42);
p.then(
function fulfilled() {
console.log(x);
},
function rejected(err) {
console.log(err);
}
)
.catch(handleErrors); // ReferenceError: x is not defined
function handleErrors(err) {
console.log(err);
}
的確捕捉到錯誤「ReferenceError: x is not defined」了,可是...若錯誤是發生在 catch 的 handleErrors 裡面呢?要無限制地加上 catch 嗎?其實永遠都會有無法被捕捉的錯誤存在,而目前尚未有更全面、可靠又普遍的解法。
另,從上面的程式碼可以觀察到,promise 還可以做流程控管,由成功或失敗來決定要執行哪一個 callback-看是要顯示結果呢,還是要執行 error handler,都是可以的。
這個部份要來看一些非同步模式的變體,它們可簡化非同步流程控制的表達方式,並讓程式碼更易於維護。
Promise.all([ .. ])
想像有位土豪到美食街任意點餐,拿個好幾個小圓盤,小圓盤分別發光震動,但他等到每個小圓盤都呼叫了,才起身取餐-這就是 Promise.all([ .. ])
在做的事情,所有的 promise 都回傳成功了才進入下一個任務,在此之前都是等待,但若其一回傳為失敗就進入失敗的處理狀況。
範例如下,當 p1 和 p2 都成功時才會進入 fulfilled 的 callback,任一失敗就進入 rejected。
var p1 = request('http://some.url.1/');
var p2 = request('http://some.url.2/');
Promise.all([p1,p2])
.then(
function fulfilled(msgs) {
return request('http://some.url.3/?v=' + msgs.join(','));
},
function rejected(err) {
console.log(err);
}
)
.then(function(msg) {
console.log(msg);
});
all([ .. ])
常用於需要迭代的狀況。
Promise.race([ .. ])
再次想像有位土豪,雖然他只想吃一份餐點,但也是到處點餐,拿了好幾個小圓盤,而他只對第一個發光震動的小圓盤取餐,其他都捨棄-這就是 Promise.race([ .. ])
在做的事情,只要有任一 promise 回傳成功就進入下一個任務,其餘的都忽略。
範例如下,當 p1 或 p2 任一成功時就會進入 fulfilled 的 callback,全都失敗就進入 rejected。注意,若 Promise.race(iterable)
中的 iterable 是空陣列,則會造成永久擱置。
var p1 = request('http://some.url.1/');
var p2 = request('http://some.url.2/');
Promise.race([p1,p2])
.then(
function fulfilled(msgs) {
return request('http://some.url.3/?v=' + msgs.join(','));
},
function rejected(err) {
console.log(err);
}
)
.then(function(msg) {
console.log(msg);
});
race([ .. ])
常用於解決逾時等候的問題,如下範例所示,若超過 3 秒未回覆就當成錯誤處理,也可看作是假裝取消承諾的解法,後面會再探討。
function timeoutPromise(delay) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
reject('Timeout!');
}, delay);
});
}
Promise.race([
foo(),
timeoutPromise(3000)
])
.then(
function() {
// `foo(..)` 在 3 秒內成功回覆!
},
function(err) {
// 可能是被拒絕或擱置超過 3 秒
}
);
更多關於 all([ .. ])
與 race([ .. ])
的變體。
none([ .. ])
:所有的 promise 都是拒絕的,才會履行。any([ .. ])
:只要有任一 promise 是成功的,就會履行。first([ .. ])
:第一個 promise 成功時就立刻履行。last([ .. ])
:最後一個 promise 成功時就立刻履行。promise 儘管為我們帶來種種美好,但其實也是有些限制的。
如前面錯誤處理所提到的,若在 promise 的 callback 內發生錯誤,是不會被捕捉到的,而會直接報錯。
var p = Promise.resolve(42);
p.then(
function fulfilled() {
console.log(x); // 有錯!
},
function rejected(err) {
console.log(err);
}
);
// Uncaught (in promise) ReferenceError: x is not defined
promise 只能回傳一個履行值或拒絕理由,因此若有多個值想要回傳的時候,就必須包裹成一個物件或陣列,接著在稍後的 callback 再一一解鎖,這顯得繁瑣又彆扭,因此提出兩個解法-拆成多個 promise 或解構(參數)。
如下,foo 會回傳兩個數字 x 與 y,必須包裹成物件或陣列後回傳,並在之後的 callback 所傳入的 msg 解開為 msgs[0]
與 msgs[1]
。
function getY(x) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve((3 * x) - 1);
}, 100);
});
}
function foo(bar, baz) {
var x = bar * baz;
return getY(x)
.then(function(y) {
// 包裹成物件或陣列再回傳
return [x, y];
});
}
foo(10, 20)
.then(function(msgs) {
// 解鎖
var x = msgs[0];
var y = msgs[1];
console.log(x, y); // 200 599
});
這可能代表兩個值是都必須要經過運算(等待)才能取得的,那麼就拆成兩個 promise,也比較符合實際狀況、簡化程式碼。
function getY(x) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve((3 * x) - 1);
}, 100);
});
}
function foo(bar, baz) {
var x = bar * baz;
// 回傳兩個 promise
return [
Promise.resolve(x),
getY(x)
];
}
Promise.all(foo(10, 20))
.then(function(msgs) {
var x = msgs[0];
var y = msgs[1];
console.log(x, y);
});
雖然稍微順暢了語意和簡化了程式碼的邏輯,但最後還是必須在之後的 callback 所傳入的 msg 解開為 msgs[0]
與 msgs[1]
。
若仍是包裹成一個物件或陣列回傳,ES6 提供解構的功能,讓我們能輕鬆指定要取得的參數值。
function getY(x) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve((3 * x) - 1);
}, 100);
});
}
function foo(bar, baz) {
var x = bar * baz;
// 回傳兩個 promise
return [
Promise.resolve(x),
getY(x)
];
}
Promise.all(foo(10, 20))
.then(function ([x, y]) { // 解構
console.log(x, y); // 200 599
});
由於 promise 是不可變的(immutable),一但 promise 被解析完成,指定值為旅行或拒絕之後就是固定了,因此當發出重複的 promise 時,除了第一個之外,其餘的都會被捨棄,因此若希望之後的仍能被接受,就必須為之後建立新的 promise 串鏈。
如下範例所示,點擊按鈕「#mybtn」後會發出一個 request 以取得資料,但由於 promise 的不可變的特性,p promise 已被解析,因此除了第一個得到的履行值之外,其餘的都會被捨棄。
var p = new Promise(function(resolve, reject) {
click('#mybtn', resolve);
});
p.then(function(e) {
var btnID = e.currentTarget.id;
return request(`http://some.url.1/?id=${btnID}`);
})
.then(function(text) {
console.log(text);
});
若希望之後的仍能被接受,就必須為之後建立新的 promise 串鏈。也就是改成,每點擊按鈕一次,就觸發一個新的 promise 串鏈。
click('#mybtn', function(e) {
var btnID = e.currentTarget.id;
request(`http://some.url.1/?id=${btnID}`)
.then(function(text) {
console.log(text);
});
});
promise 是無法被取消的,但可用使用逾時控制來假裝取消承諾。
如下,若三秒內未得到回覆,就判定為逾時並做錯誤處理。
var p = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(42);
}, 5000);
});
function timeoutPromise(delay) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
reject('Timeout!');
}, delay);
});
}
function doSomething() {
console.log('resolve!');
}
function handleError(err) {
console.log(err);
}
Promise.race([
p,
timeoutPromise(3000)
])
.then(
doSomething,
handleError
);
p.then(function() {
// 但在 timeout 的狀況下,仍會執行到這裡...
console.log('但在 timeout 的狀況下,仍會執行到這裡...');
});
得到
Timeout!
但在 timeout 的狀況下,仍會執行到這裡...
由上例可知,雖然 timeoutPromise 比較快速的回覆了,我們「假裝」已取消 promise 而忽略未來可能會回傳的結果,但其實上是沒有取消的,因此當後續 promise 抵達時還是可以捕捉到的,於是進入了後來的 then 的區塊,印出「但在 timeout 的狀況下,仍會執行到這裡...」。
改良如下,我們依然無法真正的取消 promise,但可以判定是否要「徹底的假裝取消」,也就是說,這裡設定一個 flag「OK」,為我們在判別是否為 timeoutPromise 先回覆了,如果是,就將 OK 設定為 false 並丟出錯誤原因,在後續 then 的區塊再徹底忽略 else 那個區塊就好了,我們只要處理 OK 是 true 的情況即可。
var OK = true;
var p = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(42);
}, 5000);
});
function timeoutPromise(delay) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
reject('Timeout!');
}, delay);
});
}
function doSomething() {
console.log('success');
}
function handleError(err) {
console.log(err);
}
Promise.race([
p,
timeoutPromise(3000)
.catch(function(err) {
OK = false;
throw err;
})
])
.then(
doSomething,
handleError
);
p.then(function(){
if (OK) {
// 只會在沒有 timeout 的狀況下執行
console.log('I am OK!');
} else {
console.log('I am not OK!');
}
});
得到
Timeout!
I am not OK!
...
...
雖然解法有點醜但還可以接受!
由於 promise 要做的事情很多,promise 的效能是比 callback 來得差一點點,但權衡之下,如果不是慢得離譜,這種極具信任的解法不是很好嗎?待後續探討如何衡量程式效能。
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到...
Promise.all([ .. ])
、Promise.race([ .. ])
與其他變體。同步發表於部落格。