• 可控模态框的实现
  • Artiely
  • #javaScript
  • 2020-07-13
  • 1430
  • 8 min read
  • loading...

可控模态框的实现

  • v-model 语法糖的实现
  • .sync 修饰符的使用
  • 滚动穿透的解决
  • slot 和 slot-scoped 的运用
  • 优化滚动条的处理
  • 动态添加到指定节点的实现
  • 快捷键的扩展应用[esc,enter]
  • 动画
  • 拖拽
  • jsApi
  • 扩展成提示框
  • 扩展成询问框
  • 扩展成抽屉
  • 书写文档

借助 vue-cli 快速开始原型开发

安装全局原型 cli 工具
点击我了解更多

npm install -g @vue/cli-service-global
复制成功
1

新建一个任意文件夹并创建一个基础组件,如dialog/index.vue
组件代码

<template>
  <h1>Hello!</h1>
</template>
复制成功
1
2
3

进入文件夹并启动原型

cd dialog

vue serve index.vue

复制成功
1
2
3
4

基础结构

<template>
  <div class="dialog-wrapper">
    <div class="dialog-box"></div>
  </div>
</template>

<script>
export default {}
</script>
<style lang="less" scoped>
.dialog-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
}

.dialog-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(0, 0, 0, 0.5);
}
.dialog-box {
  width: 400px;
  height: 400px;
  background: #fff;
}
</style>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

效果图

通过 v-model 实现模态框的显示和隐藏

新建一个入口组件dialog/App.vue
代码如下

<template>
  <div>
    <button @click="handleShow">显示</button>
    <Dialog v-model="show" />
  </div>
</template>

<script>
import Dialog from './index'
export default {
  components: {
    Dialog,
  },
  data() {
    return {
      show: false,
    }
  },
  methods: {
    handleShow() {
      this.show = true
    },
  },
}
</script>

<style lang="scss" scoped></style>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

启动vue serve App.vue
效果

更新dialog/index.vue

  <template>
    <div
      class="dialog-wrapper"
+     v-show="value"
    >
      <div class="dialog-box">
+        <button @click="handleClose">关闭</button>
      </div>
    </div>
  </template>
  <script>
  export default {
+  props: {
+    value: Boolean
+  },
+  methods: {
+    handleClose () {
+      this.$emit('input', false)
+    }
+  }
  }
  </script>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

这样以来我们就简单的通过v-model实现了模态框的显示和隐藏

使用更语义化的字段 visible 实现显示和隐藏

我们对组件进行改造

<template>
  <div
    class="dialog-wrapper"
    v-show="visible"
  >
    <div class="dialog-box">
      <button @click="handleClose">关闭{{visible}}</button>
    </div>
  </div>
</template>

<script>
export default {
  props: {
-    value:Boolean
+    visible: Boolean
  },
+  model: {
+    prop: 'visible',
+    event: 'change',
+  },
  methods: {
+    handleClose () {
-      this.$emit('input', false)
+      this.$emit('change', false)
+      this.$emit('close', false)
+    }
  }
}
</script>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

这样我们就支持两种控制显示和隐藏的方式,具体的使用

<!-- <Dialog v-model="show" /> -->
<Dialog :visible="show" @close="show = false" />
复制成功
1
2

到这里我们的文档就有一个 api 可以书写了

props

参数说明类型默认值
visible(v-model)对话框是否可见Booleanfalse

然后我们再来看看会出现一些什么问题,我们尝试将页面的数据增加

<template>
  <div>
    <button @click="handleShow">显示{{ show }}</button>
    <div v-for="i in 100" :key="i">{{ i }}</div>
    <!-- <Dialog v-model="show" /> -->
    <Dialog :visible="show" @close="show = false" />
  </div>
</template>
复制成功
1
2
3
4
5
6
7
8

这是打开对话框,滚动滚轮时就会出现背景跟着滚动,这就是著名的滚动穿透问题。

解决滚动穿透问题

了解更多
滚动穿透是一个复杂的问题,如果要兼容移动端会有些奇怪的东西,我们采用的是最简单粗暴的方式给body添加overflow:hidden
我们只需要给组件添加代码如下

watch: {
  visible: {
    handler (val) {
      if (val) {
        document.getElementsByTagName('body')[0].style.overflow = 'hidden'
      } else {
        document.getElementsByTagName('body')[0].style.overflow = 'auto'
      }
    },
    immediate: true
  }
}
复制成功
1
2
3
4
5
6
7
8
9
10
11
12

解决页面抖动的问题

对用户交互有极致追求的你也许会发现上线的滚动条在隐藏和显示的时候会出现页面抖动的问题。

为了增强用户体验,通过判断是否有滚动条而添加 margin-left 属性以抵消 overflow: hidden 之后的滚动条位置。

判断是否有滚动条的方法

function hasScrollbar() {
  return document.body.scrollHeight > window.innerHeight
}
复制成功
1
2
3

计算滚动条宽度的方法

因为 IE 10 以上以及移动端浏览器的滚动条都是不占据页面宽度的透明样式(其中 IE 10 以上浏览器可以通过 CSS 属性还原原始的滚动条样式),所以为了进一步增强用户体验,我们还需要计算滚动条的宽度,根据情况添加合理的 margin-left 数值。
计算滚动条宽度的方法比较简单,新建一个带有滚动条的 div 元素,通过该元素的 offsetWidth 和 clientWidth 的差值即可获得

function getScrollbarWidth() {
  let scrollDiv = document.createElement('div')
  scrollDiv.style.cssText =
    'width: 99px; height: 99px; overflow: scroll; position: absolute; top: -9999px;'
  document.body.appendChild(scrollDiv)
  let scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
  document.body.removeChild(scrollDiv)
  return scrollbarWidth
}
复制成功
1
2
3
4
5
6
7
8
9

修改后组件的代码

watch: {
    visible: {
      handler (val) {
        if (val) {
+         let sw = getScrollbarWidth()
          document.getElementsByTagName('body')[0].style.overflow = 'hidden'
+         if(hasScrollbar()){
+           document.getElementsByTagName('body')[0].style.marginRight = sw + 'px'
+         }
        } else {
          document.getElementsByTagName('body')[0].style.overflow = 'auto'
+         if(hasScrollbar()){
+           document.getElementsByTagName('body')[0].style.marginRight = 0 + 'px'
+         }
        }
      },
      immediate: true
    }
  },
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

给弹窗加上简单的过渡动画

同样是为了提升用户的体验,避免显示过于生硬
借助 vue 的 transition 组件实现一个渐渐显示的效果
点击我了解更多

<template>
  <div
    class="dialog-wrapper"
    v-show="visible"
  >
+    <transition name="fade">
+    <div class="dialog-box" v-show="visible">
      <button @click="handleClose">关闭{{visible}}</button>
+    </div>
+    </transition>
  </div>
</template>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.4s;
}
复制成功
1
2
3
4
5
6
7
8

把动画作为自定义的 api 暴露出去

props: {
    visible: Boolean,
+    transitionName: {
+      type: String,
+      default: 'fade'
+    }
  },
复制成功
1
2
3
4
5
6
7
  <div
    class="dialog-wrapper"
    v-show="visible"
  >
+    <transition :name="transitionName">
      <div
        class="dialog-box"
        v-show="visible"
      >
        <button @click="handleClose">关闭{{visible}}</button>
      </div>
    </transition>
  </div>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13

props

新增的 api 后的文档

参数说明类型默认值
visible(v-model)对话框是否可见Booleanfalse
transitionName对话框过渡动画Stringfade

点击遮罩层可关闭

相关的事件修饰符详情

<div
    class="dialog-wrapper"
    v-show="visible"
+   @click.self="handleMask"
  >
复制成功
1
2
3
4
5
  props: {
    visible: Boolean,
    transitionName: {
      type: String,
      default: 'fade'
    },
+   maskClosable: {
+     type: Boolean,
+     default: true
+   }
  },
  methods: {
    handleClose () {
      this.$emit('change', false)
      this.$emit('close', false)
    },
+    handleMask () {
+      if (this.maskClosable) {
+        this.handleClose()
+      }
+    }
  }
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

props

新增的 api 后的文档

参数说明类型默认值
visible(v-model)对话框是否可见Booleanfalse
transitionName对话框过渡动画Stringfade
maskClosable点击蒙层是否允许关闭Booleantrue

扩展键盘事件 esc 关闭弹框

只需对事件进行监听处理

 props: {
    visible: Boolean,
    transitionName: {
      type: String,
      default: 'fade'
    },
    maskClosable: {
      type: Boolean,
      default: true
    },
+    escClosable: {
+      type: Boolean,
+      default: true
+    }
  },
+ mounted () {
+    document.addEventListener('keyup', (e) => {
+      if (e.keyCode === 27 && this.escClosable) {
+        this.handleClose()
+      }
+    })
+  },
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

props

新增的 api 后的文档

参数说明类型默认值
visible(v-model)对话框是否可见Booleanfalse
transitionName对话框过渡动画Stringfade
maskClosable点击蒙层是否允许关闭Booleantrue
escClosable点击 esc 是否允许关闭Booleantrue

通过插槽分发内容

插槽分发更多详情

扩展模态框一些可自定义的内容

<template>
  <div class="dialog-wrapper" v-show="visible" @click.self="handleMask">
    <transition :name="transitionName">
      <div class="dialog-box" v-show="visible">
        <slot name="wrapper">
          <div class="dialog-inner">
            <slot name="header">
              <div class="header">
                <span v-if="title">{{ title }}</span>
                <button @click="handleClose">关闭{{ visible }}</button>
              </div>
            </slot>
            <slot name="body">
              <div class="body">
                <slot />
              </div>
            </slot>
            <slot name="footer">
              <div class="footer">
                <button @click="handleCancel">取消</button>
                <button @click="handleOk">确定</button>
              </div>
            </slot>
          </div>
        </slot>
      </div>
    </transition>
  </div>
</template>

<script>
import 'animate.css'
function hasScrollbar() {
  return document.body.scrollHeight > window.innerHeight
}
function getScrollbarWidth() {
  var scrollDiv = document.createElement('div')
  scrollDiv.style.cssText =
    'width: 99px; height: 99px; overflow: scroll; position: absolute; top: -9999px;'
  document.body.appendChild(scrollDiv)
  var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
  document.body.removeChild(scrollDiv)

  return scrollbarWidth
}
export default {
  props: {
    visible: Boolean,
    transitionName: {
      type: String,
      default: 'fade',
    },
    maskClosable: {
      type: Boolean,
      default: true,
    },
    escClosable: {
      type: Boolean,
      default: true,
    },
    title: String,
  },
  model: {
    prop: 'visible',
    event: 'change',
  },
  watch: {
    visible: {
      handler(val) {
        if (val) {
          let sw = getScrollbarWidth()
          document.getElementsByTagName('body')[0].style.overflow = 'hidden'
          if (hasScrollbar()) {
            document.getElementsByTagName('body')[0].style.marginRight =
              sw + 'px'
          }
        } else {
          document.getElementsByTagName('body')[0].style.overflow = 'auto'
          if (hasScrollbar()) {
            document.getElementsByTagName('body')[0].style.marginRight =
              0 + 'px'
          }
        }
      },
      immediate: true,
    },
  },
  mounted() {
    document.addEventListener('keyup', (e) => {
      if (e.keyCode === 27 && this.escClosable) {
        this.handleClose()
      }
    })
  },
  methods: {
    handleClose() {
      this.$emit('change', false)
      this.$emit('close', false)
    },
    handleMask() {
      if (this.maskClosable) {
        this.handleClose()
      }
    },
    handleCancel(e) {
      this.$emit('handle-cancel', e)
      this.handleClose()
    },
    handleOk(e) {
      this.$emit('handle-ok', e)
    },
  },
}
</script>
<style lang="less" scoped>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.dialog-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
}

.dialog-wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(0, 0, 0, 0.5);
}
.dialog-box {
  width: 400px;
  min-height: 100px;
  display: flex;
  .dialog-inner {
    background: #fff;
    width: 100%;
    height: 100%;
    flex: 1;
    display: flex;
    flex-direction: column;
    .header {
      padding: 16px 24px;
      color: rgba(0, 0, 0, 0.65);
      background: #fff;
      border-bottom: 1px solid #e8e8e8;
      border-radius: 4px 4px 0 0;
      display: flex;
      justify-content: space-between;
    }
    .body {
      padding: 24px;
      font-size: 14px;
      line-height: 1.5;
      word-wrap: break-word;
    }
    .footer {
      padding: 10px 16px;
      text-align: right;
      background: transparent;
      border-top: 1px solid #e8e8e8;
      border-radius: 0 0 4px 4px;
    }
  }
}

.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.4s;
}
</style>
复制成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180

Api

新增的 api 后的文档
props

参数说明类型默认值
visible(v-model)对话框是否可见Booleanfalse
transitionName对话框过渡动画Stringfade
maskClosable点击蒙层是否允许关闭Booleantrue
escClosable点击 esc 是否允许关闭Booleantrue

slot

名称说明类型默认值
wrapper弹框的自定义slot-
header弹框头部的自定义slot-
body弹框身体的自定义slot-
footer弹框底部的自定义slot-

event

名称说明类型默认值
change显示状态改变时event-
close关闭时event-
handle-cancel点击取消时event-
handle-ok点击确定时event-

待续