Vue3使用hook实现Modal对话框

1,014 阅读2分钟

前言

在 vue3 + ts 使用 API 实现 全局控制 Modal 对话框

eslint
'no-unused-vars': ['warn', { vars: 'all', args: 'none' }],
'no-undef': 'warn'TS中,以.d.ts结尾的文件默认是全局模块,里面声明的类型,或者变量会被默认当成全局性质的,其他后缀结尾的文件默认是局部模块。对于局部模块要在文件里面显式写import或者export,否则会报错
也可以关闭配置文件中的 isolatedModules:false,取消这样的限制。此限制是为了将每个文件都当成独立的模块,方便其他编译器编译ts代码
ts /* global bbb */ 全局声明

1、建立 modal 模板,在 plugins/modal

-- Modal.vue
<template>
  <Teleport to="body" :disabled="!isTeleport">
    <div v-if="modelValue" class="modal">
      <div class="mask" @click="maskClose && !loading && handleCancel()"></div>
      <div class="modal_main">
        <div class="modal_title">
          <span>{{ title || '提示' }}</span>
          <span v-if="close" title="关闭" class="close" @click="!loading && handleCancel()"></span>
        </div>
        <div class="modal_content">
          <Content v-if="typeof content === 'function'" :render="content" />
          <slot v-else>
            <img src="./img/warn.png" class="content_img" alt="warn" v-if="icon == 'warn'">{{ content }}
          </slot>
        </div>
        <div class="modal_btns">
          <button class="btn confirm" :disabled="loading" @click="handleConfirm">
            <span class="loading" v-if="loading"></span>
            {{ confirmText }}
          </button>
          <button class="btn cancel" @click="!loading && handleCancel()">{{ cancelText }}</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script lang="ts" setup name="RootModal">
import {
  getCurrentInstance,
  onBeforeMount,
  PropType
} from 'vue'
import Content from './Content'
import config from './config'
import { IContent, IInstance } from './modal.type'

defineProps({
  isTeleport: { type: Boolean, default: true },
  modelValue: { type: Boolean, default: false, require: true },
  title: {
    type: String,
    default: ''
  },
  icon: {
    type: String,
    default: 'none'
  },
  content: {
    type: [String, Function] as PropType<string | IContent>,
    default: '',
    require: true
  },
  loading: {
    type: Boolean,
    default: false
  },
  close: {
    type: Boolean,
    default: () => config!.close
  },
  maskClose: {
    type: Boolean,
    default: () => config!.maskClose
  },
  confirmText: {
    type: String,
    default: '确定'
  },
  cancelText: {
    type: String,
    default: '取消'
  }
})

const emit = defineEmits(['on-confirm', 'on-cancel', 'update:modelValue'])

let instance = getCurrentInstance() as IInstance
onBeforeMount(() => {
  instance._hub = {
    'on-cancel': () => { },
    'on-confirm': () => { }
  }
})

const handleConfirm = () => {
  emit('on-confirm')
  instance._hub['on-confirm']()
}
const handleCancel = () => {
  emit('on-cancel')
  emit('update:modelValue', false)
  instance._hub['on-cancel']()
}
</script>
<style lang="less" scoped>
@keyframes rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
.modal {
  .mask {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 9999;
    background-color: rgba(0, 0, 0, 0.45);
  }
  .modal_main {
    width: 400px;
    min-height: 180px;
    background: #FFFFFF;
    border-radius: 10px;
    z-index: 10000;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    .modal_title {
      width: 100%;
      height: 50px;
      padding: 0 20px;
      box-sizing: border-box;
      font-size: 18px;
      color: #333333;
      line-height: 50px;
      font-weight: 400;
      position: relative;
      .close {
        font-size: 10px;
        color: #93969C;
        position: absolute;
        top: 50%;
        right: 19px;
        transform: translateY(-50%);
        cursor: pointer;
      }
    }
    .modal_content {
      width: 100%;
      min-height: 30px;
      padding: 20px;
      box-sizing: border-box;
      font-size: 16px;
      color: #333333;
      line-height: 30px;
      .content_img {
        width: 30px;
        height: 30px;
        display: inline-block;
        margin-right: 9px;
        margin-top: -3px;
      }
    }
    .modal_btns {
      min-height: 60px;
      padding: 10px 20px 20px;
      box-sizing: border-box;
      .loading {
        display: inline-block;
        margin-right: 5px;
        animation: rotate 1s infinite linear;
      }
      .btn {
        min-width: 80px;
        height: 30px;
        padding: 0 9px;
        text-align: center;
        font-size: 14px;
        color: #333333;
        line-height: 28px;
        float: right;
        border: 1px solid #EEEFF2;
        background-color: #fff;
        border-radius: 4px;
        margin-left: 20px;
      }
      .confirm {
        border: 1px solid @primary-color;
        background-color: @primary-color;
        color: #fff;
      }
      .cancel:hover {
        border: 1px solid @primary-color;
        color: @primary-color;
      }
    }
  }
}
</style>

-- Content.tsx

import { h } from 'vue'
const Content = (props: { render: (h: any) => void }) => props.render(h)
Content.props = ['render']
export default Content
-- config.ts

import { ConfigType } from './modal.type'
const config: ConfigType = {
  // 是否显示右上角的关闭按钮,默认开启
  close: true,
  // 点击蒙层是否允许关闭,默认开启
  maskClose: true,
  confirmText: '确定',
  cancelText: '取消'
}
export default config
-- modal.type.ts
import { ComponentInternalInstance, VNode } from 'vue'

export interface ConfigType {
  icon?: string,
  close?: boolean,
  maskClose?: boolean,
  confirmText?: string,
  cancelText?: string,
  rootClassName?: string
}

export type IContent = string | ((h?: any) => VNode)

export interface IModalParams {
  title?: string,
  icon?: string,
  content: IContent,
  close?: boolean,
  maskClose?: boolean,
  confirmText?: string,
  cancelText?: string,
  rootClassName?: string,
  onConfirm?: () => Promise<void> | void,
  onCancel?: () => void
}
export interface IModal {
  confirm(params: IModalParams): void
}

export interface IInstance extends ComponentInternalInstance {
  _hub: {
    'on-cancel': () => void,
    'on-confirm': () => void
  }
}

-- index.ts
import { App, createVNode, render } from 'vue'
import Modal from './Modal.vue'
import { ConfigType, IInstance, IModal } from './modal.type'
import config from './config'

Modal.install = (app: App, options: ConfigType = {}) => {
  // 合并配置信息
  Object.assign(config, options || {})

  // 注册全局组件
  app.component(Modal.name, Modal)

  // 注册全局事件
  app.config.globalProperties.$modal = {
    confirm({
      title = '',
      icon = 'none',
      content = '',
      close = config!.close,
      maskClose = config!.maskClose,
      confirmText = config!.confirmText,
      cancelText = config!.cancelText,
      rootClassName = config!.rootClassName || '',
      onConfirm,
      onCancel
    }) {
      const container = document.createElement('div')
      container.className = rootClassName
      const vnode = createVNode(Modal)
      render(vnode, container)
      const instance = vnode.component as IInstance
      document.body.appendChild(container)

      const { props, _hub } = instance

      const _closeModal = () => {
        props.modelValue = false
        container.parentNode!.removeChild(container)
      }

      Object.assign(_hub, {
        t: app.config.globalProperties.$t,
        async 'on-confirm'() {
          if (onConfirm) {
            const fn: any = onConfirm()
            if (fn && fn.then) {
              try {
                props.loading = true
                await fn
                props.loading = false
                _closeModal()
              } catch (err) {
                // 发生错误时,不关闭弹框
                props.loading = false
              }
            } else {
              _closeModal()
            }
          } else {
            _closeModal()
          }
        },
        'on-cancel'() {
          onCancel && onCancel()
          _closeModal()
        }
      })

      Object.assign(props, {
        isTeleport: false,
        modelValue: true,
        title,
        icon,
        content,
        close,
        maskClose,
        confirmText,
        cancelText
      })
    }
  } as IModal
}
export default Modal

2、使用 hook useGlobal -> hooks/useGlobal/index.ts

import { getCurrentInstance } from 'vue'

/* global ICurrentInstance */
export default function useGlobal() {
  const {
    appContext: {
      config: { globalProperties }
    }
  } = (getCurrentInstance() as unknown) as ICurrentInstance

  return globalProperties
}

在 src 下新建 global.d.ts

import { ComponentInternalInstance } from 'vue'
import { IModal } from '@/plugins/modal/modal.type'

declare global {
  interface IGlobalAPI {
    $modal: IModal;
  }
  interface ICurrentInstance extends ComponentInternalInstance {
    appContext: {
      config: { globalProperties: IGlobalAPI };
    };
  }
}
export {}

3、在 main.ts 下挂载

-- plugins/useComponents.ts

import type { App } from 'vue'
import Modal from '@/plugins/modal'

const useComponents = (app: App<Element>) => {
  app.use(Modal as any)
}

export default useComponents

-- main.ts
import useComponents from './plugins/useComponents'

useComponents(app)

4、在页面中使用

import useGlobal from '@/hooks/useGlobal'

const { $modal } = useGlobal()
const sleep = (delay: number = 3000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      return reject()
    }, delay)
    // setTimeout(resolve, delay)
  })
}
const handleShowModal = () => {
  $modal.confirm({
    maskClose: true,
    close: true,
    icon: 'warn',
    content: '确认删除?',
    async onConfirm() {
      console.log('点击确定 before')
      await sleep(2000)
      console.log('点击确定 after')
    },
    onCancel() {
      console.log('取消----')
    }
  })
}