Vue3全局可替换内容模态框

模态框是常用的组件,常用在需要显示较少但是重要内容的时候。它需要立即抓到用户的焦点,所以常设计为一个居中的对话框和一个处在背后的黑色半透明遮罩。整体的样式不会有多少变化,但是内容经常变化。

对于单个页面来说,一个模态框的内容可能不会变化,所以可以写一个静态的组件放在页面中,需要时控制它的显示和隐藏即可。但是对于多个页面,内容不同,如果每个页面都放一个模态框组件,那就有点浪费资源且不够灵活。还有一种场景,就是内容是不确定的,就需要模态框可以动态的创建元素并显示。同时希望可以通过一个函数调用,且得到用户关闭模态框时的反馈。

举个例子,首页可以访问,但是有些页面需要登录后进入。点击一个需要登录的页面后弹出登录模态框,用户输入登录信息后,代码在点击处继续,调用接口登录后进入相应的页面,这一条操作链路不能断。

总结一下,现在的核心需求有:

  • 单一的全局组件
  • 动态创建内容
  • 全局函数控制显隐
  • 关闭时返回数据

除此以外,最好还可以:

  • 既可以作为全局组件,也可以作为普通组件
  • 内容和遮罩可以有不同的显示隐藏动画

okay,接下来用Vue3来实现这样一个组件。

基本组件

定义modal组件。

template

基本组件就是一个普通的组件,template如下:

<div v-show="exist">
    <transition :name="maskTransition" @after-leave="handle_afterLeave">
        <div v-show="visible" class="mask" @click="handle_mask_click"></div>
    </transition>
    <transition :name="contentTransition">
        <div v-show="visible" class="content">
            <slot :visible="visible"></slot>
        </div>
    </transition>
</div>
复制代码

.mask元素是遮罩元素,.content是内容元素,具体内容通过插槽来接入,同时向插槽传递visible变量来告知模态框的显隐变化。遮罩和内容分别用了两个transition元素,这样可以分别设置不同的动画。

显示和隐藏动画

这里用了两个变量existvisible来控制元素的显隐,绑定在元素的v-show上,一个控制整体另一个控制遮罩和内容:

data() {
    return {
        exist: false, // 控制显示
        visible: false // 控制动画
    }
}
复制代码

existmodelValue绑定,使得组件外部可以使用v-mdoel控制显隐:

props: {
    modelValue: {
        type: Boolean,
        default: false
    }
}
watch: {
    modelValue: {
        handler() {
            if (this.modelValue) {
                this.show()
            } else {
                this.hide()
            }
        },
        immediate: true
    }
}
复制代码

之所以需要两个变量是因为一个变量(只用exist)会造成隐藏动画的突变:

从上图可以看出,模态框出现时有渐隐动画,但是消失时没有动画,这是因为exist变成false后,元素的display变为none,那么其内部元素的动画就不会播放了。

为了解决这个问题,设置了两个控制显隐的变量。当点击遮罩层时,将visible设置为false,遮罩元素和内容元素播放动画,同时触发update:modelValue事件来告诉组件外元素隐藏了。所以显示和隐藏函数需要这样写:

show() {
    this.exist = true
    this.visible = true
}
hide() {
    this.visible = false
    this.$emit('update:modelValue', false)
}
复制代码

注意在隐藏函数中没有设置existfalse,而是利用transition元素的after-leave事件通知组件动画已经结束了,那么此时可以设置existfalse来真正地隐藏元素。

handle_afterLeave() {
    if (!this.visible) {
        this.exist = this.modelValue
        this.$emit('close')
    }
}
复制代码

这样显示和隐藏都有了渐变动画。

全局组件

有了基本组件后,接着需要一个全局组件来包裹这个基本组件,由它来控制组件的在全局的显隐和内容,命名它为global-modal

template

<modal
          v-model="visible"
          @close="handle_close"
          >
    <div ref="container"></div>
</modal>
复制代码

这个modal就是刚才的基本组件,用一个visible成员变量控制显隐。在插槽中传入一个元素作为内容的容器,设置ref="container"因为等会要动态地添加子元素。

广播订阅模式

首先要能够在全局控制这个属性,利用广播订阅模式是很好的选择。在根组件下添加这个一直存在的global-modal组件,监听modal广播,根据广播数据中的内容组件来进行动态地显示。

广播有很多种实现方式,假设它是这样的:

Broadcast.on('modal', this.handle_modal)
复制代码

这个函数在global-modalcreated生命周期钩子中调用,handle_modal用来处理收到modal消息后的操作:

handle_modal({component, props = {}, callback = () => {}}) {
    this.callback = callback

    this.render(component, props)
}
复制代码

其中component是内容组件(Vue组件,也就是需要在模态框中显示的组件),props是传入内容元素的属性,callback是关闭模态框时的回调。在保存了callback后render函数用于创建一个内容元素然后利用ref插入到之前提到的.container元素中。渲染函数如下:

render(component: DefineComponent, props: Props) {
    let app = createApp(component, props)
    app.use(this.$store).use(this.$router)
    this.app = app

    if (this.$refs.container) {
        let instacne = app.mount(this.$refs.container)
        instacne.Modal = {
            resolve: this.resolve
        } // 注入工具函数,生效存在延迟
        this.instance = instacne

        this.visible = true
    }
},
复制代码

直接使用createApp来创建一个Vue实例,因为它是一个独立的实例,不在项目的Vue实例中,所以还需要给它设置好当前的router和store。然后把新创建的vue实例挂载到容器元素中,并从app.mount函数的返回值获得根组件实例,也就是内容组件实例。

为了能在内容组件中灵活的控制模态框的显隐,向内容组件中注入了Modal对象,其resolve函数用于关闭模态框,且可以向它传递数据。

resolve函数如下所示:

resolve(res) {
    if (this.callback) {
        this.callback(res)
    }

    if (this.instance) {
        this.instance.$emit('Modal-Resolve')
    }

    this.visible = false
}
复制代码

如果在调用全局模态框时传入了回调函数,那么在关闭模态框时这个回调函数会被调用并传入内容组件传递的数据,整条数据链路:显示模态框 —> 内容组件交互 —-> 传递数据,就通了。

resolve函数不仅可以被内容组件调用,还可以被模态框自身调用,比如点击遮罩的时候。resolve函数会触发内容组件的Modal-Reoslve事件,让它进行一定的收尾工作。

在关闭模态框后,需要进行一些善后工作,比如内容组件的销毁和设置的重置,以方便之后的调用。

总结

要实现一个全局可替换内容的模态框,可以先创建一个普通的模态框组件,添加到根组件下,监听全局广播,然后动态的显示广播传递的内容组件,并利用回调函数来回传内容组件处理的数据。

当然除此以外,一个模态框需要考虑的问题还有:

  • 样式可配置
  • 阻止回调函数被多次执行
  • 处理路由变化

等等,详情可见源码

源码

modal

index.vue

<template>
  <div v-show="exist" class="w-modal" :class="[`horizontal-${horizontal}`, `vertical-${vertical}`]" :style="{ 'z-index': z }">
    <transition :name="maskTransition" @after-leave="handle_afterLeave">
      <div v-show="visible" class="mask" @click="handle_mask_click"></div>
    </transition>
    <transition :name="contentTransition">
      <div v-show="visible" class="content">
        <slot :visible="visible"></slot>
      </div>
    </transition>
  </div>
</template>

<script src="./component.js"></script>
<style src="./style.scss" lang="scss" scoped></style>
复制代码

component.ts

import { defineComponent } from 'vue'

export default defineComponent({
  name: 'modal',
  data() {
    return {
      exist: false, // 控制显示
      visible: false // 控制动画
    }
  },
  props: {
    modelValue: {
      type: Boolean,
      default: false
    },
    z: {
      type: Number,
      default: 1
    },
    horizontal: {
      type: String,
      default: 'center',
      validator: (v: string) => ['left', 'right', 'center'].includes(v)
    },
    vertical: {
      type: String,
      default: 'center',
      validator: (v: string) => ['top', 'bottom', 'center'].includes(v)
    },
    maskTransition: {
      type: String,
      default: 'fade',
      validator: (v: string) => ['fade', 'slide-left', 'slide-right', 'slide-up', 'slide-down'].includes(v)
    },
    contentTransition: {
      type: String,
      default: 'fade',
      validator: (v: string) => ['fade', 'slide-left', 'slide-right', 'slide-up', 'slide-down'].includes(v)
    }
  },
  watch: {
    modelValue: {
      handler() {
        if (this.modelValue) {
          this.show()
        } else {
          this.hide()
        }
      },
      immediate: true
    }
  },
  methods: {
    /**
     * @name 处理动画结束
     */
    handle_afterLeave() {
      if (!this.visible) {
        this.exist = this.modelValue
        this.$emit('close')
      }
    },
    /**
     * @name 处理遮罩点击
     */
    handle_mask_click() {
      this.hide()
    },

    /**
     * @name 显示
     */
    show() {
      this.exist = true
      this.visible = true
    },
    /**
     * @name 隐藏
     * @description 动画结束后在handle_afterLeave中完全隐藏
     */
    hide() {
      this.visible = false
      this.$emit('update:modelValue', false)
      this.$emit('hiding')
    }
  }
})
复制代码

style.scss

.modal {
  display: flex;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;

  &.horizontal-left {
    justify-content: flex-start;
  }
  &.horizontal-center {
    justify-content: center;
  }
  &.horizontal-right {
    justify-content: flex-end;
  }
  &.vertical-top {
    align-items: flex-start;
  }
  &.vertical-center {
    align-items: center;
  }
  &.vertical-bottom {
    align-items: flex-end;
  }

  .mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: hsla(0, 0, 0, 0.7);
  }

  .content {
    position: relative;
  }
}

@keyframes fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
.fade-enter-active {
  animation: fade 0.3s ease;
}
.fade-leave-active {
  animation: fade 0.3s ease reverse;
}
@keyframes slide-left {
  from {
    transform: translate(100%, 0);
  }
  to {
    transform: translate(0, 0);
  }
}
.slide-left-enter-active {
  animation: slide-left 0.3s ease;
}
.slide-left-leave-active {
  animation: slide-left 0.3s ease reverse;
}
@keyframes slide-right {
  from {
    transform: translate(-100%, 0);
  }
  to {
    transform: translate(0, 0);
  }
}
.slide-right-enter-active {
  animation: slide-right 0.3s ease;
}
.slide-right-leave-active {
  animation: slide-right 0.3s ease reverse;
}
@keyframes slide-up {
  from {
    transform: translate(0, 100%);
  }
  to {
    transform: translate(0, 0);
  }
}
.slide-up-enter-active {
  animation: slide-up 0.3s ease;
}
.slide-up-leave-active {
  animation: slide-up 0.3s ease reverse;
}
@keyframes slide-down {
  from {
    transform: translate(0, -100%);
  }
  to {
    transform: translate(0, 0);
  }
}
.slide-down-enter-active {
  animation: slide-down 0.3s ease;
}
.slide-down-leave-active {
  animation: slide-down 0.3s ease reverse;
}
复制代码

global-modal

index.vue

<template>
  <ui-modal
    class="modal z-modal"
    v-model="visible"
    :position="options.position"
    :maskTransition="options.maskTransition"
    :wrapTransition="options.wrapTransition"
    @close="handle_close"
  >
    <div ref="container"></div>
  </ui-modal>
</template>

<script src="./component.js"></script>
复制代码

component.ts

import { defineComponent, DefineComponent, createApp, App } from 'vue'
import Broadcast from 'broadcast'
import Modal from 'modal'

export default defineComponent({
  name: 'global-modal',
  components: {
    [Modal.name]: Modal
  },
  data() {
    return {
      visible: false,
      container: null,
      app: null,
      instance: null,
      resolved: false,

      options: {
        position: [],
        maskTransition: '',
        wrapTransition: '',
        single: false,
        routeBackClose: false
      } ,
      callback: () => {}
    }
  },
  watch: {
    visible() {
      if (!this.visible) {
        this.resolve()
      }
    }
  },
  created() {
    Broadcast.on('modal', this.handle_modal)
    window.addEventListener('popstate', this.handle_popstate)
  },
  mounted() {
    this.container = this.$refs.container
  },
  beforeUnmount() {
    window.removeEventListener('popstate', this.handle_popstate)
  },
  methods: {
    /**
     * @name 处理模态事件
     * @param component 内容组件
     * @param props 属性
     * @param options 选项
     *    position: Array。默认center。top,bottom,left,right的组合
     *    maskTransition: String。容器渐变动画
     *    wrapTransition:String。内容渐变动画
     *    single: Boolean。默认true。是否单例
     *    routeBackClose: Boolean。默认true。路由后退时关闭
     * @param callback 回调函数
     */
    handle_modal({
      component,
      props = {},
      options = {},
      callback = () => {}
    }) {
      if (options.single && this.instance) {
        return
      }

      this.options = options
      this.callback = callback

      this.render(component, props)
    },
    /**
     * @name 处理_路由跳转
     */
    handle_popstate() {
      if (this.visible && this.options.routeBackClose) {
        this.visible = false
      }
    },
    /**
     * @name 处理关闭
     */
    handle_close() {
      this.clear()
    },

    /**
     * @name 渲染
     * @param component 内容组件
     * @param props 属性
     */
    render(component, props) {
      let app = createApp(component, props)
      app.use(this.$store).use(this.$router)
      this.app = app

      if (this.container) {
        let instacne = app.mount(this.container)
        instacne.Modal = {
          resolve: this.resolve
        } // 注入工具函数,生效存在延迟
        this.instance = instacne

        this.visible = true
      }
    },
    /**
     * @name 关闭
     * @param res 返回数据
     */
    resolve(res) {
      if (!this.resolved && this.callback) {
        this.callback(res)
      }

      if (this.instance) {
        this.instance.$emit('Modal-Resolve')
      }

      this.visible = false
      this.resolved = true
    },
    /**
     * @name 善后
     */
    clear() {
      if (this.app) {
        this.app.unmount()
        this.app = null
        this.instance = null
      }

      this.resolved = false
      this.options = {
        position: [],
        maskTransition: '',
        wrapTransition: '',
        single: false,
        routeBackClose: false
      }
      this.callback = () => {}
    }
  }
})
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享