转载

【转载】vue3中优雅地呼出弹窗对话框——纯JS调用式阻塞弹窗对话框实现

温馨提示:
本文最后更新于 2024年03月04日,已超过 56 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

原作者:锦恢
原文链接

为什么要纯JS调用的阻塞式对话框?
最近在开发网站时,突然觉得之前设计的密码弹窗的内部实现不够优雅,因为每次在一个page下设置一个密码对话框,都需要先在 <template>里面写好一个<teleport to="body">,然后在setup中定义几个用于控制和处理事件的函数与方法,就像这样:

 <template>
     ......
     <teleport to="body">
         <dialog v-model:showDialog="show"><!-- 自定义对话框组件 -->
             <input type="text" v-model="password">
             <button @click="confirm">确认</button>
             <button @click="cancel">取消</button>
         </dialog>
     </teleport>
     ......
 </template>
 <script setup>
 import { ref } from "vue";
 const show = ref(false);
 const password = ref('');
 function confirm() {
     ...
 }
 function cancel() {
     ...
 }

 </script>

每次都需要这么写,在一个功能众多的页面中,未免显得太繁琐了,而且还会增大后期管理的难度。在element-plus中,就提供了纯JS函数的调用方法来生成一些临时组件,比如,我最常用的ElNotification,不需要定义html和css,只需要调用一个函数,就能直接弹出一个弹框:

 // 弹出一个存在时间为3000ms的弹窗
 ElNotification({title: "中午好", message:"中午的闲暇时光可不能浪费了呢", duration: 3000});

再比如,在vscode插件开发中,你只需要调用如下的函数,就能展现出一个选择框,让用户选择:

 const select = await vscode.window.showQuickPick(items);

代码执行到这句话时,便会阻塞,在用户界面弹出一个对话框,直到用户输入并点击“确定”后,await才会返回用户选择的结果(如果用户没有选择,则返回undefined)。而我们可以通过传入参数来控制生成的窗体的样式和内容。比如我参与开发的Digital-IDE中的自动例化,就调用了vscode.window.showQuickPick来让用户选择要例化的模块:

这样的做法毫无疑问,比上面维护几个函数和临时变量诞生的屎山代码来得强。

实现纯JS调用阻塞式对话框

创建对话框模组

那么,要如何实现一个这样的在外部只需要通过JS代码就能调用的阻塞式对话框呢?首先,我们当然需要先写好一个对话框的vue模块:

 <template>
     <transition name="fade">
         <div class="input-box-wrapper" v-show="show">
             <div
                 class="dialog-box"
                 :style="`height: ${dialogHeight};width: ${dialogWidth};`"
             >
                 <div class="dialog-header">
                     <div class="password-title">{{ title }}</div>

                     <div>
                         <div class="little-close" @click="confirm(undefined)">
                             ×
                         </div>
                     </div>
                 </div>

                 <hr />

                 <div>
                     <div style="width: 100%">
                         <input
                             type="password"
                             class="my-input password-input"
                             v-model="password"
                             @keydown.enter="confirm(password)"
                         />
                     </div>
                     <br />
                     <div class="dialog-btn-wrapper">
                         <button
                             class="primary password-btn"
                             @click="confirm(password)"
                         >确定</button>
                         <button
                             class="primary password-btn"
                             @click="confirm(undefined)"
                         >取消</button>
                     </div>
                 </div>
             </div>
         </div>
     </transition>
 </template>

 <script>
 import { computed, ref, onMounted } from "vue";

 import { showInfoWindow } from "@/hook/window";

 export default {
     name: "PasswordDialog",
     props: ["height", "width"],
     setup(props) {
         const dialogWidth = computed(() => (props.width ? props.width : "30%"));
         const dialogHeight = computed(() =>
             props.height ? props.height : "auto"
         );
         const show = ref(false);
         const title = ref("请输入密码 Password");
         const password = ref("");

         onMounted(() => {
             document.addEventListener("password-dialog-show", (event) => {
                 show.value = event.detail.show;
                 title.value = event.detail.title ? event.detail.title : "请输入密码 Password";
             });
         });

         function confirm(password) {
             if (typeof password == 'string') {
                 password = password.trim();
             }
             if (password === "") {
                 showInfoWindow("输入不能为空!");
                 return;
             }

             const event = new CustomEvent("password-dialog-confirm", {
                 detail: { password },
             });
             document.dispatchEvent(event);
             show.value = false;
         }

         return {
             title,
             dialogHeight,
             dialogWidth,
             password,
             confirm,
             show,
         };
     },
 };
 </script>

 <style>
 hr {
     width: auto;
     margin: 50px;
 }
 </style>
  • 上面提到的CSS我会附在文章末尾

vue->虚拟节点->真实节点->挂载DOM

然后问题就来了:既然我们要通过纯JS代码来调用一个模块,那么自然需要通过JS代码来把vue模块创建对应的虚拟节点并挂载到实际的DOM上,这篇文章我不想花太多时间来讲解vue3的内部原理,感兴趣或者不明白的同学可以在评论区留言,我来给你补课。

我们可以通过vue提供的几个基础渲染函数来将一段.vue文件导入并渲染成真实节点:

 import { defineAsyncComponent, render, createVNode } from "vue";

 const component = defineAsyncComponent(() => import("@/components/Dialog/password.vue"));
 const props = { width: "30%", height: "auto"};

 // 创建虚拟节点
 const dialog = createVNode(component, props);

 // 创建用于渲染虚拟节点的真实节点
 const container = document.createElement('div');

 // 将虚拟节点挂载渲染到真实节点上
 render(dialog, container);

 // 保存一下虚拟节点和真实节点实例
 const vnode = dialog;
 const node = container.childNodes[0];

 // 将已经实体化的虚拟节点挂载到DOM上
 document.body.appendChild(this.node);

通过上述代码,我们就能做到仅凭JS代码,不去动HTML,将vue挂载到DOM上。

那么,我们要如何让JS函数去和vue虚拟节点通信,从而控制对话框消失或展示,或者传递过来用户输入的数据呢?其实在上面的vue代码中已经剧透了,那就是通过DOM原生的两个事件函数:

 // 触发事件e,其中e的属性detail可以用于传递任意类型的参数
 document.dispatchEvent(event);

 // 监听事件,第一个参数为事件名,也就是e的名字,第二参数为e的回调
 document.addEventListener(eventName, callback)

DOM内部执行监听事件基于事件表执行,效率很高,只是为了一个对话框创建两个event和listener问题不大。

我们需要两组事件,一组用于让JS函数控制dialog展示或消失,一组用于让dialog把用户的响应返回给JS函数,然后再传递给应用层代码去操作。

简单包装一下

简单包装一下,我们有如下的代码:

 import { defineAsyncComponent, render, createVNode } from "vue";

 class PasswordDialog {
     constructor() {
         this.component = defineAsyncComponent(
             () => import("@/components/Dialog/password.vue"));
         this.vnode = null;
         this.node = null;
         this.props = {
             width: "30%",
             height: "auto",
         };
     }
     install() {
         if (!this.vnode) {
             const dialog = createVNode(this.component, this.props);
             const container = document.createElement('div');
             render(dialog, container);

             this.vnode = dialog;
             this.node = container.childNodes[0];
             document.body.appendChild(this.node);
         }
     }
     /**
      * 
      * @param {string} title 
      * @returns {Promise<string>}
      */
     show(title) {
         // 发送信号,显示窗口
         const event = new CustomEvent('password-dialog-show', { detail: 
             { show: true, title: title } 
         });
         document.dispatchEvent(event);

         return new Promise((resolve) => {
             document.addEventListener('password-dialog-confirm', event => {
                 const password = event.detail.password;
                 resolve(password);
             });
         })
     }
 }

 const passwordDialog = new PasswordDialog();

 /**
  * 展示一个阻塞式对话框
  * @param {string} title 
  * @returns {Promise<string>}
  */
 export async function showPasswordDialog(title) {
     return passwordDialog.show(title);
 }

找时机安装

诸位可能发现了,我把vue->虚拟节点->真实节点->挂载DOM的过程写在了install函数内,为什么不直接写在类的构造函数内呢?很简单,因为当我们的vue项目启动时,整体的DOM还没有渲染完成,你是无法在没有渲染完DOM的情况下挂载你的节点的,所以我们必须把这个class的install在何时的时机进行触发,也就是所谓的安装了。

那么,在哪里去安装呢?我是选择在main.js下安装:

 import { installDialog } from "@/hook/window"
 ​
 createApp(...)
           ...
           ...
           ...
 ​
 installDialog() // 这个函数是所有对话框安装的集合
  • 为什么不在show函数内部再去判断然后安装呢?因为来不及,会错过第一次让show为true的事件传递。

下面就开始愉快地使用吧!

使用

 // 提交修改版本的
 async function publish() {
     const password = await showPasswordDialog("请输入密码");
     if (password === undefined) {
         // 说明用户点了取消
         return;
     }

     // 发送密码和修改请求,等待服务器响应
     const res = await reqModifyBlog(password, ...);
     // 后续处理,包括密码不正确
     ...
 }

看一下效果

正文到此结束