網頁彈框的非同步行為分析

邊城發表於2021-11-24

1. 序

網頁彈框是個很常見的功能,比如需要告知使用者訊息的時候 (Alert),需要使用者進行確認的時候 (Confirm),需要使用者補充一點資訊的時候 (Prompt) …… 甚至可以彈框讓使用者填寫表單 (Modal Dialog)。

彈框之後,開發者需要知道這個彈框是什麼時候關閉以便進行接下來的操作。

在比較古老的 UI 元件中,這個事情是通過事件回撥來進行的,大概長這樣:

showDialog(content, title, {
    closed: function() { console.log("對話方塊已關閉"); }
})

不過對話方塊的行為。你看,它彈出來了,但它不會阻塞後面的程式碼,而且開發者並不知道什麼時候關閉,因為這是使用者行為。既然是非同步,封裝成 Promise 使用 await 語法來呼叫會更舒服一些。簡單的封裝大概可以這樣:

async function asyncShowDialog(content, title, options) {
    return new Promise(resolve => {
        showDialog(content, title, {
            ...options,
            closed: resolve
        });
    });
}

(async () => {
    await asyncShowDialog(content, title);
    console.log("對話方塊已關閉");
})();

彈框的基本的非同步行為就是這麼簡單,就這麼結束?心有不甘,再研究研究!

2. 找兩個彈框元件看看

既然是研究,先找已經存在的輪子。這裡隨便選了兩個,都是基於 Vue3 框架的

其中 Ant Design Vue 使用了事件的形式,點選“確定”按鈕會觸發 ok 事件,點選“取消”或者右上角的關閉按鈕會觸發 cancel 事件。 這兩個事件處理函式通過引數物件的 onOkonCancel 屬性掛載進去。看起來平淡無奇,但如果處理事件返回的是一個 Promise 物件,點選按鈕之後會出現載入動畫並等待直到 Promise 物件完成之後才會關閉對話方塊。這種設計把非同步等待動畫組合到彈框當中,簡潔直觀,程式碼寫起來也很方便。以 confirm 對話方塊為例:

Modal.confirm({
    ...
    onOk() {
        // 點選「確定」按鈕後,會顯示載入動畫,並在一秒後關閉對話方塊
        return new Promise(resolve => {
            setTimeout(resolve, 1000);
        });
    }
    ...
});

而 Element Plus 使用了 Promise 形式,開啟對話方塊時,並不是把確定或取消的處理函式以引數的形式傳入,而是直接返回一個 Promise 物件,供開發者通過 .then()/.catch() 或者 await 處理。示例:

try {
    await ElMessageBox.confirm(...);
    // 按下確定按鈕在這裡處理
} catch(err) {
    // 按下取消按鈕在這裡處理
}

Element Plus 的這種處理方式,要在對話方塊關閉之後才能處理業務。這也是使用 Promise 的侷限 —— 對於一個已經封裝好的 Promise 物件,很難在其中插入新的邏輯。如果使用 ElMessageBox 的時候也想像 Ant Design 那樣在關閉前進行一些非同步操作,只能去找找看它是否提供了關閉前的處理事件。一找還真找到了,它有 beforeClose 事件。該事件的處理函式簽名是 beforeClose(action, instance, done)

  • action 表示按了哪個按鈕,取值可能是 "confirm""cancel""close"(不用解釋了吧)。
  • instance 是 MessageBox 例項,可以使用它來控制一些介面效果,比如

    instance.confirmButtonLoading = true 會在“確定”按鈕上顯示載入動畫,instance.confirmButtonText 可以用來改變按鈕文字 …… 這些操作在進行非同步等待時可以提供更好的使用者體驗。

  • done 是一個函式,呼叫它表示 beforeClose() 的非同步處理完成,對話方塊現在可以關閉了!

所以類似 Ant Design 的處理可以這樣寫:

try {
    await ElMessageBox.confirm({
        ...
        beforeClose: async (action, instance, done) => {
            await new Promise(resolve => setTimeout(resolve, 1000));
            done();
        }
    });
    // 按下確定按鈕在這裡處理
} catch(err) {
    // 按下取消按鈕在這裡處理
}

3. 自己肝一個

分析了兩個彈框元件的行為處理,我們已經知道,一個體驗良好的彈框元件應該具備如下特徵:

  1. 提供基於 Promise 的非同步控制能力(Ant Design Vue 雖然沒有提供,但是像“序”中那樣封裝一下就可以)。
  2. 允許在關閉前進行一些操作,甚至是非同步操作。
  3. 提供非同步載入過程中的介面反饋,而且最好不需要開發者來控制(從這點來說 Ant Design 比 Element Plus 方便)。

captured-1.gif

去 CodePen 上看演示程式碼

接下來,我們自己寫一個,看看是如何實現上述特徵的。不過,既然我們主要研究的是行為而不是資料處理,所以不用 Vue 框架,直接用 DOM 操作,然後引入 jQuery 來簡化 DOM 處理。

對話方塊的 HTML 骨架也比較簡單:下面一層蒙板,上面一個固定大小的 <div> 層,內部再用 <div> 劃分成標題、內容、操作區三塊:

<div class="dialog" id="dialogTemplate">
  <div class="dialog-window">
    <div class="dialog-title">對話方塊標題</div>
    <div class="dialog-content">對話方塊的內容</div>
    <div class="dialog-operation">
      <button type="button" class="ensure-button">確定</button>
      <button type="button" class="cancel-button">取消</button>
    </div>
  </div>
</div>

這裡把它定義成一個模板,希望每次都從它克隆一個 DOM 出來呈現,關閉即毀。

樣式表的內容較長,可以從後面的示例連結去獲取。程式碼及程式碼的進化過程才是本文的重點。

最簡單的呈現是利用 jQuery 克隆一個顯示出來,但顯示前一定要記得刪除掉 id 屬性,並把它新增到 <body> 中去:

$("#dialogTemplate").clone().removeAttr("id").appendTo("body").show();

把它封裝成一個函式,並且新增對「確定」和「取消」按鈕的處理:

function showDialog(content, title) {
    const $dialog = $("#dialogTemplate").clone().removeAttr("id");

    // 設定對話方塊的標題和內容(簡單示例,所以只處理文字)
    $dialog.find(".dialog-title").text(title);
    $dialog.find(".dialog-content").text(content);

    // 通過事件代理(也可以不用代理)處理兩個按鈕事件
    $dialog
        .on("click", ".ensure-button", () => {
            $dialog.remove();
        })
        .on("click", ".cancel-button", () => {
            $dialog.remove();
        });

    $dialog.appendTo("body").show();
}

彈框的基本邏輯就出來了。現在做兩點優化:① 把 $dialog.remove() 封裝成函式,便於對關閉對話方塊進行統一處理(程式碼複用) ② 使用 .show() 呈現太過生硬,改為 fadeIn(200);同理,應該在 .remove() 之前先fadeOut(200)

function showDialog(...) {
    ...

    const destory = () => {
        $dialog.fadeOut(200, () => $dialog.remove());
    };

    $dialog
        .on("click", ".ensure-button", destroy)
        .on("click", ".cancel-button", destroy);

    $dialog.appendTo("body").fadeIn(200);
}

3.1. 封裝 Promise

到這一步,彈框已經可以正常彈出/關閉了,但是沒辦法注入「確定」或「取消」的邏輯程式碼。前面提到可以通過事件或 Promise 兩種形式來提供介面,這裡使用 Promise 的方式。如果點「確定」就 resolve,點「取消」就 reject。

function showDialog(...) {
    ...

    const promise = new Promise((resolve, reject) => {
        $dialog
            .on("click", ".ensure-button", () => {
                destroy();
                resolve("ok");
            })
            .on("click", ".cancel-button", () => {
                destroy();
                reject("cancel");
            });
    });

    $dialog.appendTo("body").fadeIn(200);
    return promise();
}

封裝好了,但有個問題:destroy() 是個非同步過程,但程式碼並沒有等它結束,所以 showDialog() 完成非同步處理之後還在進行 fadeOut() 操作和 remove() 操作。要解決這個問題,只能封裝 destory()。當然呼叫的時候也別忘了加 await,而加 await 就要把外層函式宣告為 async

function showDialog(...) {
    ...

    const destory = () => {
        return new Promise(resolve => {
            $dialog.fadeOut(200, () => {
                $dialog.remove();
                resolve();
            });
        });
    };

    const promise = new Promise((resolve, reject) => {
        $dialog
            .on("click", ".ensure-button", async () => {
                await destroy();
                resolve("ok");
            })
            .on("click", ".cancel-button", async () => {
                await destroy();
                reject("cancel");
            });
    });

    ...
}

3.2. 確定時允許非同步等待

不管「確定」還是「取消」都可以保持彈框顯示,進行非同步等待。但作為示例,這裡只處理「確定」的情況。

這個非同步等待過程要注入到從彈窗中,只能採用引數注入的形式。所以需要為 showDialog() 新增一個 options 引數,允許注入一個處理函式給 onOk 屬性,如果這個處理函式返回 Promise Like,就進行非同步等待。

先修改 showDialog() 介面:

function showDialog(conent, title, options = {}) { ... }

然後再處理 $dialog.on("click", ".ensure-button", ...) 事件:

$dialog
    .on("click", ".ensure-button", async () => {
        const { onOk } = options;
        // 從 options 中拿到 onOk,如果它是一個函式才需要等待處理
        if (typeof onOk === "function") {
            const r = onOk();
            // 判斷 onOk() 的結果是不是一個 Promise Like 物件
            // 只有 Promise Like 物件才需要非同步等待
            if (typeof r?.then === "function") {
                const $button = $dialog.find(".ensure-button");
                // 非同步等待過程中需要給使用者一定反饋
                // 這裡偷懶沒有使用載入動畫,只用文字來進行反饋
                $button.text("處理中...");
                await r;
                // 因為在完成之後,關閉之前有 200 毫秒的漸隱過程,
                // 所以把按鈕文字改為“完成”,給使用者及時反饋是有必要的
                $button.text("完成");
            }
        }
        await destroy();
        resolve("ok");
    })

現在這個彈框的行為基本上處理完了,呼叫的示例:

const result = await showDialog(
    "你好,這裡是對話方塊的內容",
    "打個招呼",
    {
        onOk: () => new Promise((resolve) => { setTimeout(resolve, 3000); })
    }
).catch(msg => msg);  // 這裡把取消引起的 reject 變成 resolve,避免使用 try...catch...

console.log(result === "ok" ? "按下確定" : "按下取消");

3.3. 細節完善

都有對話方塊了最後還用 console.log(...) 實在有點不妥,直接彈框提示訊息不更好?

但是現在的 showDialog() 只處理了 Confirm 彈框,沒有處理 Alert 彈框 …… 問題不大,在 options 里加個 type 好了。如果 type"alert" 就把「取消」按鈕幹掉。

async function showDialog(content, title, options = {}) {
    ...
    
    if (options.type === "alert") {
        $dialog.find(".cancel-button").remove();
    }
    
    ...
}

然後,最後的 console.log(...) 可以進化一下:

showDialog(result === "ok" ? "按下確定" : "按下取消", "提示", { type: "alert" });

3.4. 改革

如果不喜歡在 options 中注入處理函式,還可以換個法子,在返回的 Promise 物件中注入。先在 .ensure-button 的事件中把 const { onOk } = options 改為 const { onOk } = promise,也就是從 promise 中獲取注入的 onOk。然後改呼叫部分:

const dialog = showDialog("你好,這裡是對話方塊的內容", "打個招呼");
// 把處理函式注入到 promise 的 onOk
dialog.onOk = () => new Promise((resolve) => { setTimeout(resolve, 3000); });
const result = await dialog.catch(msg => msg);

showDialog(result === "ok" ? "按下確定" : "按下取消", "提示", { type: "alert" });

這裡有幾點要注意:

  1. dialog 必須只能是 showDialog() 直接返回的。如果呼叫了 .catch() 將會得到另一個 Promise 物件,此時再注入 onOk 就注入不到 showDialog() 裡面產生的那個 Promise 物件上了。
  2. showDialog() 不能宣告為 async 的,否則返回出來的 Promise 物件也不是裡面產生的那一個。
  3. 別忘了 await

相關文章