likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 20

效果预览

从零开始实现一个基于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
评论
请登录