當你想實現阻止Electron視窗關閉,並彈出詢問對話方塊,提示使用者:“文章尚未儲存,是否要關閉視窗”這類業務時,那麼你99%會碰到這個BUG:

https://github.com/electron/electron/issues/24994

這是我在去年8月份發現的BUG,Electron的作者也已經確認了這個BUG,但遺憾的是現在還沒有修復。下面我們就聊聊這個問題,以及應對這個問題的方案。

問題描述

要阻止視窗關閉,必須在視窗的關閉事件中,執行preventDefault操作才行,如下程式碼所示:

win.on("close", (e) => {
e.preventDefault();
});

然而這個preventDefault的操作,必須同步呼叫才能生效,所有非同步呼叫preventDefault的操作都沒有任何效果,程式碼如下所示:

win.on("close", async (e) => {
console.log("win close");
await new Promise((resolve) => setTimeout(resolve, 1000));
e.preventDefault(); //沒有任何作用
});

上述程式碼中的preventDefault操作就不會起任何作用。這就帶來了一個業務問題:我們往往在詢問使用者並獲得使用者的允可後才會阻止視窗關閉,比如:“文章尚未儲存,您確認關閉視窗嗎?”開發者無法在這種非同步的詢問通知前執行preventDefault操作,就無法正確的阻止視窗關閉。

可能你會想到用dialog模組的showMessageBoxSync方法來完成這個詢問操作,如下程式碼所示:

win.webContents.on('will-prevent-unload', event => {
let choice = dialog.showMessageBoxSync({
title:'do you want to close',
buttons:['cancel','yes']
});
if(choice === 1) event.preventDefault();
//...
})

沒錯showMessageBoxSync是一個同步方法,但這也會導致整個主程序的JavaScript執行緒阻塞,你預期在未來發生的所有事件,以及這些事件的回撥方法,都不會再執行了(想想看,你的setInterval的回撥方法不會定時執行的結果)。

直到使用者關閉showMessageBoxSync方法開啟的視窗,主程序的JavaScript執行緒才會恢復,如果使用者永遠不做出這個選擇,那麼整個JavaScript執行緒就會一直等待下去。

應對方案

為了解決這個問題,我們可以通過額外的哨兵變數來處理,程式碼如下所示:

//import { app, BrowserWindow, dialog } from "electron";
let winCanBeClosedFlag = false;
win.on("close", async (e) => {
if (!winCanBeClosedFlag) {
e.preventDefault();
let choice = await dialog.showMessageBox(win, {
title: "do you want to close",
message: "你確定要關閉視窗嗎?",
buttons: ["否", "是"],
});
if (choice.response == 1) {
winCanBeClosedFlag = true;
win.close();
return;
}
}
winCanBeClosedFlag = false;
});

預設情況下winCanBeClosedFlag 這個變數的值是false,即不允許使用者關閉視窗(此處的preventDefault是同步操作),當我們詢問過使用者,並且使用者做出了確認關閉的選擇後,這個變數才會被設定為true。此時立即呼叫視窗的close方法,這個視窗的close事件被再次觸發,因為winCanBeClosedFlag 變數已經被置為true了,所以不會執行preventDefault操作,視窗被正常關閉。

視窗被關閉的同時winCanBeClosedFlag變數又被置為false,以備下一次使用者的操作。