這個章節我們要來探討JavaScript在ES6之後新增的Promise
。究竟它是如何解決以往用回呼函數來開發JavaScript程式所造成的問題呢?
先來回顧一下上一章用回呼函數的方式所撰寫的檔案處理程式,如下:
import fs = require("fs");
import path = require("path");
const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";
fs.stat(INPUT_PATH, (err, stats) => {
if (err) {
console.error(err);
return;
}
console.log(stats);
fs.mkdir(OUTPUT_FOLDER, { recursive: true }, (err) => {
if (err) {
console.error(err);
return;
}
const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);
fs.copyFile(INPUT_PATH, outputPath, (err) => {
if (err) {
console.error(err);
return;
}
fs.unlink(INPUT_PATH, (err) => {
if (err) {
console.error(err);
}
console.log("Done!");
});
});
});
});
由於Node.js的fs
模組提供的stat
、mkdir
、copyFile
和unlink
函數都是非同步的檔案處理API,因此我們需要在回呼函數中確認API的執行結果,且同樣要在回呼函數中再去呼叫下一個檔案處理的API。寫到後來我們的程式敘述區塊就會愈寫愈多層、愈來愈難看,也愈來愈難維護。
Promise
為了解決上述的回呼函數程式碼寫到後來會變得難以撰寫、也難以閱讀的問題,在ES6之後,添加了Promise
物件。這個Promise
物件可以用來將非同步的API改成「看起來同步」的用法。
Promise的基本用法
Promise
物件的最基本用法如下:
const promise = new Promise((resolve, reject) => {
...
});
利用new
關鍵字可以產生Promise
的物件實體。建構子的部份可以傳入一個回呼函數作為參數,這個函數有兩個參數,第一個為resolve
,第二個為reject
。當Promise
物件被建立的時候,就會去同步執行這個回呼函數。舉例來說:
process.nextTick(() => {
console.log('nextTick');
});
const promise = new Promise((resolve, reject) => {
console.log('promise');
});
以上程式會輸出:
nextTick
resolve
參數其實是一個函數,這個函數可以傳入一個參數,作為回傳的「正確值」。reject
參數也是一個函數,這個函數可以傳入一個參數,作為回傳的「錯誤值」,或者稱「錯誤原因」。舉例來說:
import fs = require("fs");
const INPUT_PATH = "input";
const promise = new Promise((resolve, reject) => {
fs.stat(INPUT_PATH, (err, stats) => {
if (err) {
return reject(err);
}
resolve(stats);
});
});
以上程式,在JavaScript執行緒進入事件循環之前,就會先去呼叫傳入Promise
建構子的回呼函數,在這個函數中,我們去呼叫了Node.js的fs
模組提供的stat
函數,這個是會非同步執行的函數,所以我們還要在它的第二個參數傳給它一個回呼函數,作為stat
「執行完畢」的事件觸發後要執行的函數。一旦stat
「執行完畢」的事件被觸發,這個事件就會被儲存在事件循環第二階段的佇列中。
JavaScript執行緒執行到事件循環的第二階段時,就會去執行傳入stat
函數的回呼函數。一開始會先去判斷回呼函數的err
參數是否不是undefined
、null
、false
、0
或NaN
,如果不是的話表示stat
函數在處理過程中有錯誤發生,此時將err
作為參數來呼叫reject
函數,便可以將這個「錯誤原因」給「回傳」出來。reject
函數(和resolve
函數)本身並沒有回傳值(void
),所以為了不讓stat
的回呼函數在呼叫reject
函數後繼續執行下去,程式第10行的部份還用了return
關鍵字來直接讓回呼函數回傳undefined
。
如果stat
函數的執行過程正常,就用resolve
函數把stats
參數所儲存的結果給「回傳」出來。
也就是說,如果stat
函數的執行過程正常,promise
常數的值就是stats
參數的值嗎?
當然不是!這邊的promise
常數的值在JavaScript進入事件循環之前就決定好了,它就是一個Promise
物件實體。
所以我們究竟要怎麼取得resolve
函數或是reject
函數「回傳」出來的值?這就要靠Promise
物件實體提供的then
和catch
方法啦!
如下:
import fs = require("fs");
const INPUT_PATH = "input";
const promise = new Promise((resolve, reject) => {
fs.stat(INPUT_PATH, (err, stats) => {
if (err) {
return reject(err);
}
resolve(stats);
});
});
promise
.then((stats) => {
console.log(stats);
})
.catch((err) => {
console.error(err);
});
以上程式,利用Promise
物件實體(promise
)的then
方法,可以讓resolve
函數被呼叫的時候,去建立一個微任務,這個微任務要執行的函數即為傳入then
方法第一個參數的函數。promise
的catch
方法,可以讓reject
函數被呼叫的時候,去建立一個微任務,這個微任務要執行的函數即為傳入catch
方法第一個參數的函數。
這邊要注意的是,then
和catch
方法都會產生出新的Promise
物件實體,所以如果要將它們分開呼叫的話,要記得用一個常數或變數來儲存回傳值。例如:
const p1 = promise.then((stats) => {
console.log(stats);
});
const p2 = p1.catch((err) => {
console.error(err);
});
不要寫成以下這樣:
promise.then((stats) => {
console.log(stats);
});
promise.catch((err) => {
console.error(err);
});
then
方法可以被呼叫多次,且可以被置於catch
方法之後。第一個的then
方法如果是以resolve
函數來觸發的話,其回呼函數的第一個參數即為傳入resolve
函數作為第一個參數的值。但如果是reject
函數被執行,且catch
方法之後有使用then
方法,則這個then
方法也會被觸發,其回呼函數第一個參數的值此時會為catch
方法的回呼函數的回傳值。
例如:
promise
.catch((err) => {
console.error(err);
return err;
})
.then((statsOrErr) => {
console.log(statsOrErr);
});
以上程式,then
方法的回呼函數的第一個參數的值可能是promise
回傳的「正確值」或是「錯誤值」。
同樣地,如果then
方法後又有then
方法,則後者的回呼函數的第一個參數的值,為前者的回呼函數所回傳的值。
例如:
promise
.then((stats) => {
console.log(stats);
return true;
})
.catch((err) => {
console.error(err);
return false;
})
.then((status) => {
console.log(status);
});
以上程式,最後一個then
方法的回呼函數有可能會在主控台輸出true
(如果promise
是回傳正確值)或是false
(如果promise
是回傳錯誤值)。在then
方法或是catch
方法之後觸發的then
方法,會產生新的微任務。
then
方法其實也可以傳入兩個回呼函數,第一個就是原本的回呼函數,第二個是用來傳入原本要傳入catch
方法的回呼函數,也就是說這時就不需要用catch
方法了。例如:
promise
.then((stats) => {
console.log(stats);
return true;
}, (err) => {
console.error(err);
return false;
})
.then((status) => {
console.log(status);
});
以上寫法和原本使用catch
方法的寫法的邏輯並不一樣,只不過目前還體現不出來,稍候會再提到。
若then
方法和catch
方法的回呼函數是回傳Promise
物件實體,則它們之後的then
方法的回呼函數的第一個參數即為該Promise
物件實體的「正確值」。在這種情況下,也可以加入更多catch
方法來接收Promise
物件實體的「錯誤值」。當然,也還是可以只使用一個catch
方法來處理這些Promise
物件實體的「錯誤值」,只要將該catch
方法放在所有的then
方法後面來呼叫即可。
例如:
promise
.then((stats) => {
console.log(stats);
return new Promise((resolve) => {
resolve(true);
});
})
.catch((err) => {
console.error(err);
return new Promise((resolve, reject) => {
reject(false);
});
})
.then((status) => {
console.log(status);
})
.catch((status) => {
console.error(status);
});
以上程式,若promise
是回傳「正確值」,第一個then
方法的回呼函數就會被執行,它會回傳一個新的Promise
物件實體,這個Promise
物件實體只會回傳一個true
作為「正確值」,此時第一個then
方法之後的then
方法和catch
方法就等同於是第一個then
方法回傳的Promise
物件實體的then
方法和catch
方法。
當然,若以上程式的promise
是回傳「錯誤值」,第一個catch
方法的回呼函數就會被執行,它會回傳一個新的Promise
物件實體,這個Promise
物件實體只會回傳一個false
作為「錯誤值」,此時第一個catch
方法之後的then
方法和catch
方法就等同於是第一個catch
方法回傳的Promise
物件實體的then
方法和catch
方法。
所以,以上程式,有可能會在主控台輸出true
(如果promise
是回傳正確值)或是false
(如果promise
是回傳錯誤值)。
事實上,Promise
物件本身就有提供resolve
和resolve
函數,可以用來簡化以上程式的寫法。如下:
promise
.then((stats) => {
console.log(stats);
return Promise.resolve(true);
})
.catch((err) => {
console.error(err);
return Promise.reject(false);
})
.then((status) => {
console.log(status);
})
.catch((status) => {
console.error(status);
});
這邊要注意的是,如果我們將以上程式的resolve
函數改成reject
,第一個catch
方法的回呼函數就會被執行。如果我們想確保第一個then
方法的回呼函數所回傳的Promise
物件實體不會被其之後原本是要接收promise
「錯誤值」的第一個catch
方法影響的話,這時就要使用上面提到的方式,將catch
方法拿掉,把其回呼函數傳進then
方法的第二個參數中。如下:
promise
.then((stats) => {
console.log(stats);
return new Promise((resolve, reject) => {
// simulate a correct or erroneous result
if (Math.floor(Math.random() * 2)) {
resolve(true);
} else {
reject(null);
}
});
}, (err) => {
console.error(err);
return Promise.reject(false);
})
.then((status) => {
console.log(status);
})
.catch((status) => {
console.error(status);
});
Promise
物件實體的finally
方法可以被呼叫多次,通常會置於then
方法和catch
方法之後使用。finally
方法的第一個參數需要傳入一個無參數的函數,這個函數的回傳值不會影響到其它方法的回呼函數。finally
方法若置於then
和catch
方法前,則會被resolve
或reject
函數觸發,產生微任務,傳入finally
方法的函數會被執行。finally
方法若置於then
和catch
方法後,則其函數也會在這些方法被觸發之後跟著被觸發。
例如:
promise
.then((stats) => {
console.log(stats);
})
.catch((err) => {
console.error(err);
})
.finally(() => {
console.log("Done!");
});
依序執行多個Promise
物件實體
以我們剛才寫的「取得一個檔案的資訊,並創立一個目錄,將這個檔案複製進新的目錄下,然後再把原本的檔案刪除」程式來舉例,可用Promise
物件改寫如下:
import fs = require("fs");
import path = require("path");
const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";
const pStat = () => new Promise((resolve, reject) => {
fs.stat(INPUT_PATH, (err, stats) => {
if (err) {
return reject(err);
}
resolve(stats);
});
});
const pMkdir = () => new Promise((resolve, reject) => {
fs.mkdir(OUTPUT_FOLDER, { recursive: true }, (err) => {
if (err) {
return reject(err);
}
resolve();
});
});
const pCopyFile = () => new Promise((resolve, reject) => {
const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);
fs.copyFile(INPUT_PATH, outputPath, (err) => {
if (err) {
return reject(err);
}
resolve();
});
});
const pUnlink = () => new Promise((resolve, reject) => {
fs.unlink(INPUT_PATH, (err) => {
if (err) {
return reject(err);
}
resolve();
});
});
pStat()
.then((stats) => {
console.log(stats);
return pMkdir();
})
.then(() => {
return pCopyFile();
})
.then(() => {
return pUnlink();
})
.then(() => {
console.log("Done!");
})
.catch((err) => {
console.error(err);
});
雖然以上程式寫起來還是挺麻煩的,但看起來比較像是同步的程式,變得更容易閱讀了!
一次執行多個Promise
物件實體並同步取得結果
利用Promise
物件自身提供的all
方法,可以傳入一個Promise
物件實體陣列作為參數,將它們組合成一個Promise
物件實體。這些陣列中的Promise
物件實體會被非同步執行,它們回傳的「正確值」會被存入一個陣列中的相應位置,最終當它們全都執行完時可以透過then
方法傳入回呼函數來取得這些「正確值」的陣列。當這些Promise
物件實體中有一個回傳「錯誤值」時,可以透過catch
方法傳入回呼函數來取得這個「錯誤值」。
舉例來說:
const p1 = new Promise((resolve) => {
setTimeout(() => {
resolve("one");
}, 1000);
});
const p2 = new Promise((resolve) => {
setTimeout(() => {
resolve("two");
}, 2000);
});
const p3 = new Promise((resolve) => {
setTimeout(() => {
resolve("three");
}, 3000);
});
const p4 = new Promise((resolve, reject) => {
reject("reject");
});
Promise.all([p1, p2, p3]).then((values) => {
console.log(values);
}, (err) => {
console.log(err);
});
以上程式會輸出:
若將p4
常數也加進陣列中,則只會輸出:
Promise
物件實體的型別
Promise
有一個泛型參數,其所代表的型別即為傳入其resolve
函數的值的型別。
舉例來說:
const promise = new Promise((resolve) => {
resolve("Hello");
});
以上這個Promise
物件實體的型別因為沒有明確指定泛型參數所代表的型別,所以會被TypeScript編譯器當成Promise<unknown>
。換句話說,我們必須在Promise
物件實體的then
方法的回呼函數中使用as
關鍵字來將第一個參數強制轉型,才能「正常」使用這個Promise
物件實體回傳的「正確值」。
例如:
const promise = new Promise((resolve) => {
resolve("Hello");
});
promise.then((value) => {
const s = value as string;
console.log(s.length);
});
為了更有效地利用TypeScript的型別檢查機制,我們最好在每次建立Promise
的物件實體時都去指定其泛型參數代表的型別。如下:
const promise = new Promise<string>((resolve) => {
resolve("Hello");
});
以上這個Promise
物件實體的型別為Promise<string>
。如此一來,在使用then
方法的時候,回呼函數的第一個參數就是string
型別了。如下:
const promise = new Promise<string>((resolve) => {
resolve("Hello");
});
promise.then((value) => {
console.log(value.length);
const n: number = value; // compilation error
});
下一個章節要來繼續介紹JavaScript的「async / await」語法。
下一章:用「async / await」語法擺脫難以閱讀的非同步程式。