效果预览
前言
众所周知,drawer组件是 Web 端项目中经常要用到的组件,ElementUI 组件库中也有此组件,为了熟知其实现原理,以及尽可能的定制化,所以花了点时间写了一个。项目使用的vue版本为 2.6.10,vue-cli版本为 3.12.1,node版本为 14.17.5。因本人能力水平有限,如有错误和建议,欢迎在评论区指出。若本篇文章有帮助到了您,不要吝啬您的小手还请点个赞再走哦!
※注:本文代码区域每行开头的“+”表示新增,“-”表示删除,“M”表示修改;代码中的“…”表示省略。
组件说明
@property 为父组件传给子组件props中的属性,@event为 组件中触发的事件函数,@slot为组件中的插槽
/**
-
@property {String} direction 弹出方向,btt:bottom to top。
-
@property {String, Number} size 窗体大小, 不是传数字时必须传百分比
-
@property {Boolean} visible 是否显示drawer,默认false不显示
-
@property {String} title Drawer 的标题,也可通过具名 slot (见下方slot)传入,
-
@property {Boolean} append-to-body Drawer 自身是否插入至 body 元素上。默认false
-
@property {Boolean} show-title 控制是否显示 title 部分, 默认为 true, 当此项为 false 时, title 属性和插槽 均不生效
-
@event {Function} open 打开时的回调
-
@event {Function} close 关闭时的回调
-
@event {Function} opened 打开动画结束后的回调
-
@event {Function} closed 关闭动画结束后的回调
-
@slot {element} title 标题部分的插槽
*/
Drawer组件代码
drawer.vue:
<template>
<div @click.self="handleWrapperClick" class="base-drawer_wrapper" :style="{ zIndex: zIndex }" v-show="isShowBaseDrawer">
<div :class="`base-drawer base-drawer-${_uid}`" :style="drawerStyle">
<header class="drawer_header" v-if="showTitle">
<slot name="title">
<span :title="title" class="title">{{ title }}</span>
</slot>
</header>
<section class="drawer_body">
<slot></slot>
</section>
</div>
</div>
</template>
<script>
/**
* @property {String} direction 弹出方向,btt:bottom to top。
* @property {String, Number} size 窗体大小, 不是传数字时必须传百分比
* @property {Boolean} visible 是否显示drawer,默认false不显示
* @property {String} title Drawer 的标题,也可通过具名 slot (见下方slot)传入,
* @property {Boolean} append-to-body Drawer 自身是否插入至 body 元素上。默认false
* @property {Boolean} show-title 控制是否显示 title 部分, 默认为 true, 当此项为 false 时, title 属性和插槽 均不生效
* @event {Function} open 打开时的回调
* @event {Function} close 关闭时的回调
* @event {Function} opened 打开动画结束后的回调
* @event {Function} closed 关闭动画结束后的回调
* @slot {element} title 标题部分的插槽
*/
export default {
props: {
direction: {
type: String,
default: 'btt',
validator(val) {
return ['ltr', 'rtl', 'ttb', 'btt'].includes(val)
},
},
size: {
type: [String, Number],
default: '30%',
},
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
},
showTitle: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
},
computed: {
drawerStyle() {
let obj = {}
switch (this.direction) {
case 'btt':
obj.transform = 'translate3d(0, 100%, 0)'
obj.bottom = 0
break
case 'ttb':
obj.transform = 'translate3d(0, -100%, 0)'
obj.top = 0
break
case 'ltr':
obj.transform = 'translate3d(-100%, 0, 0)'
obj.left = 0
obj.width = this.computedSize
break
case 'rtl':
obj.transform = 'translate3d(100%, 0, 0)'
obj.right = 0
break
default:
break
}
if (this.direction === 'btt' || this.direction === 'ttb') {
obj.left = 0
obj.height = this.computedSize
obj.width = '100%'
}
if (this.direction === 'ltr' || this.direction === 'rtl') {
obj.top = 0
obj.width = this.computedSize
obj.height = '100%'
}
return {
...obj,
}
},
computedSize() {
if (typeof this.size === 'number') {
return this.size + 'px'
} else {
return this.size
}
},
},
data() {
return {
isShowBaseDrawer: false,
isAddEvent: true, // 是否添加事件
drawerEle: null,
zIndex: 10,
}
},
watch: {
visible: {
handler(val) {
// console.log(val, oldVal);
// val 为true时展开,此时isShowBaseDrawer如果也为true就触发不了展开动画,所以要重置为false
if (val && this.isShowBaseDrawer) {
this.isShowBaseDrawer = false
}
// console.log(this.$el);
if (val && this.appendToBody) {
document.body.appendChild(this.$el)
}
this.handleToogleShow(val)
},
},
},
mounted() {
this.drawerEle = document.querySelector(`.base-drawer-${this._uid}`)
this.handleTransitionend = this.handleTransitionend.bind(this)
if (this.drawerEle) {
this.drawerEle.addEventListener('transitionend', this.handleTransitionend)
// 写这个是为了在mounted时默认展开
if (this.visible) {
if (this.appendToBody) {
document.body.appendChild(this.$el)
}
this.handleToogleShow()
}
}
},
methods: {
handleTransitionend(e) {
e.stopPropagation()
if (e.target.classList.contains('base-drawer')) {
// console.log(this.visible)
// 展开动画结束后
if (this.visible) {
this.$emit('opened')
} else {
this.isShowBaseDrawer = false
this.$emit('closed')
}
}
},
handleWrapperClick() {
this.$emit('update:visible', false)
// 当前处于展示状态时才做隐藏操作
if (this.visible && this.isShowBaseDrawer) {
// console.log(this.visible, this.isShowBaseDrawer);
this.handleToogleShow()
}
},
handleToogleShow() {
if (!this.drawerEle) {
this.drawerEle = document.querySelector(`.base-drawer-${this._uid}`)
}
// 打开
if (this.visible && !this.isShowBaseDrawer) {
this.isShowBaseDrawer = true
// 使用window.requestAnimationFrame(),因为它可以把代码推迟到下一次重绘之前执行,而不是立即要求页面重绘。
window.requestAnimationFrame(() => {
this.$emit('open')
// 打开遮罩层
this.$modal({ show: true, zIndex: this.zIndex - 1 })
// 强制触发浏览器重绘,不写这句浏览器会合并绘制,不能触发动画
this.drawerEle.offsetWidth
this.drawerEle.classList.remove(`fade_leave_${this.direction}`)
this.drawerEle.classList.add(`fade_enter_${this.direction}`)
})
}
// 关闭
if (!this.visible && this.isShowBaseDrawer) {
// 关闭遮罩层
this.$modal()
this.drawerEle.classList.remove(`fade_enter_${this.direction}`)
this.drawerEle.classList.add(`fade_leave_${this.direction}`)
this.$emit('close')
}
},
},
destroyed() {
// 如果DOM是插入到body的,组件销毁时移除body中的元素
if (this.appendToBody && this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
}
},
}
</script>
<style lang="scss" scoped>
.base-drawer_wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
margin: 0;
.base-drawer {
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2), 0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
position: fixed;
background-color: #fff;
transition: transform 0.3s;
display: flex;
flex-direction: column;
.drawer_header {
padding: 20px 20px 0;
margin-bottom: 30px;
text-align: center;
.title {
}
}
.drawer_body {
padding: 20px;
flex: 1;
}
&.fade_enter_btt {
transform: translate3d(0, 0, 0) !important;
}
&.fade_leave_btt {
transform: translate3d(0, 100%, 0) !important;
}
&.fade_enter_ttb {
transform: translate3d(0, 0, 0) !important;
}
&.fade_leave_ttb {
transform: translate3d(0, -100%, 0) !important;
}
&.fade_enter_ltr {
transform: translate3d(0, 0, 0) !important;
}
&.fade_leave_ltr {
transform: translate3d(-100%, 0, 0) !important;
}
&.fade_enter_rtl {
transform: translate3d(0, 0, 0) !important;
}
&.fade_leave_rtl {
transform: translate3d(100%, 0, 0) !important;
}
}
}
</style>
drawer中的遮罩:函数式组件$modal()
项目目录结构:@表示src目录下
- /public
|- /src
|- /plugins
|- index.js
|- /modal
|- modal.vue
|- index.js
|- main.js
@/plugins/modal/modal.vue:
<template>
<div class="base-modal" :style="{ zIndex: zIndex }" v-if="show"></div>
</template>
<script>
export default {
data() {
return {
zIndex: 10,
show: false,
}
},
destroyed() {
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
}
},
}
</script>
<style lang="scss" scoped>
.base-modal {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.5;
background: #000;
}
</style>
@/plugins/modal/index.js:
import Vue from 'vue'
import modal from './modal.vue'
const ModalConstructor = Vue.extend(modal)
let instance
/**
* 调用 this.$modal({ show: true, zIndex: this.zIndex - 1 }) 显示遮罩,遮罩存在时再次调用 this.$modal() 会移除遮罩
* @param {Object} options 可选
* @returns
*/
const modalFunc = (options = {}) => {
// console.log(instance);
if (!instance) {
instance = new ModalConstructor({
data: options,
}).$mount()
// 如果 $mount() 没有提供 elementOrSelector 参数,模板将被渲染为文档之外的的元素 (可以理解为未挂载状态的vue实例对象) ,并且你必须使用原生 DOM API 把它插入文档中
document.body.appendChild(instance.$el)
return instance
} else {
// console.log(instance.$el.parentNode);
if (instance.$el && instance.$el.parentNode) {
instance.$el.parentNode.removeChild(instance.$el)
}
instance = null
return instance
}
}
export default modalFunc
注册组件@/plugins/index.js:
// main.js 中引入此文件后,执行 Vue.use(plugins) 时会执行下方的 install 方法
import modal from '@/plugins/modal'
export default {
install(Vue) {
Vue.prototype.$modal = modal
}
}
@/main.js:
...
import plugins from '@/plugins'
Vue.use(plugins)
...
原文链接:https://juejin.cn/post/7212878112455180345 作者:傑丶