在網頁世界裡面一直存在著三個超古老的內建對話方塊:alert
、confirm
和 prompt
。這三個打從二十幾年前我剛上國中的時候就已經存在了,一直到現在也都還在。但是當然,原則上專業寫網頁的人一定不會去用這三個東西,一方面因為它們的外觀完全沒有自訂空間、無法融入網頁的設計,另外一方面則因為它們都是同步方法,一旦呼叫了這些對話方塊,在使用者做出回應之前網頁上的 JavaScript 是完全停擺的,這兩點都是對使用者體驗有負面影響的特質。因此,當我們真的有需要使用類似用意的對話方塊的時候,通常都會選擇另外用 UI 框架中提供的對話方塊元件去做,例如 BPS 採用的 Bootstrap 框架中的 Modal 元件就是一種對話方塊。
不過,BS Modal 背後的思維跟原生的 alert
等等是非常不一樣的,所以如果我們想要做的事情是用 BS Modal 來取代原生對話方塊的功能,那倒也需要下一些非同步程式碼的功夫才能夠達到類似的效果。本篇中我就來談 BPS 關於這一部分採用的方法。
內容目錄
基本架構
BPS 中有兩種通用的對話方塊,分別對應於 alert
和 confirm
的作用,差別只在於前者傳回空值、而後者會傳回 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>
不一致,所以我還是只好乖乖寫成上面那樣了。
留言