最近更新: 2023-09-19

學習 ECMAScript/JavaScript 6 - Promise 學習六步

Promise 是 ECMAScript 6 訂出的語言規範,用於延遲運算 (deferring) 的場合。 正式說明太難解釋了,所以我根據這兩年的使用經驗,決定換一種比較白話的講法。 ECMAScript 6 的 Promise 就是一種跳躍語法(goto),但不會執行到它這一行就立即跳躍,而是先上個標記,延至程式區塊結束才跳躍。 Promise 是延遲式跳躍。另一個說法叫非同步工作。 JavaScript 後來實現了 async function,提供更簡單易讀的語法。

雖然現在年輕人可能從來沒用過跳躍(goto) ,但應該不妨礙各位理解跳躍是什麼。 就連 JavaScript 也保留了一部份跳躍語法,那就是 label

本文將按下列六步,逐步說明 Promise 的使用概念與實例:

  1. Promise 建構式必須有一個執行者引數(executor)
  2. Promise 建構式的 executor 可接受兩個引數,分別表示 then 和 catch 兩個跳躍點的名稱。
  3. Promise 標記時會保存指定的狀態內容
  4. then/catch 串接實現非同步工作的同步化,且簡化程式的巢狀結構深度。
  5. Promise.all 可以一次收集多個 promise 的結果。
  6. 用函數包裝 Promise ,提高重用性。

1 執行者引數(executor)

Promise 建構式必須有一個執行者引數(executor)。 顧名思義,此引數內容必須是函數。

executor 可接受兩個引數。 若沒有提供引數的給 executor ,那麼 Promise 只是單純的同步執行 executor 的內容,而不能串接。

當配置一個 Promise 實體時,就會呼叫執行者,同步執行內部的程式區塊。

範例程式1

函數 Hello 將作為執行者引數傳給 Promise 建構式。


<html>
<meta charset="utf-8">
<p>JavaScript ES6 Promise Examples 1</p>
<p>打開瀏覽器 console (主控台) 觀察執行過程。</p>
<script>
function Hello()
{
    console.log('hello promise');
} 
    
// 在配置 promise1 時,就執行 Hello() 了。
let promise1 = new Promise(Hello);

promise1.then(() => { 
    console.log('不會執行這裡');
});

console.log('end');
</script>
</html>

本文範例都是在瀏覽器執行。請打開瀏覽器主控台 (console) 觀察執行過程。

後續範例將不包含 HTML 的部份,只列出 JavaScript 程式碼。

2 executor 的兩個引數

Promise 建構式的 executor 可接受兩個引數,分別表示 thencatch 兩個跳躍點的名稱。 executor 第一個引數是跳到 then 的標記名稱。 executor 第二個引數,則是跳到 catch 的標記名稱。

雖然在使用形式上,它看起來像是要呼叫者傳兩個 callback 引數。但並非如此。這兩個引數只是名稱符號。

標記名稱可自定。 ES6 的慣例是跳到 then 的標記叫 resolve ,跳到 catch 的標記叫 reject 。 jQuery 的慣例則分別叫 donefail

我們可以在執行者的程式區塊的任意位置呼叫跳躍標記。 但 Promise 不會立即跳出程式區塊,而是等到程式區塊執行結束後再跳躍。 所以我才說 Promise 是延遲式跳躍。

如果程式區塊中呼叫了好幾次跳躍標記,實際上只會處理第一個被呼叫的標記,忽略其他。

範例程式2

分別用 will_goto_then 和 will_goto_catch 作為標記名稱。 初學者比容易理解這兩個標記名稱的用途。 但之後會用常見的 resolve/reject 或 done/fail 。


let promise2 = new Promise(function(
        will_goto_then, // 跳到 then 的標記名稱
        will_goto_catch // 跳到 catch 的標記名稱
    )
    { // 同步執行區塊開始
        console.log('promise2 同步工作內容');

        will_goto_catch(); // 等同步執行區塊結束後,跳到 catch
        will_goto_then();  // 等同步執行區塊結束後,跳到 then
        // 此例看似先執行 will_goto_catch() 再執行 will_goto_then()
        // 但其實只處理第一個跳躍標記。故只跳到 catch

        console.log('這行之後的內容會先跑完,最後才跳到 then 或 catch');
    } // 同步執行區塊結束
);

// executor 提供跳到 then 和 catch 的引數,故可用 .then/.catch 撰寫後續工作。
promise2
.then(()=>{
    console.log('then: 從 promise2 的 will_goto_then 跳過來了');
})
.catch(()=>{
    console.log('catch: 從 promise2 的 will_goto_catch 跳過來了');
});

console.log('end');

在瀏覽器主控台看到的輸出順序應該如下列所示。


promise2 同步工作內容
這行之後的內容會先跑完,最後才跳到 then 或 catch
end
catch: 從 promise2 的 will_goto_catch 跳過來了

Promise 先跑完同步執行區塊才跳躍。 所以我們會先看到同步執行區塊的最後一行輸出 end ,最後才是 catch 區塊的輸出內容。

3 標記時會保存指定的狀態內容

Promise 是先標記最後要跳到哪,並保存標記時指定變數的狀態。

保存狀態跳躍是 Promise 和一般跳躍語法的不同之處。它是既想要跑完執行者(executor)的程式區塊,又需要把中途的變數狀態交給下一棒(then/catch)。所以就在程式區塊中,使用跳躍標記將變數狀態保存起來。等程式區塊結束後,就把標記的變數狀態提出來,交給下一棒。

雖然跳躍標記可放在執行區塊中間任意位置,但和 Generator 不同, Promise 不會中途跳出去再跳回來。

範例程式3

程式區塊呼叫 resolve 標記時,將變數 x 作為引數傳入。resolve(x) 會形成一個閉包 (Closure) ,保存變數 x 當時的狀態。


let promise3 = new Promise(function(
        resolve // 跳到 then 的標記. 省略跳到 catch 標記
    )
    { // 同步執行區塊開始
        console.log('promise3 同步工作內容');

        let x = 1;
        console.log('let x = 1');
        resolve(x); // 等同步執行區塊結束後,跳到 then 並帶一個引數 x
        // resolve(x) 會形成一個閉包,保存 x 此時的狀態。

        x = 2;
        console.log('let x = 2');
        resolve(x); // 這個 resolve 會被忽略

        console.log('這行之後的內容會先跑完,才跳到 then 或 catch');
    } // 同步執行區塊結束
);

promise3
.then(x =>{
    console.log('從 promise3 的 resolve 跳過來了,x 為 ', x);
});

console.log('end');

在瀏覽器主控台看到的輸出順序應該如下列所示。


promise3 同步工作內容
let x = 1
let x = 2
這行之後的內容會先跑完,才跳到 then 或 catch
end
從 promise3 的 resolve 跳過來了,x 為  1

雖然 executor 最後指派 x 之值為 2 ,但在呼叫 resolve 時已經保存了 x 當時的狀態 (1),所以 then 區塊顯示 x 值是 1 。

4 串接實現非同步工作的同步化

在實務設計上,很多非同步工作實際上有順序依賴性。 比如說 B 工作需要 A 工作的結果才可以執行,也就是 B 要跟著 A 的腳步走,此即同步化。 在沒有用 Promise 甚至沒有用到串接能力之前,這類工作會隨著順序串接而形成很深的巢狀結構。但 then/catch 可以改善這件事。

只要 then/catch 區塊的程式繼續回傳 Promise 個體,就可以繼續接其他 then/catch 。 then/catch 串接實現非同步行為的同步化(順序依賴),且簡化程式的巢狀結構深度。

範例程式4

先示範沒有串接之前的四個非同步工作依序執行的寫法。 接著用 return promise 的方式改寫,就可以串接起來。


// 多個有順序依賴性的 promise 工作,可以這樣寫。
// 但工作愈多,巢狀愈深,結構不好看。
new Promise(done => {
    console.log('promise4a 同步工作內容');
    done('A');
})
.then(v => {
    console.log('end promise4a.');

    new Promise(done => {
        console.log('promise4b 同步工作內容', v);
        done(v + 'B');
    })
    .then(v => {
        console.log('end promise4b.', v);

        new Promise(done => {
            done();
        })
        .then(_ => {
            new Promise(done => {
                done();
            })
            .then(_ => {
                console.log('last');
            });
        });
    });
});

// 改成 return 下一個 promise ,就可以簡化巢狀結構,更好串接。
(new Promise(done => {
    console.log('promise4c 同步工作內容');
    done('C');
}))
.then(v => {
    console.log('end promise4c.');

    return new Promise(done => {
        console.log('promise4d 同步工作內容', v);
        done(v + 'D')
    });
})
.then(v => {
    console.log('end promise4d.', v);

    return new Promise(done => {
        done();
    });
})
.then(_ => {
    return new Promise(done => {
        done();
    });
})
.then(_ => {
    console.log('last');
});

console.log('end');

5 Promise.all

Promise.all() 可以一次收集多個 promise 的結果。

Promise.all() 的引數是要同時處理的 promise 陣列。 而 Promise.all().then() 將會帶一個陣列引數,其內容是各 promise 回傳的結果。

當所有 promise 都選擇跳到 then ,Promise.all() 就會收集那些 promise 回傳的結果,並跳到 then 。

若其中一個 promise 選擇跳到 catch ,則 Promise.all() 跳到 catch 。

適用於要求全部成功才繼續,其中一個失敗就取消的情境。

範例程式5

同時發起3個非同步工作,然後收集3個工作結果決定要跳到哪個區塊。 就是 map/reduce 概念。


Promise.all(
    [
        new Promise(done => {
            console.log('promise5a 同步工作內容');
            done('A');
        }),
        new Promise(done => {
            console.log('promise5b 同步工作內容');
            done('B');
        }),
        new Promise(done => {
            console.log('promise5c 同步工作內容');
            done('C');
        }),
    ]
)
.then(results => {
    console.log('all done. values: ', results);
})
.catch(_ => {
    console.log('one of promise is failure.')
});

console.log('end');

6 用函數包裝 Promise

可以用函數包裝 Promise ,提高重用性。 寫法很簡單,定義一個函數,此函數回傳一個 Promise 實體即可。 當然函數內還可以做一些其他的事。

Web 前端設計領域最常見的包裝對象就是 XMLHttpRequest 和 setInterval 。 這兩個範例我寫在這篇:ECMAScript/JavaScript 6 - Promise 實際範例

範例程式6-1

傳入一個同步函數,根據此函數回傳結果決定跳躍標記。


// 跑一個 同步函數 job
function MyPromise1(job)
{
    console.log('其他工作1');
    return new Promise((resolve, reject) => {
        // 這裡用的標記名稱分別是 resolve 和 reject
        if (job()) {
            resolve();
        }
        else {
            reject();
        }
    });
}

MyPromise1(_=>true)
.then(_=>{
    console.log('my promise1 then');
})
.catch(_=>{
    console.log('my promise1 catch');
})

範例程式6-2

包裝 XMLHttpRequest 。這是最實用的範例了。

XMLHttpRequest 本身就是非同步工作實體,它的執行結果要用事件觸發形式取得。 所以 Promise 的跳躍標記就要放在 XMLHttpRequest 的 load 和 error 事件處理方法之中。


// 包裝一個非同步工作,例如 XMLHttpRequest
function MyPromise2(url)
{
    let promise = new Promise((done, fail) => {
        // 這裡用的標記名稱分別是 done 和 fail
        let xhr = new XMLHttpRequest();

        xhr.addEventListener('load', _ => {
            if (Number(xhr.status) >= 400) {
                // 拿不到資料,跳到 catch
                fail(xhr);
            }
            else {
                // 成功拿到資料才跳到 then
                done(xhr.responseText);
            }
        });

        xhr.addEventListener('error', _ => {
            // 網址錯誤或禁止使用,跳到 catch
            console.log("request error");
            fail(xhr);
        });

        xhr.open('GET', url);
        xhr.send();
    });
    return promise;
}

MyPromise2('promise1.html')
.then(response => {
    console.log('my promise2 get', response.substring(0, 40));
})
.catch(request => {
    console.log('my promise2 catch', request.status);
})

Promise.all([MyPromise2('promise2.html'), MyPromise2('promise3.html')])
.then(results => {
    console.log('全部都成功', results.length);
})
.catch(_ => {
    console.log('其中一個失敗了');
});

Promise 的規範說明
JavaScript 6 系列文章