一個Electron的設計缺陷及應對方案

當你想實現阻止Electron窗口關閉,並彈出詢問對話框,提示用戶:「文章尚未保存,是否要關閉窗口」這類業務時,那麼你99%會碰到這個BUG:

//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,以備下一次用戶的操作。