5

网页弹框的异步行为分析

 2 years ago
source link: https://segmentfault.com/a/1190000040978125
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

网页弹框的异步行为分析

发布于 47 分钟前

网页弹框是个很常见的功能,比如需要告知用户消息的时候 (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

接下来,我们自己写一个,看看是如何实现上述特征的。不过,既然我们主要研究的是行为而不是数据处理,所以不用 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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK