vue实战:来写一个移动端弹窗吧!

833 阅读22分钟

前言

在移动端的开发中,我们经常会使用到弹窗组件

比如确认弹窗

比如提醒弹窗

再比如一些功能更强大的弹窗

从0到1去实现一个弹窗组件不管对开发人员的css功底、javascript功底、vue功底都是一个有挑战的工作,去实现一个完整的可以支撑各大业务的弹窗也是一件不容易的事

今天我们将从代码入手,让大家可以随时有反馈并由浅入深地去实现一个"精致"的弹窗!

项目搭建

弹窗基于vue,所以我们选择了vue官方提供的开箱即用的vue-cli脚手架

版本

本次演示核心版本

  • vue-cli 4.1.1
  • vue 2.6.10
  • node 10.15.1

基于vue-cli创建项目

首先让我们创建项目

vue create dialog-demo

为了让写样式更快 更方便 我们这里需要用到预处理器,所以我们把它勾选上

本次演示采用的是用的较为广泛的scss,如果安装node-sass报错,多半是网络不好,可以去选择第一项,这里演示选择的第二项node-sass

然后选择代码格式化,这个看个人喜好,平常我喜欢用prettier所以选择了这一项

剩余的选项不重要,可以全部选默认选项,全选完后等待安装

安装好后就可以打开项目了

项目完善

响应式布局

因为项目是移动端的项目,所以牵扯到一个知识点叫移动端的响应式布局

大致有三个方法

  • rem布局 最有名的就是flexible.js rem的缺点就是部分手机字体显示会出问题

  • vw布局 其实上面给的flexible.js链接里面也提到了如何在Vue项目中使用vw实现移动端适配 大家有兴趣可以去细读 vw唯一的缺点就是兼容性不好 但现在来看 vw也能满足大部分的设备 本次演示采用的便是这个方法

  • rem + vw这种和第一种的原理相似,第一种是动态设置外层font-sizepx值,而这种是把font-size设置成一个vw

因为我们采用的vue-cli脚手架,所以没第二种方法里面给出的文章那么麻烦

我们只需要安一个插件 postcss-px-to-viewport 这个插件可以自动把px转换为vw

当然如果大家平常用的rem布局,同样也有一个postcss-pxtorem可以把px转成rem

现在我们安装它

yarn add postcss-px-to-viewport -D

然后在项目根目录创建postcss.config.js,本次演示ui设计尺寸为750px,所以对应的代码如下

module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px',
      viewportWidth: 750,
      viewportHeight: 1334,
      unitPrecision: 3,
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      minPixelValue: 1,
      mediaQuery: false
    }
  }
};

代码格式化

因为这里的格式化vue-cli提供的默认js里面是用的是双引号,平常习惯用单引号,所以我在项目目录下添加了prettier.config.js并设置为单引号

module.exports = {
  singleQuote: true
};

到这项目便算是搭建完了,你可以运行npm run serve开启这个项目

开始封装组件吧!

基础目录

项目下有一个src/components目录,这里便是用来存放组件的

我们首先在项目下创建一个QDialog文件夹,这里要注意是一个文件夹

大家可能会疑惑,为啥不是直接创建一个QDialog.vue文件呢,别急,这里是为了后面做铺垫

创建完QDialog文件夹后,我们在文件夹第一层创建Index.vue文件,这个文件便是我们的弹窗组件,然后我们在这个文件中填充一些内容

<script>
export default {
  name: 'QDialog',
  render() {
    return <div>QDialog</div>;
  }
};
</script>

<style scoped></style>

大家看到这里可能会比较疑惑,为什么不采用template的写法,而要采用较为"复杂"的jsx写法

其实理由很简单,jsx在复杂的业务逻辑中使用体验好于template

体现在两处

  1. 较为复杂的条件判断用v-if v-else写可能会显得沉余,如果采用jsx写,大部分时候可以用三元选择condition? a : b这种形式,即便用到if-else条理性也会比template看着要清晰
  2. template中的变量必须在data中定义才能引用,但有时候我们仅仅只是想保存一个变量方便引用

当然jsx也有它的缺点,比如不支持v-for v-if v-html等指令,不过替代的成本也不大

因为弹窗组件还算一个业务比较复杂的组件 所以我们选择了jsx

然后我们在App.vue中引用

<template>
  <div id="app">
    <q-dialog></q-dialog>
  </div>
</template>

<script>
import QDialog from './components/QDialog/Index';

export default {
  name: 'app',
  components: {
    QDialog
  }
};
</script>

然后打开浏览器便可以看到我们的组件已经挂载成功了

基础样式思考

一个精致的弹窗组件肯定免不了一个精致的外形,所以我们找ui小姐姐拿到了弹窗的标准ui设计

ui设计图宽度为750px,ui图没法贴出来,所以这里用文字描述下

标题距离顶部50px,距离详情30px,距离左右40px,颜色为#333,字体大小为40px

详情距离底部40px,行高42px,颜色为#666,字体大小为28px

按钮高度90px,字体大小为32px,取消按钮颜色#999,确定按钮颜色为#40A1FF

弹窗border颜色为#eee,弹窗圆角为8px,弹窗宽度590px

看完ui图后,我们来思考一下布局中可能会存在的难点

如何让弹窗垂直水平居中

垂直居中在各个场合的面试中都会出现,方法很多很多,在不同的条件也有不同的最优解

不过对于我们这种弹窗高度不固定的垂直居中有两个方案是最优的解法

  • position + transform(translate)

绝对定位 然后用transform改变位移

position: fixed;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
  • position + table + margin

绝对定位 然后用margin居中 且这个位置得设置displaytable

position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
display: table;

两种方案实现结果是一样的,但我们选择了后者

因为在之后作弹窗显示隐藏的过度特效的时候我们用的animate.css,直接copy过来用的,动画效果里面自带的有transformtranslate,如果元素也有就得去相对的改改animate.css原本的值了,图个方便所以用了第二种

移动端1px解决方案

大家可以留意下弹窗的按钮和上面的文字中间有一个横线,如果纯用borderretina高清屏上展示整条线会变得特别粗,这肯定是过不了ui小姐姐锐利的眼睛的

我们可以仔细看一下下面两图border线的差距

  • 采用纯border

  • 采用1px解决方案

可能截图不是特别好观察,但采用纯border的图是会比采用真1px的线更粗的

1px的解决方法也比较多,这也是一个知识点,如果大家要细看可以去看看这篇文章

解决的方法常用的有三种

  • 通过border-image 用图片实现

  • 通过meta元素 在不同的dpr屏幕下 设置不同的 初始比例 initial-scale 最大比例 maximum-scale 最小比例 minimum-scale

  • 通过伪元素 用transform改变伸缩比
.q-border {
  position: relative;
}
.q-border::after {
  content: ' ';
  position: absolute;
  left: 0;
  top: 0;
  width: 200%;
  height: 200%;
  color: #e1e1e1;
  transform-origin: left top;
  transform: scale(0.5);
  border: 1px solid #e1e1e1;
}

本次演示采用的第三种伪元素

css命名规则

css命名现在没有以前那么注重标准了,因为出现了scope css这个概念,vue中也是自带了这个实现,每个组件的样式都会多出一个hash值,即便类名一样,hash不同,就不会产生副作用

同样的其实在我们写弹窗组件的时候,仍然会有scope css,但为什么我们还要去注重css命名呢?

其实这是为了暴漏有规则的css命名从而提高开发者的使用体验,让开发者再某些特定需求或业务常用可以更友好的对组件样式进行定义覆盖

本次演示采用的css命名规范为BEM

简单来说就是 普通元素间用-连接,如果当前元素是一个文本节点则用__连接,如果要修饰当前元素则用--连接

基础布局代码

在理清了布局思路后,我们可以先开始写纯布局的弹窗了

Index.vue

<script>
export default {
  name: 'QDialog',
  render() {
    return (
      <div class="q-dialog">
        <div class="q-dialog-background"></div>
        <div class="q-dialog-core">
          <div class="q-dialog-core__title">标题</div>
          <div class="q-dialog-core__details">详情</div>
          <div class="q-dialog-core-btns">
            <div class="q-dialog-core-btns-chunk q-dialog-core-btns-chunk--cancel">
              <div>取消</div>
            </div>
            <div class="q-dialog-core-btns-chunk q-dialog-core-btns-chunk--confirm q-dialog-core-btns-chunk--confirm--line">
              <div>确认</div>
            </div>
          </div>
        </div>
      </div>
    );
  }
};
</script>

<style lang="scss" scoped>
.q-dialog {
  &-background {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    width: 100%;
    background-color: #000;
    opacity: 0.3;
    z-index: 5;
  }
  /** 此处采用的便是 position margin table 垂直水平居中  **/
  &-core {
    width: 590px;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    margin: auto;
    display: table;
    background-color: #fff;
    z-index: 6;
    border-radius: 6px;
    font-size: 28px;
    &__title {
      font-size: 40px;
      line-height: 40px;
      padding: 50px 30px 30px 30px;
      font-weight: 500;
      text-align: center;
    }
    &__details {
      color: #666666;
      padding: 0 40px 40px 40px;
      text-align: center;
      max-height: 350px;
      overflow: auto;
      -webkit-overflow-scrolling: touch;
    }
    &-btns {
      position: relative;
      display: flex;
      &-chunk {
        flex-shrink: 1;
        flex-grow: 1;
        width: 0;
        display: flex;
        height: 90px;
        justify-content: center;
        align-items: center;
        font-size: 32px;
        line-height: 32px;
        font-weight: 400;
        &--cancel {
          color: #999;
        }
        &--confirm {
          color: #40a1ff;
          &--line {
            position: relative;
            /** 弹窗按钮中间的横线采用1px伪元素解决方案  **/
            &::before {
              content: '';
              position: absolute;
              left: 0;
              top: 20%;
              height: 120%;
              width: 1px;
              transform-origin: top left;
              transform: scaleY(0.5);
              background-color: #eeeeee;
            }
          }
        }
      }
    }
    /** 弹窗按钮上面的横线采用1px伪元素解决方案  **/
    &-btns::before {
      content: '';
      position: absolute;
      height: 1px;
      width: 100%;
      transform-origin: top left;
      transform: scaleY(0.5);
      border-top: 1px solid #eeeeee;
    }
  }
}
</style>

可以看到基础的布局就写好了

大家如果细看上面vue中的html代码可能会有疑问,为什么对按钮 取消和确认不能一个类名,像确认的元素类名就给了3个

其实这是在为后面作铺垫,传入的按钮是动态的,它可能是一个可能是两个

也就是说它可能是提醒弹窗,也可能是确认弹窗,我们如何去区分样式,其实就是通过不同情况下去添加不同的类名作的区分

props思考

如果说刚才我们写的弹窗只是静态的切图的话,那么props就是将静态的切图变成动态的切图,所以我们得来思考思考,对于弹窗组件来说,哪些是动态会改变的呢?

弹窗的打开和关闭

这个不仅是动态改变的,还是一个经常改变的

我们知道vue中的组件(非根组件)中的prop是不允许被更改的,如果你更改了,vue会给你抛一个错,所以vue中更改prop的方法是内部组件向外部组件发送一个事件,然后在组件上用v-on@ 接收这个事件,然后去改外部组件传过来的值从而改变prop

对于这种频繁改动的prop,我们用一个事件去接受它,然后再外部组件去改显得有点麻烦,不过还好,vue中提供了一个指令叫v-model,可以用于这个场景

可能很多同学会有疑问,v-model不是用在input这类元素上的,怎么可以用在自定义组件上

其实是这两者都是可以的,文档中也提到过这个功能

文档里model中的prop的值指的就是当前要双向绑定的组件中prop的值,event指的就是发送的事件名,文档中用的是change,这里我们不定义event,用默认的input事件

所以我们在弹窗组件上增加了prop show和与之对应的model,当我们需要改变show这个prop的时候,直接执行this.$emit('input', bool)就可以改变双向绑定的值了

Index.vue

export default {
+  model: {
+    prop: 'show'
+  },
+  props: {
+    // 是否展示弹窗
+    show: {
+      type: Boolean,
+      default: false
+    }
+  },
}

并且我们在对应的html元素上也要绑定v-show指令

Index.vue

<div class="q-dialog">
+  <div class="q-dialog-background" v-show={this.show}></div>
+    <div class="q-dialog-core" v-show={this.show}>
       <!-- 省略 -->
     </div>
   </div>
</div>

大家可能有疑问,干嘛不在q-dialog这个元素上直接绑v-show指令,非得要绑两个元素,其实这是在位我们后面做弹窗的显示和隐藏动画做准备,先预留着

此时你会发现原本在浏览器中显示的弹窗不在了,其实这就是因为show被设置成了默认的false,所以我们得在QDialog这个组件上添加v-model并让值为true

App.vue

<q-dialog v-model="show"></q-dialog>

App.vue

export default {
  data() {
    return {
      show: true
    };
  },
}

我们可以看到弹窗又显示了出来

标题

标题毫无疑问是一个动态的值,所以我们在prop中添加title表标题

Index.vue

export default {
  props: {
+   // 标题
+   title: {
+     type: String,
+     default: ''
+   }
  },
}

然后我们在之前html中找到title的位置并改写为动态title

Index.vue

- <div class="q-dialog-core__title">标题</div>
+ {this.title && <div class="q-dialog-core__title">{this.title}</div>}

title并不是一个必选项,所以我们前面加了一个条件判断

然后我们在组件上传入title

App.vue

<q-dialog v-model="show" title="传入的标题"></q-dialog>

可以看到title定义成功了

详情

详情也是动态的,不过这里出于业务场景,详情的值可能是两种类型

一种就是纯文字,另外一种就是富文本,富文本vue有自带的指令v-html

不过v-htmljsx中的写法并不是这样而是domPropsInnerHTML

所以我们添加两个props一个details代表纯文字详情,一个richText代表富文本详情

Index.vue

export default {
  props: {
+     // 详情
+     details: {
+       type: String,
+       default: ''
+     },
+     // 富文本
+     richText: {
+       type: String,
+       default: ''
+     }
  },
}

然后改变和添加对应的html

Index.vue

<div class="q-dialog-core" v-show={this.show}>
  {this.title && <div class="q-dialog-core__title">{this.title}</div>}
-   <div class="q-dialog-core__details">详情</div>
+   {this.details && (
+     <div class="q-dialog-core__details">{this.details}</div>
+   )}
+   {this.richText && (
+     <div
+       class="q-dialog-core__details--richText"
+       domPropsInnerHTML={this.richText}
+     ></div>
+   )}
  <div class="q-dialog-core-btns">
    <!-- 省略 -->
  </div>
</div>

大家可以看到我们新增了一个元素并定义了一个新的class类名去放置富文本

大家可能有疑问,为什么两个元素的位置一样且都代表详情,干嘛要用两个class类名呢?

其实原因就在于如果使用者需要自定义在详情和插槽下的不同样式的时候,可以通过类名的不同去获取组件的这两个位置

因为新增了一个类名,所以我们也得新增对应不同的样式

Index.vue

&__details {
  color: #666666;
  padding: 0 40px 40px 40px;
  text-align: center;
  max-height: 350px;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
+   &--richText {
+     color: #666666;
+     padding: 0 40px 40px 40px;
+     text-align: center;
+     max-height: 350px;
+     overflow: auto;
+     -webkit-overflow-scrolling: touch;
+   }
}

让我们改变组件传入的参数来试一试

首先是details

App.vue

<q-dialog v-model="show" title="传入的标题" details="自定义详情"></q-dialog>

然后是richText

App.vue

<q-dialog
  v-model="show"
  title="传入的标题"
  richText="<div>我是富文本</div>"
></q-dialog>

可以看到两者都作用成功了

按钮

弹窗的按钮有两种类型,一种是提醒弹窗,就只有一个按钮,另外一种就是确认弹窗,有取消确认两个按钮

所以我们需要定义一个prop type来区分两种情况,当为alert的时候是提醒弹窗,当为confirm的时候是确认弹窗,这个prop也需要一个默认值,默认值我们指定为alert

同样的我们也需要去动态定义按钮的文字和颜色,所以我们得提供四个prop

一个confirmText表确认按钮文案

一个confirmTextColor表确认按钮文案颜色

一个cancelText表取消按钮文案

一个cancelTextColor表取消按钮文案颜色

大家会不会感觉有一点麻烦,最多的时候可能这5个prop都要改,而且名字还挺长的

特别是如果项目中还存在接口返回弹窗的结构的时候,会特别麻烦,所以为啥我们不从接口的角度考虑,我们能否像接口少返回点字段那样,少写一点prop

当然是可以的

我们新增了一个btns的props,这是一个数组对象,每一个对象就是指一个按钮

const btns = [
  {
    value: '取消',
    color: 'red'
  },
  {
    value: '确认',
    color: 'blue'
  }
]

对象中的value便指按钮文案,color便指按钮文案颜色

其实btns这个prop在5个都需要改的时候会轻松,但在可能其中1个或者2个需要改的时候,它又会显得复杂,所以这里我们保留两个方案,所以按钮这一共有6个prop控制

然后我们来思考思考这个位置 代码该怎么写呢

最简单的思路就是分三种情况

btns存在的时候写一种html

btns不存在且type为alert的时候写一种html

btns不存在且type为confirm的时候写一种html

这种思路是挺清晰的,我们也假写了一个三种情况的代码

<div>
  <!-- btns存在 -->
  <div v-if="btns">
    <div v-for="(item, index) in btns" :key="index">{{ item }}</div>
  </div>
  <!-- btns不存在 -->
  <div v-else>
    <!-- 提醒弹窗 -->
    <div v-if="type === 'alert'">
      <div>{{ confirmText }}</div>
    </div>
    <!-- 确认弹窗 -->
    <div v-if="type === 'confirm'">
      <div>{{ cancelText }}</div>
      <div>{{ confirmText }}</div>
    </div>
  </div>
</div>

其实这三种情况写三份html是没必要的,我们可以将成本控制小一点,我们不在html的时候区分,我们将三种情况合并为一个新的btnsTrans数组,我们在js层进行区分,然后渲染html的时候就只会有一种html

这里的改动比较大,我们贴出了包含之前实现的所有功能的代码,讲解将放到代码下面

Index.vue

<script>
export default {
  name: 'QDialog',
+   data() {
+     return {
+       btnsTrans: []
+     };
+   },
  model: {
    prop: 'show'
  },
+   watch: {
+     type: {
+       handler() {
+         this.generateBtnsTrans();
+       },
+       immediate: true
+     },
+     btns: {
+       handler() {
+         this.generateBtnsTrans();
+       },
+       deep: true
+     }
+   },
+   methods: {
+     // 构造btns
+     generateBtnsTrans() {
+       if (this.btns && this.btns.length) {
+         this.btnsTrans = this.btns;
+       } else if (this.type === 'alert') {
+         this.btnsTrans = [
+           {
+             value: this.confirmText,
+             color: this.confirmTextColor
+           }
+         ];
+       } else if (this.type === 'confirm') {
+         this.btnsTrans = [
+           {
+             value: this.cancelText,
+             color: this.cancelTextColor
+           },
+           {
+             value: this.confirmText,
+             color: this.confirmTextColor
+           }
+         ];
+       }
+     }
+   },
  props: {
    // 是否展示弹窗
    show: {
      type: Boolean,
      default: false
    },
    // 标题
    title: {
      type: String,
      default: ''
    },
    // 详情
    details: {
      type: String,
      default: ''
    },
    // 富文本
    richText: {
      type: String,
      default: ''
    },
+     // 类型
+     type: {
+       type: String,
+       default: 'alert'
+     },
+     // 关闭文案
+     cancelText: {
+       type: String,
+       default: '取消'
+     },
+     // 关闭文案颜色
+     cancelTextColor: {
+       type: String,
+       default: '#999999'
+     },
+     // 确认文案
+     confirmText: {
+       type: String,
+       default: '确认'
+     },
+     // 确认文案颜色
+     confirmTextColor: {
+       type: String,
+       default: '#40A1FF'
+     },
+     // 按钮集合
+     btns: {
+       type: Array,
+       default: () => []
+     }
  },
  render() {
    return (
      <div class="q-dialog">
        <div class="q-dialog-background" v-show={this.show}></div>
        <div class="q-dialog-core" v-show={this.show}>
          {this.title && <div class="q-dialog-core__title">{this.title}</div>}
          {this.details && (
            <div class="q-dialog-core__details">{this.details}</div>
          )}
          {this.richText && (
            <div
              class="q-dialog-core__details--richText"
              domPropsInnerHTML={this.richText}
            ></div>
          )}
-           <div class="q-dialog-core-btns">
-             <div class="q-dialog-core-btns-chunk q-dialog-core-btns-chunk--cancel">
-               <div>取消</div>
-             </div>
-             <div class="q-dialog-core-btns-chunk q-dialog-core-btns-chunk--confirm q-dialog-core-btns-chunk--confirm--line">
-               <div>确认</div>
-             </div>
-           </div>
+          <div class="q-dialog-core-btns">
+            {this.btnsTrans.map((v, i, arr) => {
+              const type =
+                arr.length === 2 ? (i === 0 ? 'cancel' : 'confirm') : 'confirm';
+              let className = `q-dialog-core-btns-chunk--${type}`;
+              className += ' q-dialog-core-btns-chunk';
+              if (arr.length === 2 && type === 'confirm') {
+                className += ' q-dialog-core-btns-chunk--confirm--line';
+              }
+              const color = v.color;
+              return (
+                <div class={className} style={{ color }} key={i}>
+                  <div>{v.value}</div>
+                </div>
+              );
+           })}
+          </div>
        </div>
      </div>
    );
  }
};
</script>

<style lang="scss" scoped>
.q-dialog {
  &-background {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    width: 100%;
    background-color: #000;
    opacity: 0.3;
    z-index: 5;
  }
  &-core {
    width: 590px;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    margin: auto;
    display: table;
    background-color: #fff;
    z-index: 6;
    border-radius: 6px;
    font-size: 28px;
    &__title {
      font-size: 40px;
      line-height: 40px;
      padding: 50px 30px 30px 30px;
      font-weight: 500;
      text-align: center;
    }
    &__details {
      color: #666666;
      padding: 0 40px 40px 40px;
      text-align: center;
      max-height: 350px;
      overflow: auto;
      -webkit-overflow-scrolling: touch;
      &--richText {
        color: #666666;
        padding: 0 40px 40px 40px;
        text-align: center;
        max-height: 350px;
        overflow: auto;
        -webkit-overflow-scrolling: touch;
      }
    }
    &-btns {
      position: relative;
      display: flex;
      &-chunk {
        flex-shrink: 1;
        flex-grow: 1;
        width: 0;
        display: flex;
        height: 90px;
        justify-content: center;
        align-items: center;
        font-size: 32px;
        line-height: 32px;
        font-weight: 400;
        &--cancel {
          color: #999;
        }
        &--confirm {
          color: #40a1ff;
          &--line {
            position: relative;
            &::before {
              content: '';
              position: absolute;
              left: 0;
              top: 20%;
              height: 120%;
              width: 1px;
              transform-origin: top left;
              transform: scaleY(0.5);
              background-color: #eeeeee;
            }
          }
        }
      }
    }
    &-btns::before {
      content: '';
      position: absolute;
      height: 1px;
      width: 100%;
      transform-origin: top left;
      transform: scaleY(0.5);
      border-top: 1px solid #eeeeee;
    }
  }
}
</style>

按钮解析

上面代码比较长,大家也别害怕,我们来一个一个捋一捋新增的内容

props

我们把刚刚确定的6个变量加到props里面

data

我们在data中新增了一个btnsTrans变量,我们刚才也讲过,将三种不同情况的按钮合并成一个数组,这个btnsTrans就是这个数组

methods

我们在methods中新增了一个generateBtnsTrans方法,这就是合三为1的方法,我们也可以看到这个if-else,第一种指的就是btns存在的情况,这种情况优先级最高,第二种就是当弹窗为提醒弹窗的时候,第三种就是当弹窗为确认弹窗的时候

watch

我们新增了两个watch监听

一个监听的type,另外一个监听的btns

首先我们看看这两个监听里面做了啥,我们调用了构造新按钮数组的方法

其实也很好理解,当typebtns改变的时候,对应的按钮展示方式也得变,所以我们得去监听这两个值

但按理来说,我们应该去监听上面提到的6个prop,因为他们的改变都会导致按钮数组改变,不过一般业务场景改变的一般只有这两个,所以我们在demo中监听的就它俩

小伙伴们可能还发现了一个问题,就是watch并不会在值第一次传入的时候触发回调,意思就是如果typebtns都不是异步传值进来的将不会触发其中任何一个watch

其实这一点我们考虑到了,这也就是为什么type下有一个immediate参数,代表的就是立即执行

html
<div class="q-dialog-core-btns">
    {this.btnsTrans.map((v, i, arr) => {
      const type =
        arr.length === 2 ? (i === 0 ? 'cancel' : 'confirm') : 'confirm';
      let className = `q-dialog-core-btns-chunk--${type}`;
      className += ' q-dialog-core-btns-chunk';
      if (arr.length === 2 && type === 'confirm') {
        className += ' q-dialog-core-btns-chunk--confirm--line';
      }
      const color = v.color;
      return (
        <div class={className} style={{ color }} key={i}>
          <div>{v.value}</div>
        </div>
      );
    })}
</div>

这段代码稍微比较复杂,不过逻辑也是很好理解的

构造后的按钮数组的长度最大为2,当数组为2的时候,如果索引为0肯定是取消按钮,索引为1是确认按钮,数组为1的时候,便只有确认按钮

所以通过这个我们拿到type表示当前的类型

大家还记得之前说过的为啥要给取消确认按钮放那么多类名吗

其实类名便是通过type拼凑的

新增的点已经说完了,现在让我们看看实际的效果吧

App.vue

<q-dialog
  v-model="show"
  title="传入的标题"
  richText="<div>我是富文本</div>"
  :btns="btns"
></q-dialog>
export default {
  data() {
    return {
      show: true,
      btns: [
        {
          value: '测试取消',
          color: 'red'
        },
        {
          value: '测试确认',
          color: 'blue'
        }
      ]
    };
  }
};

可以看到渲染结果和我们预期的一致

关闭按钮+禁止背景关闭

这里需要两个prop作开关控制 lock和事件有关,所以我们将放到事件位置讲解

Index.vue

export default {
  props: {
+     // 是否展示关闭按钮
+    closeIcon: {
+       type: Boolean,
+       default: false
+     },
+     // 是否禁止背景点击
+     lock: {
+       type: Boolean,
+       default: false
+     },
  },
}

然后添加对应的html

Index.vue

<div class="q-dialog-core" v-show={this.show}>
+   {this.closeIcon && (
+     <div
+       class="q-dialog-core__closeIcon"
+     ></div>
+   )}
</div>

添加样式 这里有一个icon图标 走的掘金的cdn 大家可以替换下面样式中的url为 p1-jj.byteimg.com/tos-cn-i-t2…

Index.vue

&-core {
+   &__closeIcon {
+     background-image: url('~@/assets/close.png');
+     height: 28px;
+     width: 28px;
+     background-size: 100% 100%;
+     position: absolute;
+     right: 0;
+     top: -58px;
+   }
}

App.vue

<q-dialog
  v-model="show"
  title="传入的标题"
  details="详情"
  closeIcon
></q-dialog>

现在我们可以看到右上角的关闭按钮已经展示出来了

异步关闭

弹窗默认点击确认按钮、取消按钮、背景或关闭icon将会直接关闭弹窗,但如果有时候点击了后需要做一些判断条件才关闭,此时就需要提供一个方法,这里牵扯的更多的是事件相关的 我们将放到事件处讲解

slots

props聊完后我们来聊聊插槽

什么时候需要插槽呢?就是props无法满足复杂的业务条件,需要更丰富的dom去完善业务功能的时候

详情插槽

比如弹窗中间要展示一个图片或者表单的时候,普通的详情和富文本也无法满足,此时就需要插槽

<div class="q-dialog-core" v-show={this.show}>
{this.details && (
  <div class="q-dialog-core__details">{this.details}</div>
)}
+  {this.$slots.details && (
+    <div class="q-dialog-core__details--slot">
+      {this.$slots.details}
+    </div>
+  )}
{this.richText && (
  <div
    class="q-dialog-core__details--richText"
    domPropsInnerHTML={this.richText}
  ></div>
)}
</div>

同样的因为增加了插槽对应的类名,我们也需要在样式中加入对应的样式

&__details {
  color: #666666;
  padding: 0 40px 40px 40px;
  text-align: center;
  max-height: 350px;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  &--richText,
+   &--slot {
    color: #666666;
    padding: 0 40px 40px 40px;
    text-align: center;
    max-height: 350px;
    overflow: auto;
    -webkit-overflow-scrolling: touch;
  }
}

现在我们来看看演示

App.vue

<q-dialog v-model="show" title="传入的标题" closeIcon>
  <div slot="details">我是详情插槽</div>
</q-dialog>

事件

同步关闭

现在我们来补充刚才讲到的事件,我们一共有4个事件,确认按钮点击的confirm、取消按钮点击的cancel、 弹窗关闭的close和弹窗打开的open

可以关闭弹窗的位置有4个,两个按钮+背景+关闭icon

我们在4个位置分别加上点击关闭事件,并添加对应需要向组件外抛出的事件

Index.vue

按钮

return (
<div
  class={className}
  style={{ color }}
  key={i}
+  onClick={this.doBtnClick.bind(this, v, type)}
>
  <div>{v.value}</div>
</div>
 );

背景

<div
  class="q-dialog-background"
  v-show={this.show}
+   onClick={this.doBackgroundClick}
></div>

关闭icon

{this.closeIcon && (
<div
  class="q-dialog-core__closeIcon"
+   onClick={this.doCloseIconClick}
></div>
)}

此时我们也可以把前面漏掉的lock属性加上,当这个值为true的时候,点击背景不关闭弹窗

Index.vue

methods:{
+   // 按钮点击
+   doBtnClick(v, type) {
+     this.$emit(type, v);
+     this.$emit('input', false);
+   },
+   // 关闭icon点击
+   doCloseIconClick() {
+     this.$emit('input', false);
+   },
+   // 背景点击
+   doBackgroundClick() {
+     if (!this.lock) {
+       return;
+     }
+     this.$emit('input', false);
+   }
},
watch: {
+   show: {
+     handler(val) {
+       if (!val) {
+         this.$emit('close');
+       } else {
+         this.$emit('opened');
+       }
+     }
+   }
},

我们将事件发送添加完了,大家可以看到openclose我们换了种方式,试想如果在4个关闭位置触发要写四次close,有点沉余,所以我们用watch监听了show,当show改变的时候便触发openclose

现在我们来尝试下

App.vue

<q-dialog
  v-model="show"
  type="confirm"
  title="传入的标题"
  details="详情"
  closeIcon
></q-dialog>

我们发现点击4个位置弹窗都可以关闭了

当弹窗可以显示和关闭的时候,我们现在就给弹窗加上动画

弹窗动画

动画效果选择的animate.css

背景的进入和离开选择的动画是fadeInfadeOut

弹窗的进入和离开选择的动画是zoomInzoomOut

现在我们给弹窗加上动画

Index.vue

+ <transition
+   name="fade"
+   enterActiveClass="animated fadeIn"
+   leaveActiveClass="animated fadeOut"
+ >
  <div
    class="q-dialog-background"
    v-show={this.show}
    onClick={this.doBackgroundClick}
  ></div>
+ </transition>
+ <transition
+   name="zoom"
+   enterActiveClass="animated zoomIn"
+   leaveActiveClass="animated zoomOut"
+ >
  <div class="q-dialog-core" v-show={this.show}>
+ </transition>
+ .animated {
+   animation-duration: 0.2s;
+   animation-fill-mode: both;
+ }
+ .fadeIn {
+   animation-name: fadeIn;
+ }
+ .fadeOut {
+   animation-name: fadeOut;
+ }
+ @keyframes fadeIn {
+   from {
+     opacity: 0;
+   }
+   to {
+     opacity: 0.3;
+   }
+  }
+ @keyframes fadeOut {
+   from {
+     opacity: 0.3;
+   }
+   to {
+     opacity: 0;
+   }
+ }
+ @keyframes zoomIn {
+   from {
+     opacity: 0;
+     transform: scale3d(0.3, 0.3, 0.3);
+   }
+   50% {
+    opacity: 1;
+   }
+ }
+ .zoomIn {
+   animation-name: zoomIn;
+ }
+ @keyframes zoomOut {
+   from {
+     opacity: 1;
+   }
+   50% {
+     opacity: 0;
+     transform: scale3d(0.3, 0.3, 0.3);
+   }
+   to {
+     opacity: 0;
+   }
+ }
+ .zoomOut {
+   animation-name: zoomOut;
+ }

现在点击弹窗关闭可以发现,弹窗会有一个过渡动效

异步关闭

我们先来分析分析 如何做异步关闭

异步loading动画

首先是异步关闭这里会弹一个圈圈转动

这里用的svg加动画写的下面有一个小demo,可以执行看看动画演示

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <style>
    .svg {
      height: 40px;
      width: 40px;
      animation: loading-rotate 2s linear infinite;
      margin-right: 20px;
    }

    .circle {
      animation: loading-dash 1.5s ease-in-out infinite;
      stroke-dasharray: 90, 150;
      stroke-dashoffset: 0;
      stroke-width: 2;
      stroke-linecap: round;
      stroke: #40a1ff;
    }

    @keyframes loading-rotate {
      100% {
        transform: rotate(360deg);
      }
    }

    @keyframes loading-dash {
      0% {
        stroke-dasharray: 1, 200;
        stroke-dashoffset: 0;
      }

      50% {
        stroke-dasharray: 90, 150;
        stroke-dashoffset: -40;
      }

      100% {
        stroke-dasharray: 90, 150;
        stroke-dashoffset: -120;
      }
    }
  </style>
</head>

<body>
  <svg class="svg" viewBox="25 25 50 50">
    <circle class="circle" cx="50" cy="50" r="20" fill="none" />
  </svg>
</body>

</html>

如何触发异步

那么如何去判断是否在加载中呢,大家还记得我们构造后的数组吗,我们可以在构造数组上添加一个loading字段,当beforeClose存在,且点击的时候,这个值为true,就显示对应的loading图案,所以我们先修改和添加对应的dom

Index.vue

return (
  <div
    class={className}
    style={{ color }}
    key={i}
    onClick={this.doBtnClick.bind(this, v, type)}
  >
-    <div>{v.value}</div>
+    {!v.loading && <div>{v.value}</div>}
+    {v.loading && (
+      <svg
+       class="q-dialog-core-btns-chunk-loading"
+       viewBox="25 25 50 50"
+      >
+       <circle
+         class="q-dialog-core-btns-chunk-loading__circle"
+         cx="50"
+         cy="50"
+         r="20"
+         fill="none"
+       />
+     </svg>
+   )}
  </div>
);

然后我们在构造btnsTrans这个按钮数组的时候,给每一个对象添加一个loading属性,默认为false

methods:{
  // 构造btns
  generateBtnsTrans() {
    if (this.btns && this.btns.length) {
-       this.btnsTrans = this.btns;
+       this.btns.forEach((v, i) => {
+         const bool =
+           (this.btnsTrans[i] && this.btnsTrans[i].loading) || false;
+         this.$set(v, 'loading', bool);
+       });
+       this.btnsTrans = this.btns;
    } else if (this.type === 'alert') {
      this.btnsTrans = [
        {
          value: this.confirmText,
          color: this.confirmTextColor,
+           loading: false
        }
      ];
    } else if (this.type === 'confirm') {
      this.btnsTrans = [
        {
          value: this.cancelText,
          color: this.cancelTextColor,
+           loading: false
        },
        {
          value: this.confirmText,
          color: this.confirmTextColor,
+           loading: false
        }
      ];
    }
  },
},
watch: {
  show: {
    handler(val) {
      if (!val) {
+         this.btnsTrans.forEach(v => (v.loading = false));
        this.$emit('close');
      } else {
        this.$emit('opened');
      }
    }
  }
},

然后添加对应的样式

&-chunk {
+   &-loading {
+     height: 40px;
+     width: 40px;
+     animation: loading-rotate 2s linear infinite;
+     margin-right: 20px;
+     &__circle {
+       animation: loading-dash 1.5s ease-in-out infinite;
+       stroke-dasharray: 90, 150;
+       stroke-dashoffset: 0;
+       stroke-width: 2;
+       stroke-linecap: round;
+       stroke: #40a1ff;
+     }
+   }
}
+ @keyframes loading-rotate {
+   100% {
+     transform: rotate(360deg);
+   }
+ }
+ @keyframes loading-dash {
+   0% {
+     stroke-dasharray: 1, 200;
+     stroke-dashoffset: 0;
+   }
+   50% {
+     stroke-dasharray: 90, 150;
+     stroke-dashoffset: -40;
+   }
+   100% {
+     stroke-dasharray: 90, 150;
+     stroke-dashoffset: -120;
+   }
+ }

因为我们还没有写beforeClose,所以这里没法看到效果,但我们可以暂时改动一下generateBtnsTrans方法中的第二个if语句,当typealert的时候将loading设置为true

generateBtnsTrans() {
  if (this.btns && this.btns.length) {
    // 省略
  } else if (this.type === 'alert') {
    this.btnsTrans = [
      {
        value: this.confirmText,
        color: this.confirmTextColor,
-         loading: false
+         loading: true
      }
    ];
  } else if (this.type === 'confirm') {
    // 省略
  }
},

然后我们看看这个demo

App.vue

<q-dialog
  v-model="show"
  title="传入的标题"
  details="详情"
  closeIcon
></q-dialog>

可以看到loading图案展示出来了

看完演示记得把之前设置的loading重置为默认的false

异步关闭方法

第一种 提供四个prop去作一个开关控制是否点击按钮+背景+icon是默认关闭的,在四个点击事件中多加一个if判断,当这个值设置为不是默认关闭,则不发送默认关闭的事件,关闭只能让开发者去更改v-model绑定的值

第二种 提供一个函数,函数的第一个参数是当前点击的按钮类型,类型有4个,就是两个按钮+背景+关闭icon,第二个参数是一个函数,当调用这个函数后,弹窗关闭

第二个方法肯定是优于第一个的,一个是因为prop只有一个,另一个是不需要开发者在各个事件中去手动修改v-model的值

所以我们采用第二种方法

首先我们在props上增加一个beforeClose

Index.vue

export default {
  props: {
+     // 关闭前的回调
+     beforeClose: {
+       type: [Function, String],
+       default: ''
+     }
  },
}

然后完善各个方法,当beforeClose存在的时候,会优先调用beforeClose,我们完善各个methods

Index.vue

methods:{
+   // 完成回调
+   done() {
+    this.$emit('input', false);
+   },
  // 按钮点击
  doBtnClick(v, type) {
+     if (this.beforeCloseCheck(type)) {
+       v.loading = true;
+       return;
+     }
    this.$emit(type, v);
    this.$emit('input', false);
  },
  // 关闭icon点击
  doCloseIconClick() {
+     if (this.beforeCloseCheck('icon')) {
+       return;
+     }
    this.$emit('input', false);
  },
  // 背景点击
  doBackgroundClick() {
    if (!this.lock) {
+       if (this.beforeCloseCheck('background')) {
+         return;
+       }
      this.$emit('input', false);
    }
  },
+   // 关闭前检查
+   beforeCloseCheck(type) {
+     if (this.beforeClose) {
+       this.beforeClose(type, this.done);
+       return true;
+     }
+   }
}

然后我们写一个demo来看看效果

App.vue

<q-dialog
  v-model="show"
  type="confirm"
  title="传入的标题"
  details="详情"
  closeIcon
  :beforeClose="beforeClose"
>
</q-dialog>
methods: {
  beforeClose(type, done) {
    console.log(type);
    setTimeout(() => {
      done();
    }, 1000);
  }
},

可以看到异步关闭的功能实现了,并且点击不同的位置输出了不同的type

this实例调用弹窗

大家可能或多或少都会有点印象,诸如toast dialog actionsheet这种和页面布局没啥关系却又经常用的组件,大多数ui库提供了this实例去调用

比如this.$dialog.show() show函数中传入配置,就可以直接唤起弹窗

这是怎么做的呢?

方法思考

常用的有两个方法

event bus

javascript有一种设计模式叫发布订阅模式,这也是一个考点

简单来说就是用一个变量对象去存储一个key-value的集合,这里的key就是事件名字,value就是事件的函数集合

它有一个 on方法是添加事件的,有一个emit方法是触发事件的

这里我们写了一个最简单的发布订阅

class Event {
  constructor() {
    this.events = Object.create(null);
  }
  on(type, fn) {
    if (!this.events[type]) {
      this.events[type] = fn;
    } else {
      this.events[type] = Array.isArray(this.events[type])
        ? [...this.events[type], fn]
        : [this.events[type], fn];
    }
  }
  emit(type) {
    if (!this.events[type]) {
      return;
    }
    if (Array.isArray(this.events[type])) {
      this.events[type].forEach(v => v());
    } else {
      this.events[type]();
    }
  }
}

const e = new Event()

e.on('test', () => {
  console.log(1)
})

setTimeout(() => {
  e.emit('test')
}, 1000);

在1s后便可以看到控制台打印出了1

大家看到onemit是否有联想到vue中的this.$emitthis.$on呢,其实两者是如出一辙的

为了更有反馈性的理解这种方法,我们首先先脱离本次业务,我们来以一个实例去理解

首先我们在src目录下创建bus.js,这里我们要导出一个用于存储事件的vue实例

import Vue from 'vue';
export default new Vue();

大家如果之前没有接触过vue-bus的概念,可能会感觉到疑惑,这里导出一个新的vue实例是为什么呢?

大家不妨看看上面写到的简单的发布订阅的代码,事件是被存储在了一个实例对象events中,每个实例都拥有一个events对象,为了保持当前实例发布的事件能被触发,得保证on监听的事件和emit触发的事件在同一个实例下面

所以我们导出了一个公共的vue实例

然后我们在src/components下面创建BusDialog文件夹

在文件夹中创建 Index.vue

<template>
  <div>bus-dialog</div>
</template>

<script>
import bus from '../../bus';
export default {
  name: 'BusDialog',
  created() {
    bus.$on('bus-dialog-show', () => {
      console.log('我接受到了显示的方法');
    });
  }
};
</script>

我们在这个仿弹窗组件中加入了一个事件bus-dialog-show,当外部触发这个事件的时候,会调用一个回调方法

同样的 我们在BusDialog文件夹中创建index.js

import bus from '../../bus';

export default {
  show() {
    bus.$emit('bus-dialog-show');
  }
};

我们在这里向外部提供了一个方法show用于触发bus-dialog-show这个事件

现在事件的发送和接口都写好了,我们来思考思考如何让this实例能调用这个方法

vue是一个构造函数,为了让每个vue实例都能访问到这个方法,所以我们在vue的原型上添加一个公用方法

我们在main.js上添加如下内容

import Vue from 'vue';
import App from './App.vue';
+ import BusDialog from './components/BusDialog';
+ Vue.prototype.$dialog = BusDialog;

new Vue({
  render: h => h(App)
}).$mount('#app');

这样我们就可以在vue文件中通过this.$dialog.show()触发bus-dialog-show这个事件

然后修改App.vue

<template>
  <div id="app">
    <bus-dialog></bus-dialog>
  </div>
</template>

<script>
import BusDialog from './components/BusDialog/Index.vue';

export default {
  name: 'app',
  components: {
    BusDialog
  },
  created() {
    this.$dialog.show();
  }
};
</script>

此时我们打开浏览器发现什么也没输出,但我们实际上是调用了这个方法的,这是为什么?

细心的同学可能已经发现了,我们在子组件BusDialog添加事件是在created生命周期里面,而我们在父组件触发这个事件也是在created生命周期里

vue正确的生命周期顺序是 父beforeCreate => 父 created => 父 beforeMount => 子beforeCreate => 子 created => 子 beforeMount => 子 mounted => 父 mounted

可以看到父组件触发事件的时候事件还没有挂载,所以我们要在父组件的mounted生命周期才能挂载

export default {
-   created() {
-     this.$dialog.show();
-   },
+   mounted() {
+    this.$dialog.show();
+   },
};

大家可能会疑惑,如果这么用,那不是业务里面要调用这个方法得全部在mounted生命周期

其实不是这样的,我们现在看到的是同一个组件下的生命周期顺序,平常的业务代码都会用到vue-router,不同页面都是动态加载引入的,所以和生命周期也没啥关系了

更改后就可以看见控制台打印出了

因为这个方法不是我们最终采用的方法,所以我们只是举了个列子,可以让大家理解,就不在这个方法上对dialog组件进行扩展了,我们有更优雅和华丽的办法

Vue.extend

第二个方法就是用Vue.extend,这个大家可能不怎么使用到的api

Vue.extend干了些啥呢,这个方法继承了Vue构造函数上的所有属性,extend的第一个参数就是组件选项配置,然后返回一个新的构造函数

然后new一个此构造函数的实例,这便是dialog的实例组件,然后调用实例上的$mount属性进行挂载,将返回的Dom节点添加到document.body

听着可能有一点玄幻,所以我们仍然先脱离业务,以一个小demo来理解这个列子

我们在src/components目录下面创建一个ExtendDialog目录,在目录下创建Index.vue

<template>
  <div>{{ test }}</div>
</template>

<script>
export default {
  props: {
    test: {
      type: String,
      default: ''
    }
  }
};
</script>

然后我们在目录ExtendDialog下创建index.js

import Vue from 'vue';
import ExtendDialogVue from './Index.vue';

// 新的构造函数
const ExtendDialogVueConstructor = Vue.extend(ExtendDialogVue);

export default {
  show() {
    // dialog组件实例
    const instance = new ExtendDialogVueConstructor();
    // 挂载实例
    instance.$mount();
    // 设置prop test
    instance.test = '123';
    // 添加实例dom到网页中
    document.body.appendChild(instance.$el);
  }
};

然后同样的在main.js中给Vue的原型上添加展示弹窗的方法

import Vue from 'vue';
import App from './App.vue';
import ExtendDialog from './components/ExtendDialog';

Vue.prototype.$dialog = ExtendDialog;

new Vue({
  render: h => h(App)
}).$mount('#app');

然后在App.vue中调用这个方法

<template>
  <div id="app"></div>
</template>

<script>
export default {
  name: 'app',
  created() {
    this.$dialog.show();
  }
};
</script>

此时便可以看见网页中展示出了我们demodom

现在我们用console来分析下这段代码

import Vue from 'vue';
import ExtendDialogVue from './Index.vue';

// 新的构造函数
const ExtendDialogVueConstructor = Vue.extend(ExtendDialogVue);

+ console.log(ExtendDialogVueConstructor);

export default {
  show() {
    // dialog组件实例
    const instance = new ExtendDialogVueConstructor();
+     console.log(instance);
    // 挂载实例
    instance.$mount();
    // 设置prop test
    instance.test = '123';
+     console.log(instance.$el);
    // 添加实例dom到网页中
    document.body.appendChild(instance.$el);
  }
};

第一个console打印出来的是Vue.extend返回的一个新的构造函数,这个构造函数里的配置项就是ExtendDialog.vue里的配置项

第二个console打印出来的是ExtendDialog.vue的实例,上面有同样在vue文件里面this能直接拿到的属性

其实这里有一个疑问,我们直接去设置了实例上的test属性值为123,但这个属性值明明是prop,按理来说,直接设置prop应该会报错,其实这是因为这里是根组件,非根组件下设置vue会给一个警告,但在根组件不会

第三个console打印的就是实例的dom,这也是我们添加到document.body里面的dom

现在仅仅是一个最简单的demo,还有很多不完善的地方,我们将在写dialog组件的过程进行完善

完善this唤起弹窗

基础

我们回归到业务本身,我们在QDialog目录下创建index.js,其实上面的demo中大家也发现了,我们最初说到的为啥创建的是一个目录而不直接是一个vue文件,就是为了this实例调用弹窗作准备的

index.js

import QDiloagVue from './Index.vue';
import Vue from 'vue';

const QDialogVueConstructor = Vue.extend(QDiloagVue);

export default {
  show(opts = {}) {
    // 创建实例
    const instance = new QDialogVueConstructor();
    // 挂载实例
    instance.$mount();
    // 添加到网页中
    document.body.appendChild(instance.$el);
    // 设置实例的值
    for (const key in opts) {
      instance[key] = opts[key];
    }
    // 让弹窗显示
    instance.show = true;
  }
};

同样的在main.js里面将调用方法添加到Vue的原型上

import Vue from 'vue';
import App from './App.vue';
import QDialog from './components/QDialog';

Vue.prototype.$dialog = QDialog;

new Vue({
  render: h => h(App)
}).$mount('#app');

然后调用弹窗

App.vue

<template>
  <div id="app"></div>
</template>

<script>
export default {
  name: 'app',
  created() {
    this.$dialog.show({
      title: '实例弹窗',
      details: '详情'
    });
  }
};
</script>

可以看到弹窗展示出来了

关闭

大家此时点击上面列子的关闭会发现弹窗关闭不了,但我们不是写了弹窗的关闭事件,我们发送了一个this.$emit('input', false)但为啥弹窗还是没关闭呢

其实this.$emit('input', false)是发送给v-model双向绑定的值,告诉这个值要改变,但我们实例调用的时候并没有使用到v-model,所以我们此时需要直接操作去更改prop的值

那么如何区分当前关闭是调用组件下的关闭还是通过this调用弹窗下的关闭呢,其实很简单,this下创建的是根组件,所以此组件的this.$roottrue而且此组件的this.$parent值不存在

本次演示我们取this.$parent不存在作判断

methods: {
  // 完成回调
  done() {
+     if (this.$parent) {
      this.$emit('input', false);
+     } else {
+       this.show = false;
+     }
  },
  // 按钮点击
  doBtnClick(v, type) {
    if (this.beforeCloseCheck(type)) {
      v.loading = true;
      return;
    }
+     if (this.$parent) {
      this.$emit(type, v);
      this.$emit('input', false);
+     } else {
+       this.show = false;
+     }
  },
  // 关闭icon点击
  doCloseIconClick() {
    if (this.beforeCloseCheck('icon')) {
      return;
    }
-     this.$emit('input', false);
+     this.done();
  },
  // 背景点击
  doBackgroundClick() {
    if (!this.lock) {
      if (this.beforeCloseCheck('background')) {
        return;
      }
-       this.$emit('input', false);
+       this.done();
    }
  },
}

我们在4个可以关闭弹窗的位置加上了关闭判断,现在点击弹窗便可以关闭了

回调事件的触发

其实不光关闭触发不了,this实例调用下,组件向外部抛的事件比如confirm cancel也触发不了

这里有两个方法

beforeClose

第一个就是用我们本身就提供的一个beforeClose方法

this.$dialog.show({
  title: '实例弹窗',
  details: '详情',
  type: 'confirm',
  closeIcon: true,
  beforeClose: (type, done) => {
    console.log(type);
    done();
  }
});

我们可以通过传过来的type做不同处理

promise

第二个方法是promise,弹窗有一个取消和确认,确认和promisethen回调对应,取消和promisecatch回调对应

这个方法看着会很优雅,都是是有业务上的缺陷的,但触发这个场景的缺陷不多,所以我们仍然会给组件添加这个方法

首先我们会做一个判断当beforeClose存在的时候便不会返回一个promise,然后我们再按钮点击的位置在做一次处理,当点击确认执行promiseresolve,点击取消执行promisereject

首先我们完善index.js

import QDiloagVue from './Index.vue';
import Vue from 'vue';

const QDialogVueConstructor = Vue.extend(QDiloagVue);

+ let instance, _resolve, _reject;

+ // promise关闭
+ function _doPromiseInstanceClose(type, v) {
+   if (type === 'confirm') {
+     _resolve(v);
+   } else if (type === 'cancel') {
+     _reject(v);
+   }
+   instance.show = false;
+ }

export default {
  show(opts = {}) {
+     const promise = new Promise((resolve, reject) => {
      // 创建实例
-       const instance = new ExtendDialogVueConstructor();
+       instance = new QDialogVueConstructor();
      // 挂载实例
      instance.$mount();
      // 添加到网页中
      document.body.appendChild(instance.$el);
+       // 保存变量
+       _resolve = resolve;
+       _reject = reject;
+       // 方法赋值到实例中
+       instance._doPromiseInstanceClose = _doPromiseInstanceClose;
      // 设置实例的值
      for (const key in opts) {
        instance[key] = opts[key];
      }
      // 让弹窗显示
      instance.show = true;
+     });
+     if (opts.beforeClose) {
+       return '';
+     } else {
+       return promise;
+     }
  }
};

然后完善按钮点击处

methods:{
    // 按钮点击
    doBtnClick(v, type) {
      if (this.beforeCloseCheck(type)) {
        v.loading = true;
        return;
      }
      if (this.$parent) {
        this.$emit(type, v);
        this.$emit('input', false);
      } else {
-         this.show = false;
+         this._doPromiseInstanceClose(type, v);
      }
    },
}

然后我们来做一个测试

this.$dialog
  .show({
    title: '实例弹窗',
    details: '详情',
    type: 'confirm',
    closeIcon: true
  })
  .then(() => {
    console.log('点击了确定');
  })
  .catch(() => {
    console.log('点击了取消');
  });

可以看到点击确认和取消按钮分别输出了对应的值

但其实这里有一个缺陷,就是我们开头提到过的,promise虽然实现方式看着很优雅,但最多也只有thencatch回调,对于icon和背景点击,我们没法在promise的回调触发,因为不管放thencatch里面都不太合适,所以如果牵扯到要去抓点击icon和背景的回调得用beforeClose这个方法,也是开头提到过的,这种业务场景不多,所以我们保留了promise的写法

多次创建实例

我们现在只考虑了第一次调用弹窗的情况,但忽略了第二次调用的情况

比如我们这么调用

this.$dialog
  .show({
    title: '实例弹窗',
    details: '详情',
    type: 'confirm'
  })
  .then(() => {})
  .catch(() => {});

setTimeout(() => {
  this.$dialog
    .show({
      title: '第二次实例弹窗',
      details: '第二次详情'
    })
    .then(() => {})
    .catch(() => {});
}, 2000);

我们会发现第二次调用实例的时候,不管第一个弹窗是否在2s内关闭,页面中都出现了两个弹窗dom,其实这里是没有必要的,因为两个弹窗其实可以共用为一个弹窗实例,我们仅需要改变的是弹窗组件中props的值,就可以达到实现不同功能

所以我们在index.js添加如下代码,做一次判断,当弹窗已经被实例化添加到dom一次后,之后的弹窗就不需要在实例化了

// 重置
+ function resetInstance() {
+   const props = instance.$options.props;
+   for (const key in props) {
+     let defaultValue = props[key].default;
+     if (typeof defaultValue === 'function') {
+       defaultValue = defaultValue();
+     }
+     instance[key] = defaultValue;
+    }
+ }

export default {
  show(opts = {}) {
    const promise = new Promise((resolve, reject) => {
+       if (!instance) {
        // 创建实例
        instance = new QDialogVueConstructor();
        // 挂载实例
        instance.$mount();
        // 添加到网页中
        document.body.appendChild(instance.$el);
+       } else {
+        // 重置实例
+        resetInstance();
+       }
      // 保存变量
      _resolve = resolve;
      _reject = reject;
      // 方法赋值到实例中
      instance._doPromiseInstanceClose = _doPromiseInstanceClose;
      // 设置实例的值
      for (const key in opts) {
        instance[key] = opts[key];
      }
      // 让弹窗显示
      instance.show = true;
    });
    if (opts.beforeClose) {
      return '';
    } else {
      return promise;
    }
  }
};

我们在show处增加了一个if判断,当实例不存在则挂载实例,当实例存在的时候,则去制空之前定义的值

其实更直白一点,这次置空的应该是所有props的值,props又有一个default值,所以只需要遍历一下props,将它们全部重置为各自下的默认值就可以了

总结

到这里,弹窗组件就设计完成了,本次演示的源码在这里

但这也并不是一个100%完整的弹窗

我们用typescript + Vue Composition API 对项目进行了完善

并添加了业务场景可能不多但还是会存在的按钮插槽 也给项目配上了单元测试和demo演示,你也可以点击这里访问