這個章節我們要來探討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模組提供的statmkdircopyFileunlink函數都是非同步的檔案處理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');
});

以上程式會輸出:

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參數是否不是undefinednullfalse0NaN,如果不是的話表示stat函數在處理過程中有錯誤發生,此時將err作為參數來呼叫reject函數,便可以將這個「錯誤原因」給「回傳」出來。reject函數(和resolve函數)本身並沒有回傳值(void),所以為了不讓stat的回呼函數在呼叫reject函數後繼續執行下去,程式第10行的部份還用了return關鍵字來直接讓回呼函數回傳undefined

如果stat函數的執行過程正常,就用resolve函數把stats參數所儲存的結果給「回傳」出來。

也就是說,如果stat函數的執行過程正常,promise常數的值就是stats參數的值嗎?

當然不是!這邊的promise常數的值在JavaScript進入事件循環之前就決定好了,它就是一個Promise物件實體。

所以我們究竟要怎麼取得resolve函數或是reject函數「回傳」出來的值?這就要靠Promise物件實體提供的thencatch方法啦!

如下:

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方法第一個參數的函數。promisecatch方法,可以讓reject函數被呼叫的時候,去建立一個微任務,這個微任務要執行的函數即為傳入catch方法第一個參數的函數。

這邊要注意的是,thencatch方法都會產生出新的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物件本身就有提供resolveresolve函數,可以用來簡化以上程式的寫法。如下:

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方法若置於thencatch方法前,則會被resolvereject函數觸發,產生微任務,傳入finally方法的函數會被執行。finally方法若置於thencatch方法後,則其函數也會在這些方法被觸發之後跟著被觸發。

例如:

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);
});

以上程式會輸出:

[ 'one', 'two', 'three' ]

若將p4常數也加進陣列中,則只會輸出:

reject

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」語法擺脫難以閱讀的非同步程式