从零开始实现一个基于Vue的Drawer组件

效果预览

从零开始实现一个基于Vue的Drawer组件

前言

众所周知,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 作者:傑丶

(0)
上一篇 2023年3月21日 下午7:00
下一篇 2023年3月21日 下午7:10

相关推荐

发表回复

登录后才能评论