非同步與 Promise

August 14, 2022

到目前為止介紹過的範例都是單一流程,也就是執行 .js 從開始至結束只有一個主流程;JavaScript 最初為瀏覽器而生,而使用者在操作瀏覽器時會有各種操作,若想在某操作事件發生時執行對應的程式流程,該怎麼做呢?

使用 setTimeout

從頭至尾只使用一個主流程設計有什麼問題嗎?來看個例子吧!如果要設計一個龜兔賽跑遊戲,賽程長度為10步,每經過一秒,烏龜會前進一步,兔子可能前進兩步或睡覺,那該怎麼設計呢?如果用目前學過的流程語法來說,可能會如下設計:

const INTERVAL = 1000;
const TOTAL_STEP = 10;

let tortoiseStep = 0;
let hareStep = 0;

console.log('龜兔賽跑開始...');
do {
    let time = Date.now();
    while(Date.now() - time < INTERVAL) {}

    tortoiseStep += 1;
    let running = Math.ceil(Math.random() * 10) % 2;
    if(running) {
        hareStep += 2;
    }
    else {
        console.log('兔子睡著了zzzz');
    }

    if(tortoiseStep < TOTAL_STEP) {
        console.log(`烏龜跑了 ${tortoiseStep}  步...`);
    } 
    else {
        console.log('烏龜抵達終點!');
    }
    
    if(hareStep < TOTAL_STEP) {
        console.log(`兔子跑了 ${hareStep}  步...`);
    }
    else {
        console.log('兔子抵達終點!');
    }
} while(tortoiseStep < TOTAL_STEP && hareStep < TOTAL_STEP);

由於程式只有一個主流程,就不得不將計時任務,以及烏龜與兔子的行為混雜在同一流程中撰寫,如果可以分別定義計時任務以及烏龜、兔子的流程,程式邏輯會比較清楚。

setTimeout 函式雖然不是 JavaScript 本身的標準,然而在瀏覽器環境或 Node.js 都支援,可以用來實現計時任務,而烏龜、兔子的行為,可以定義在不同的函式,並在逾時(Timeout)事件發生時呼叫。例如:

const INTERVAL = 1000;
const TOTAL_STEP = 10;

function tortoise(step = 1) {
    if(step < TOTAL_STEP) {
        console.log(`烏龜跑了 ${step} 步...`);        
        setTimeout(tortoise, INTERVAL, step + 1);
    } else {
        console.log('烏龜抵達終點!');
    }
}

function hare(step = 2) {
    if(step < TOTAL_STEP) {
        let running = Math.ceil(Math.random() * 10) % 2;
        if(running) {
            console.log(`兔子跑了 ${step} 步...`);
            setTimeout(hare, INTERVAL, step + 2);
        }
        else {
            console.log('兔子睡著了zzzz');
            setTimeout(hare, INTERVAL, step);
        }
    }
    else {
        console.log('兔子抵達終點!');
    }
}

console.log('龜兔賽跑開始...');
setTimeout(tortoise, INTERVAL);
setTimeout(hare, INTERVAL);

在這個範例中,使用 tortoisehare 函式分別定義了烏龜與兔子的流程,計時任務是由 setTimeout 函式負責,它的第一個參數接受要執行的函式,第二個參數是毫秒指定的時間(若不指定,預設為0毫秒),之後的參數可以是呼叫指定的函式提供之引數;首次執行 tortoisehare 函式的時間是一秒之後,之後若還沒抵達終點,就再次呼叫 setTimeout 函式。執行時的結果會像是…

兔賽跑開始...
烏龜跑了 1 步...
兔子睡著了zzzz
烏龜跑了 2 步...
兔子睡著了zzzz
烏龜跑了 3 步...
兔子跑了 2 步...
烏龜跑了 4 步...
兔子跑了 4 步...
烏龜跑了 5 步...
兔子睡著了zzzz
烏龜跑了 6 步...
兔子睡著了zzzz
烏龜跑了 7 步...
兔子睡著了zzzz
烏龜跑了 8 步...
兔子跑了 6 步...
烏龜跑了 9 步...
兔子跑了 8 步...
烏龜抵達終點!
兔子抵達終點! 

同步?非同步?

電腦科學領域有許多名詞,不過多半沒有嚴謹的定義;然而,既然這邊要討論非同步,總還是得定義一下非同步與同步(Synchronous),以便後續進行討論。

之前的程式碼或範例,都有個明顯的特徵,無論是運算式、陳述句或是函式,都是在定義的任務完成之後,才會往下一個運算式、陳述句或函式執行,這樣的流程稱為同步。例如,若已知 foo 函式的任務為顯示 foo 字樣,若底下的程式碼執行結果,是依序顯示 begin、foo、end,那麼這個程式碼片段就是同步的流程:

console.log('begin');
foo();
console.log('end');

之前定義過的函式,都是這類函式,姑且稱為同步函式,當然,要實作這樣的 foo 函式並不難,例如:

function foo() {
    console.log('foo');
}

如果是另一種 foo 函式實作,會令方才的程式片段顯示 begin、end、foo 呢?也就是 foo 的任務未完成前,就執行了後續的程式流程,那麼這樣的流程就是非同步的,而這類函式就被稱為非同步函式。

先前使用了 setTimeout,可以指定的時間後才執行指定的函式;若拿 setTimeout 來設計底下的 foo 函式,就可以達到非同步的需求。

// 非同步函式
function foo() {
    setTimeout(console.log, 1000, 'foo');
}

// 非同步流程,執行後顯示begin、end、foo
console.log('begin');
foo();
console.log('end');

計時本身就是個獨立於主流程的任務,逾時是個事件,在事件發生時必須執行某個指定操作,像這類獨立於程式主流程的任務、事件生成,以及處理事件的方式,稱為非同步(Asynchronous)。

在瀏覽器的環境時,獨立於主流程的任務可能是下載檔案,下載完成是個事件,有事件發生時必須執行某個指定操作;在 Node.js 的環境中,獨立於主流程的任務可能是寫入檔案,寫入完成是個事件,有事件發生時必須執行某個指定操作;為了滿足諸如此類的需求,非同步根本上就是與 JavaScript 息息相關的議題。

如果你曾經學習過其他具有多執行緒(Multi-thread)特性的語言(例如Java),可能會想到,為什麼不透過多執行緒呢?確實地,在支援多執行緒的語言中,執行緒是用來實現非同步的方式之一;然而,JavaScript 本身不支援多執行緒,而且 JavaScript 引擎是以單執行緒方式執行程式碼。

對於熟悉多執行緒的許多開發者來說,單執行緒可以實現非同步,乍聽之下難以想像,實際上,JavaScript 執行環境會在事件迴圈(Event loop),不斷地檢查事件佇列(Event queue),當事件發生時,並不是馬上執行指定的函式,而是將事件排入佇列,在迴圈下一輪的檢查時,才將佇列中事件對應的任務依序執行完成。

以方才的範例來說,foo 執行時,setTimeout 會令瀏覽器或 Node.js 進行計時,然後 setTimeout 就執行完畢了,這也意謂著 foo 函式執行完畢,因此才會往下一行 console.log('end') 執行,當逾時事件發生時,setTimeout 指定的函式會排入事件佇列,在迴圈下一輪檢查佇列時執行。

簡而言之,JavaScript 是以單執行緒,不斷地在事件迴圈中檢查事件佇列,若佇列中有任務的話就依序執行完成;這就意謂著,JavaScript 沒有多執行緒切換的機制,也就是說,如果某個事件對應的任務執行過久,佇列中後續事件對應的任務就會被卡住,若是在使用者操作瀏覽器的時候發生了這類情況,就會感覺操作畫面被凍結,或者是發生無回應的狀態;這也表示,在支援多執行緒的語言中 sleep 之類的函式或方法,在 JavaScript 中無法實現。

在 HTML5 規範包含 Web Worker API,可以提供多執行緒,然而這個支援是由「瀏覽器」提供,而不是 JavaScript 引擎。

非同步與回呼

對於同步函式,任務執行完之後會傳回結果;然而非同步函式呼叫時,被指定的任務未完成前,函式就會立即返回,這麼一來,該怎麼取得任務執行結果呢?

對於非同步函式想要取得執行結果,方式之一是使用回呼函式。例如,若asyncFoo()函式會在兩秒之後,使用指定的引數乘上隨機產生的數字,若要取得該結果的話,可以如下設計:

function asyncFoo(n, callback) {
    setTimeout(
        () => callback(n * Math.random()), 
        2000
    );    
}

asyncFoo(10, console.log);

這個範例在執行完 setTimeout 後,asyncFoo 函式立即返回,而在兩秒之後完成任務,以結果呼叫了指定的 callback 函式,這種回呼模式直覺而簡單,一些基礎程式庫,像是 Node.js 中想要讀取檔案,可以使用 fs 模組的 readFile 函式,若想取得檔案讀取結果並做進一步處理,就必須指定回呼函式。例如:

let fs = require('fs');
fs.readFile('files.txt', function(_, files) {
    console.log(files.toString());
});

這邊談到的模組,是指 Node.js 本身的模組,並非 JavaScript 規範的模組,Node.js 也支援 JavaScript 模組。

在事情簡單時,回呼模式沒什麼問題,然而,若希望任務完成後,接續地執行下一個非同步任務時,就會發生回呼函式形成巢狀結構的問題。例如:

let fs = require('fs');
fs.readFile('files.txt', function(_, files) {
    files.toString().split('\r\n').forEach(function(file) {
        fs.readFile(file, function(_, content) {
            console.log(content.toString());
        });
    });
});

在 JavaScript 的應用中,非同步地執行任務是常見需求,回呼函式形成巢狀結構的問題可能會變得嚴重,因而引發回呼地獄(Callback hell)的問題,令撰寫程式或後續閱讀程式碼發生困難。

若使用方才的 assyncFoo 為例,想在取得任務結果之後,再次用來呼叫 assyncFoo,連續三次地呼叫之後,就會寫成這樣如下結構:

asyncFoo(10, r1 => {
    asyncFoo(r1, r2 => {
        asyncFoo(r2, r3 => {
            console.log(r3);
        });
    });
});

可以看到的,就算使用了箭號函式,在這種需求之下,會因為巢狀的回呼函式,造成可讀性迅速下降。

回呼模式不是不好,若非同步操作取得結果後,不會有進一步執行非同步任務的需求來說,回呼模式直覺而簡單;然而,若發現需求已經超出了原本預想的範圍,程式碼開始有形成回呼地獄的傾向時,就應該適時重構,以免影響程式的可讀性,重構時可以採取的方向之一是 Promise 模式,而 ES6 以後,提供了 Promise API 來支援此模式。

Promise API

在 ES6 以後,Promise 實例可以作為是非同步函式的傳回值,正如名稱暗示的,Promise 實例代表著「承諾」在未來提供任務執行結果。以方才的範例來說,可以使用 Promise 來實作 asyncFoo 函式,令其傳回 Promise 實例:

function asyncFoo(n) {
    return new Promise(resolve => {
        setTimeout(
            () => resolve(n * Math.random()), 
            2000
        );
    });
}

asyncFoo(10)
    .then(r1 => asyncFoo(r1))
    .then(r2 => asyncFoo(r2))
    .then(r3 => console.log(r3));

Promise 實例建立後,處於未定(Pending)狀態,在建立 Promise 實例時可以指定回呼函式,該函式可以具有參數,第一個參數慣例上常命名為 resolve,這兩個參數會各自接受函式,這邊僅定義了 resolve 參數,若呼叫 resolve 函式,表示 Promise 指定的任務達成(Fulfilled)狀態,呼叫 resolve 時指定的引數,就是任務達成的結果。

Promise 實例具有 then 方法,then 接受的回呼函式可以具有參數,若 Promise 指定的任務達成,那麼呼叫 resolve 時指定的引數,就會是 then 回呼函式的參數值,回呼函式可以傳回任何值,通常會是 Promise 實例或是 thenable 物件(具有 then 方法的物件),若是後兩者,代表下個待達成的任務,then 方法會傳回 Promise,因此可以形成方法鏈進行連續呼叫,各個 then 呼叫的回呼函式中,可以取得任務達成結果。

就程式碼閱讀來說,Promise 的撰寫風格就像是循序的,也避免了回呼地獄的問題,不過順序的保證,僅限於 then 指定的任務,上面的範例若在最後一行加上了 console.log('end')


asyncFoo(10)
    .then(r1 => asyncFoo(r1))
    .then(r2 => asyncFoo(r2))
    .then(r3 => console.log(r3));
console.log('end');

那麼會先顯示end字樣,後續才會顯示非同步任務的結果,如果想要顯示非同步任務的結果,最後才顯示 end 字樣,方式之一是再寫個 then


asyncFoo(10)
    .then(r1 => asyncFoo(r1))
    .then(r2 => asyncFoo(r2))
    .then(r3 => console.log(r3))
    .then(_ => console.log('end'));

Promisethen 方法,也可以直接接受 Promise 實例,若不在乎前一個 Promise 的結果,只需要在前一個 Promise 完成後執行時使用。例如:

asyncFoo(10)
    .then(asyncFoo(20));

在建立 Promise 實例時指定的回呼函式,可以定義第二個參數,慣例上常命名為 reject,如果指定的任務無法達成,可以使用傳入的 reject 函式來否決任務,此時 Promise 就會處於否決(Rejected)狀態。例如:

function randomDivided(divisor) {
    return new Promise((resolve, reject) => {
        let n = Math.floor(Math.random() * 10);
        if(n !== 0) {
            resolve(divisor / n);
        } else {
            reject('Shit happens: divided by zero');
        }
    });
}

randomDivided(10)
    .then(
        n   => console.log(n),
        err => console.log(err)
    );

在上例中,若 n 不為 0 任務就會達成,若為 0 就會否決任務,若有定義 then 的第二個回呼函式,該函式就會被呼叫,reject 接受的引數,可以成為 then 第二個回呼函式的引數,在這邊 reject 時指定了字串值。

本質上,Promise 被否決時,表示任務因為某個錯誤而無法執行下去,如果想要突顯錯誤處理的流程,建議使用 catch 方法。例如:

randomDivided(10)
    .then(n   => console.log(n))
    .catch(err   => console.log(err));

銜接 Promise

Promise 建構式本身擁有幾個特性,也就是以 Promise 為名稱空間的函式(不是定義在 Promise.prototype 上的特性),主要目的是作為 API 的銜接。例如,方才的範例,除了直接以 new 建構來銜接 Promise API,也可以這麼撰寫:

function randomDivided(divisor) {
    let n = Math.floor(Math.random() * 10);
    if(n !== 0) {
        return Promise.resolve(divisor / n);
    } else {
        return Promise.reject('Shit happens: divided by zero');
    }
}

randomDivided(10)
    .then(
        n   => console.log(n),
        err => console.log(err)
    );

Promise.resolve 可以接受任何值,包括 Promise 實例或是 thenable 物件,這些物件任務達成的值,會成為 Promise.resolve 傳回 Promise 實例的達成值;Promise.reject 也可以接受任何值。

如果手邊有多個 Promise 實例,而且不在意達成的順序,只要達成的結果,是依照指定的 Promise 順序排列就可以的話,可以使用 Promise.all,它接受 Promise 組成的可迭代物件,並傳回 Promise 實例,例如:

Promise.all([randomDivided(10), randomDivided(20), randomDivided(30)])
       .then(
           results => console.log(results[0], results[1], results[2]),
           err => console.log(err)
       );

Promise.all 就像是將一組 Promise 組合為一個 Promise,就上例來說,如果三個 Promise 的任務都達成了,那麼 results 就會是三個任務的結果陣列,元素是依照 Promise.all 最初指定的 Promise 順序;然而,如果有任何一個 Promise 被否決了,無論是否還有其他 Promise 的任務在執行,都會直接呼叫 then 指定的第二個回呼函式,未達成的 Promise 還是會繼續執行,也無法取得其他有達成任務的 Promise 之結果。

Promise.race 函式則接受 Promise 組成的可迭代物件,這組 Promise 任一個先達成或否決的話,就會當成 Promise.race 傳回的 Promise 達成或否決的結果,不過其他 Promise 還是會繼續執行就是了。一個使用 Promise.race 的例子是:

Promise.race([randomDivided(10), randomDivided(20), randomDivided(30)])
       .then(
           result => console.log(result),
           err => console.log(err)
       );

那麼,如果想指定一組 Promise,並且在全部的 Promise 達成或否決之後,再來判斷各個 Promise 的狀態,該怎麼做呢?ES11 以後,有個 Promise.allSettled 函式可以使用:

function randomDivided(divisor) {
    let n = Math.floor(Math.random() * 10);
    if(n !== 0) {
        return Promise.resolve(divisor / n);
    } else {
        return Promise.reject('Shit happens: divided by zero');
    }
}

Promise.allSettled([randomDivided(10), randomDivided(20), randomDivided(30)])
    .then(statusObjs => statusObjs.filter(obj => obj.status === 'fulfilled'))
    .then(results => results.map(result => result.value))
    .then(console.log);

Promise.allSettled 會個別地呼叫 Promisethen 方法,各個 Promise 若達成,就傳回 status 特性為 'fufilled' 的物件,若否決傳回 status 特性為 'rejected' 的物件,這些 PromisePromise.all 收集起來,Promise.all 傳回的 Promise,就可以取得具有 status 特性的物件陣列,接下來就看是對達成的 Promise 或是被否決的 Promise 感興趣,這可以針對物件的 status 特性來進行判斷。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter