可控模态框的实现 ¶
- v-model 语法糖的实现
- .sync 修饰符的使用
- 滚动穿透的解决
- slot 和 slot-scoped 的运用
- 优化滚动条的处理
- 动态添加到指定节点的实现
- 快捷键的扩展应用[esc,enter]
- 动画
- 拖拽
- jsApi
- 扩展成提示框
- 扩展成询问框
- 扩展成抽屉
- 书写文档
借助 vue-cli 快速开始原型开发 ¶
安装全局原型 cli 工具
点击我了解更多
npm install -g @vue/cli-service-global
复制成功
新建一个任意文件夹并创建一个基础组件,如dialog/index.vue
组件代码
<template> <h1>Hello!</h1> </template>
复制成功
2
3
进入文件夹并启动原型
cd dialog vue serve index.vue
复制成功
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>
复制成功
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>
复制成功
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>
复制成功
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>
复制成功
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" />
复制成功
2
到这里我们的文档就有一个 api 可以书写了
props ¶
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
visible(v-model) | 对话框是否可见 | Boolean | false |
然后我们再来看看会出现一些什么问题,我们尝试将页面的数据增加
<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>
复制成功
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 } }
复制成功
2
3
4
5
6
7
8
9
10
11
12
解决页面抖动的问题 ¶
对用户交互有极致追求的你也许会发现上线的滚动条在隐藏和显示的时候会出现页面抖动的问题。
为了增强用户体验,通过判断是否有滚动条而添加 margin-left 属性以抵消 overflow: hidden 之后的滚动条位置。
判断是否有滚动条的方法 ¶
function hasScrollbar() { return document.body.scrollHeight > window.innerHeight }
复制成功
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 }
复制成功
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 } },
复制成功
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>
复制成功
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; }
复制成功
2
3
4
5
6
7
8
把动画作为自定义的 api 暴露出去 ¶
props: { visible: Boolean, + transitionName: { + type: String, + default: 'fade' + } },
复制成功
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>
复制成功
2
3
4
5
6
7
8
9
10
11
12
13
props ¶
新增的 api 后的文档
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
visible(v-model) | 对话框是否可见 | Boolean | false |
transitionName | 对话框过渡动画 | String | fade |
点击遮罩层可关闭 ¶
<div class="dialog-wrapper" v-show="visible" + @click.self="handleMask" >
复制成功
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() + } + } }
复制成功
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) | 对话框是否可见 | Boolean | false |
transitionName | 对话框过渡动画 | String | fade |
maskClosable | 点击蒙层是否允许关闭 | Boolean | true |
扩展键盘事件 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() + } + }) + },
复制成功
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) | 对话框是否可见 | Boolean | false |
transitionName | 对话框过渡动画 | String | fade |
maskClosable | 点击蒙层是否允许关闭 | Boolean | true |
escClosable | 点击 esc 是否允许关闭 | Boolean | true |
通过插槽分发内容 ¶
扩展模态框一些可自定义的内容
<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>
复制成功
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) | 对话框是否可见 | Boolean | false |
transitionName | 对话框过渡动画 | String | fade |
maskClosable | 点击蒙层是否允许关闭 | Boolean | true |
escClosable | 点击 esc 是否允许关闭 | Boolean | true |
slot
名称 | 说明 | 类型 | 默认值 |
---|---|---|---|
wrapper | 弹框的自定义 | slot | - |
header | 弹框头部的自定义 | slot | - |
body | 弹框身体的自定义 | slot | - |
footer | 弹框底部的自定义 | slot | - |
event
名称 | 说明 | 类型 | 默认值 |
---|---|---|---|
change | 显示状态改变时 | event | - |
close | 关闭时 | event | - |
handle-cancel | 点击取消时 | event | - |
handle-ok | 点击确定时 | event | - |