Vue3 如何实现一个带遮罩的 dialog 对话框

16,377 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情

前言: 今天在项目中遇到了很多很多需要弹出一个对话框的场景,由于之前全都是通过 v-if 来控制这个组件的显示与否,这样就造成了很多页面莫名多出了很多不相关的代码,极度不优雅。所以我尝试去实现了一个函数式调用的 dialog 组件,感觉在简单的场景下还是比较好用的,特来分享一下这个思路。🎁

a.gif


一. 前期准备

你需要创建两个文件来和我一起完成这个函数式调用的 dialogDialog.vuedialogCreator.ts
image.png

二. dialog 遮罩的样式

  1. 我的组件样式是采用 UnoCss 的写法,是将样式内嵌在标签的class 属性里。和大家在 Style 标签里写是一模一样的效果,大家不用特别担心样式写法的问题,样式和本文主要内容没有任何直接的关系。

  2. 这里我们选择先写一个遮罩,关于遮罩的关键点其实就是需要设置一个带一点点透明度的背景,我选择了 rgba(0,0,0,0.4) ,也就是带 0.4 透明度的纯黑背景颜色。
    image.png
    在这里我们需要特别注意,由于我们的遮罩是会出现在“其它页面之上”的,所以我们需要给整个组件外部设置一个 absolute 来使它独立于其它页面,为了防止某些边界情况,需要设置 z-index:9999 来保证这个页面会在整个应用之上。整体效果如下:
    image.png

三. dialog 对话框的样式

  1. 关于 dialog 对话框的样式这里我们不统一设置,但是我们组件至少需要包含三个主要元素。一个 Header 区域,一个 content 区域,最后一个取消按钮和确定按钮的区域。 image.png

  2. 在这里你可以先把文字都暂时写成固定值,到后面我会解释如何通过 props 动态的传递这些值。

四. h 函数和 render 函数的用法

  1. 让我们打开之前准备的 dialogCreator.ts 文件,引入我们刚刚编写的 Dialog 组件,一会儿我们就需要用到它了。
    image.png

  2. 在此之前我们还需要引入两位老朋友 h,函数和 render 函数。在这里看过我之前《如何创建一个全局搜索框🔍》《如何创建一个 Toast》 这两篇文章的朋友一定不会陌生这两个函数的意义,但为了照顾新朋友我还是会大概讲解一下这两个函数的主要用途的。
    image.png

  3. 我相信大家对 Vue 渲染组件的流程有一个大概的认知,Vue 是先构建出 虚拟dom 然后再根据 虚拟dom 去渲染出 真实dom的。

  4. 在这里我们需要清晰的知道, Vue 给我们提供的的 template 标签仅仅只是一个让我们可以用熟悉的 html 标签书写 虚拟dom 的语法糖而已。
    image.png
    是的,你没有听错,它仅仅只是一个语法糖而已,它底层是会被编译成用 h 函数创造出的 虚拟dom 在这里从而引出官方解释。
    image.png

  5. 那么上文官方提到的渲染函数又是什么呢?其实就是刚刚我们提到的 h 函数。h() 函数更准确名字其实应该是 createVnode(),和它的英文翻译是一一对应的,创建虚拟Dom。
    image.png

  6. 这个函数具体该如何使用呢?我们从实战去理解,让我们继续编写我们的 DialogCreator 类,我们创建两个函数,一个控制 dialog 的出现叫做 present 方法,另一个控制 dialog 的消失,叫做 dismiss 方法。 image.png

  7. 这里马上就要用到刚刚提到的 h 函数。h 函数的第一个参数可以接收一个组件作为实参,并且返回这个组件的 虚拟dom 给我们。所以我们可以按照下面的写法拿到我们所需要的 Dialog 组件的 虚拟dom
    image.png

  8. 拿到 虚拟dom 有什么用呢?这里需要引入我们的第二个关键函数 render 函数。我们需要知道,我们目前只拿到了一个游离于 真实dom节点 之外的一个“假的dom”节点,你需要告诉它该渲染到哪里。什么意思呢?打开我们的 main.ts 文件。
    image.png
    千万不要忘记这个 #app 是什么。
    image.png
    它就是我们全局唯一的 真实dom ,一个朴实无华的一个 id 叫做 app真实dom。

  9. 然后我们观察我们 render 函数可以接收的参数类型是什么,看下图我画黄色线的地方,看到什么惊喜了吗?第一个参数是一个 vnode
    image.png
    什么?vnode,我刚刚不才通过 h(Dialog) 函数拿到了一个 vnode 吗?没错,聪明的你应该能猜到下面的写法了。
    image.png

  10. emm 但是好像在报错,我们看一下错误信息。(这里我们忽略第三个参数,只考虑两个参数即可。)
    image.png
    🤔,这个 container 参数的类型是一个 element 或者 ShadomRoot,这又是什么鬼呢?我们继续点击 render 函数,进入它的定义,发现 container 原来最终是一个 HostElement 类型。看来这个搞清楚这个 HostElement 是关键。
    image.png

  11. 在这里我们转变一下思路,我们反向推断 HostElement 是个什么。让我们再次打开 main.ts 文件,这次我们跳进 mount 函数的定义,就是下面黄色圈圈圈起来的这个函数。
    image.png
    你看到了什么?
    image.png
    没错很熟悉的几个单词 HostElement ,注意,你千万不要觉得这个 HostElement 是什么很神奇的元素,让我们回想一下 mount 函数的参数是什么来着?
    image.png
    没错,还是那个普普通通的,一个叫做 app 的全局的真实dom
    image.png

  12. 由此我们可以反向推断出,render 函数需要一个 真实dom 来包裹我们的虚拟dom。生产出一个 真实dom 还不容易吗?我们直接调取 js 的方法,createElement(‘div’) 来生产一个普通的 div 元素用来包裹我们的虚拟dom。 image.png

五. 完善 DialogCreator 类

  1. 现在也告诉了虚拟Dialog 组件该放在哪里了,接下来就需要将我们的 containerEl 放在正确的位置,放在哪里呢?由于我们的 dialog 出现的情况一般都是最顶层。提醒你一下,别忘了我们所有其它页面都是被放到了 id为 appdiv 标签里。那么为了保证它绝对出现在最顶层而不被其它页面遮挡的这种情况发生,那我们延伸一下思路,如果让我们的 Dialog 成为 body 标签的第一个子元素,并且由于之前我们给 Dialog 组件设置了 absolute 属性,那么它就会正好浮现在我们所有页面之上,由于它脱离了文档流,那么它的出现就不会影响我们其它页面的布局
    image.png

  2. 思路有了,这还不简单吗?如何成为 body 的第一个子元素就是基础方法了,这里就不过多解释了。 image.png

  3. 而让元素消失的方法就更简单了,合适的时机移除这个 dom 元素即可。
    image.png

  4. 让我们测试一下是否可行,我们随便在哪一个页面里去调用我们的 DialogCreator 类调用 new 生成一个 Dialog 实例。然后随便写两个按钮去调用这两个方法测试一下。
    image.png
    效果如下:
    啊.gif
    但是由于我们的“遮罩”挡住了我们的按钮,所以目前为止我们暂时点击不了消失按钮。别着急,我们一步一步尝试优化现在的代码。

六. 神奇的 h 函数

  1. 目前我们的 dialog 已经可以出现到我们的页面了,但是现在它的内容都是写死的,不灵活,我们需要按照不同的场景传递不同的文字该如何实现呢?这里又需要请出我们的老朋友,h 函数。

  2. 这里我先抛出概念,等等我们一步一步验证。

h 函数是可以接收第二个参数的,并且第二个参数的值将被转换成 props 传递给我们的组件。

  1. 让我们回到 dialogCreator.ts 文件。我们声明一个类型,准备作为 DialogCreator 内部 constructor 参数的类型。
    image.png
    并且声明两个类的属性 titlecontent 来准备做为 props 传递给我们的 Dialog.vue 组件。
    image.png

  2. 我们现在还缺少一个关键的东西,就是取消按钮确定按钮的函数,我们一并声明。(这里需要注意,一般取消按钮就是关闭 dialog 对话框的功能,也就是类本身的 dismiss 方法,所以我们不需要用户额外提供取按钮的函数,只需要提供确定时的回调函数即可。)
    image.png
    这里需要读者仔细品味上图代码的含义。

  3. 接下来我们就需要传递 this.optionh 函数即可。
    image.png
    报错了没关系,是因为我们还没有在 Dialog.vue 组件内部定义 Props

  4. 让我们分别从 dialogCreator.ts 文件导出这个 DialogPropsType 类型,再从 Dialog.vue 引入这个类型用来定义 props 即可。
    image.png
    随即可以看到我们刚刚到报错消失了,说明我们的思路是没问题的。
    image.png

七. 改造 Dialog.vue 组件

  1. 我们先将之前固定写死的,title 部分和 content 部分替换成我们声明的 props 里的 titlecontentimage.png

  2. 然后别忘了我们 props 还存放着《确定》和《取消》的的方法。取出来分别放置在这两个按钮身上。 image.png

  3. 随便找一个其它页面,测试刚刚的 DialogCreator 类,内容我就随便自己写了
    image.png 我们测试一下:
    啊.gif

八. 遮罩的关闭效果

  1. 现在我们点击遮罩是没办法关闭 dialog 的,效果如下:
    a.gif

  2. 造成这种情况的原因也很简单,因为我们的遮罩没有点击事件,怎么办呢?非常非常简单,给遮罩添加取消 cancelBtn ,也就是 dismiss 方法不就可以了吗?
    image.png
    测试一下,现在点击遮罩已经可以正常关闭 dialog 了。
    啊.gif

九. 修复冒泡造成的 Bug

  1. 目前看起来功能已经很棒了,但是目前的代码会造成一个严重的 bug,我们在点击 dialog 本身的时候,由于事件冒泡,会错误的触发遮罩层的方法。
    a.gif

  2. 我们验证一下,我们随便编写一个函数,然后绑定到 dialog 组件上。
    image.png
    注意:这里的 dialog 指的是中间的那个实实在在的对话框本身,不是指整个组件。
    image.png
    image.png

  3. 然后给 cancelBtn 也加一行 console.log 测试一下。
    image.png
    效果如下:
    a.gif

  4. 解决方法简单的出乎你的意料,让我们回到中间的 diaolog 身上,仅仅只需要绑定一个空的 click 函数,然后加上修饰符 stop 即可。 image.png

  5. 效果如下,可以看到,现在点击 dialog 已经不会错误的关闭整个 对话框了。
    QQ20221229-225519-HD.gif
    至此我们的 dialog 组件已经可以在绝大部分场景下使用了。🎁~

总结

目前的代码只是一个很粗糙的实现,更加具体实用的功能还需读者根据自己项目的需求自行完成。下面是 DialogCreator.ts 文件的代码。读者可根据需要自行查阅。

import Dialog from "./Dialog.vue";

import { h, render } from "vue";

interface DialogType {
  title: string;
  content: string;
  confirmBtn: () => void;
}

export interface DialogPropsType extends DialogType {
  closeBtn: () => void;
}
export class DialogCreator {
  containerEl: HTMLDivElement;
  option: DialogPropsType;
  constructor(option: DialogType) {
    this.containerEl = document.createElement("div");
    this.option = { ...option, closeBtn: this.disMiss.bind(this) };
  }

  present() {
    const vnode = h(Dialog, this.option);
    render(vnode, this.containerEl);
    document.body.insertBefore(this.containerEl, document.body.firstChild);
  }

  disMiss() {
    render(null, this.containerEl);
    document.body.removeChild(this.containerEl);
  } //dialog 消失的方法
}