file

在網頁世界裡面一直存在著三個超古老的內建對話方塊:alertconfirmprompt。這三個打從二十幾年前我剛上國中的時候就已經存在了,一直到現在也都還在。但是當然,原則上專業寫網頁的人一定不會去用這三個東西,一方面因為它們的外觀完全沒有自訂空間、無法融入網頁的設計,另外一方面則因為它們都是同步方法,一旦呼叫了這些對話方塊,在使用者做出回應之前網頁上的 JavaScript 是完全停擺的,這兩點都是對使用者體驗有負面影響的特質。因此,當我們真的有需要使用類似用意的對話方塊的時候,通常都會選擇另外用 UI 框架中提供的對話方塊元件去做,例如 BPS 採用的 Bootstrap 框架中的 Modal 元件就是一種對話方塊。

不過,BS Modal 背後的思維跟原生的 alert 等等是非常不一樣的,所以如果我們想要做的事情是用 BS Modal 來取代原生對話方塊的功能,那倒也需要下一些非同步程式碼的功夫才能夠達到類似的效果。本篇中我就來談 BPS 關於這一部分採用的方法。

基本架構

BPS 中有兩種通用的對話方塊,分別對應於 alertconfirm 的作用,差別只在於前者傳回空值、而後者會傳回 boolean 值(代表使用者是否同意)。因為兩者的寫法大同小異(而確實,在 BPS 中它們都繼承自相同的基底類別,共用約八成的程式碼),所以我底下用 confirm 當例子解說就好。

雖然實際上 BPS 是用 Vue 元件的方式在撰寫對話方塊,不過為了避免失焦,底下都改成用 vanilla 的方式來撰寫。剛好現在 BS 5 也捨棄了 jQuery 而採用 vanilla 語法,這樣正好。

對話方塊的 HTML 語法部份並沒有太大的玄機:

<div id="cfmModal" class="modal fade">
    <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content">
            <div id="cfmBody" class="modal-body"></div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary"
                        data-bs-dismiss="modal">否</button>
                <button type="button" class="btn btn-primary"
                        data-bs-dismiss="modal" onclick="responseYes()">是</button>
            </div>
        </div>
    </div>
</div>

從這邊我們可以看得出來,兩個按鈕按下去都一樣會把對話方塊關掉,只是差在按下「是」的時候會多呼叫一個 responseYes() 函數,這個等一下會講。

非同步顯示對話方塊

我們希望寫出一個這樣的函數(以 TypeScript 來說):

function confirm(message: string): Promise<boolean>;

如此一來,其它的程式碼在使用的它的時候,就可以在 async 函數裡面 await 它的執行結果,再看看要如何繼續執行;相較於 callback 的作法,這樣寫能大幅提昇程式碼的品質,我想時至今日這部份應該是可以不用再多打廣告了的。

基於教學上的用意,我這邊先給出一個比較陽春的版本,待會再來改進。首先我們有一些前置作業是一開始先做好的:

// 取得 modal 的 HTML 元素
let el = document.getElementById("cfmModal");

// 建立一個 BS Modal 的實體
let modal = new bootstrap.Modal(el, {
    // 這個選項是說使用者不能按陰影區來關閉對話方塊,
    // 一定要跟對話方塊互動才行
    backdrop: 'static'
});

然後我們就可以來寫我們的函數了:

// 這個變數代表著使用者的回答
let userResponse: boolean;

function confirm(message: string): Promise<boolean> {
    // 把訊息填入到 modal-body 裡面
    document.getElementById("cfmBody").innerText = message;

    // 初始化
    userResponse = false;

    // 建立 Promise 物件
    let promise = new Promise(resolve => {
        // 監聽「Modal 消失完畢」事件
        el.addEventListener('hidden.bs.modal',
            // 消失完畢就將使用者回應傳回
            () => resolve(userResponse),
            // 設定為單次監聽
            { once: true }
        );
    });

    // 正式顯示對話方塊,並傳回建立好的 Promise
    modal.show();
    return promise;
}

// 然後還記得剛才的這個函數吧?這會在消失完畢之前先被執行,
// 因此上面 Promise resolve 的時候會傳回正確的值
function responseYes(): void {
    userResponse = true;
}

非同步排隊

這個陽春版如果單次呼叫還不是問題,但是如果一連多次呼叫,那就會打架了。或問:什麼樣的情境會連續呼叫 confirm?其中一個 BPS 裡面的應用情境是這樣的:想像使用者按下「全部關閉」把所有開啟的編輯器頁籤都關掉,其中有一些檔案還沒有存檔。這個時候 BPS 會逐一關掉頁籤、但是如果遇到還沒有存檔的頁籤,它就會詢問使用者是否確定要放棄存檔並關閉——重點來了。在對話方塊出現的同時,為了避免浪費時間,BPS 會繼續在背後把其它頁籤關掉,此時它可能會在前一個對話方塊還沒關閉之前、就又遇到了下一個尚未存檔的頁籤,於是就會出現接連呼叫 confirm 的情況。

在遇到這種情況的時候,我們希望的流程會是「等到前一個對話方塊消失之後、再跳出下一個佇列中的對話方塊請求,依此類推」。但是這邊我們並不需要使用佇列資料結構,而是多用一個變數來儲存「最後一個對話方塊的 Promise」即可:

let userResponse: boolean;

// 這邊我們將它宣告成 Promise<unknown> 型態,以便儲存任何的 Promise;
// 一開始我們將它初始化成 Promise.resolve(),亦即已完成
let lastModal: Promise<unknown> = Promise.resolve();

function confirm(message: string): Promise<boolean> {
    // 這邊的順序很重要!要先建立 Promise 物件!
    let promise = new Promise(resolve => {
        // 然後在 Promise 的內部去等候 lastModal 做完
        lastModal.then(() => {
            // 裡面才是我們前一個版本當中做的那些事情
            document.getElementById("cfmBody").innerText = message;
            userResponse = false;
            el.addEventListener('hidden.bs.modal',
                () => resolve(userResponse),
                { once: true }
            );
            modal.show();
        };
    });

    // 然後把 lastModal 取代成新建立出來的 Promise,
    // 如此一來再下一次的 confirm 呼叫便會等候當前的 Promise 結束
    lastModal = promise;

    // 最後一樣傳回
    return promise;
}

調用的程式碼

有了上述架構之後,例如像前面提到的「關閉全部頁籤」的程式碼其實也是有其講究之處,否則會沒有充分發揮到非同步程式碼的效能。為了解說起見,我們先想像有一個這樣的頁籤介面存在:

interface Tab {
    readonly name: string; // 頁籤的檔案名稱
    readonly dirty: boolean; // 是否尚未存檔
    close(): void; // 關閉的方法
}

然後我們先一樣看一個陽春版本的程式碼:

// 假設這裡面有所有開啟中的頁籤
let tabs: Tab[];

async function closeAllTabs(): Promise<void> {
    for(let tab in tabs) {
        if(!tab.dirty || await confirm(`確定要關閉 ${tab.name}?`)) {
            tab.close();
        }
    }
}

如果各位有在用 ESLint 而且有開啟規則 no-await-in-loop,那麼此時應該就會看到它跟你抗議說你不應該在迴圈裡面用 await 的;而這樣的抗議確實是有它的道理在。確實,上面的程式碼並沒有達到最好的效能,因為一旦它遇到了一個 dirty 的頁籤,它就會卡在 await 上頭等待使用者回應,而不會繼續在背景中關閉之後的頁籤,從而我們前一節做的努力就等於白做了。比較好的作法是這樣才對:

// 方便起見,寫一個非同步輔助函數來關閉單一頁籤
async function closeTab(tab: Tab): Promise<void> {
    if(!tab.dirty || await confirm(`確定要關閉 ${tab.name}?`)) {
        tab.close();
    }
}

async function closeAllTabs(): Promise<void> {
    // 一口氣把所有的頁籤轉換成對應的 Promise
    let tasks: Promise<void>[] = tabs.map(tab => closeTab(tab));

    // 然後直接用 Promise.all 同時等候它們全體,
    // 這樣一來我們前一節的機制就會發揮作用了
    await Promise.all(tasks);
}

最後補充一點,雖然我不禁會想要直接傳回最後一行的 Promise.all(tasks) 並且拿掉 closeAllTabs() 函數的 async 宣告,可惜該物件的型別為 Promise<void[]>,跟函數要傳回的 Promise<void> 不一致,所以我還是只好乖乖寫成上面那樣了。


分享此頁至:
最後修改日期: 2021/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。