Vue3 Ant design关于Modal的封装

49 阅读3分钟
优化项
全屏/退出全屏
<template>
  <teleport :to="getContainer()">
    <ProConfigProvider>
      <div class="draggable-modal" :class="{ fullscreen: fullscreenModel }">
        <Modal>
          <template #closeIcon>
            <slot name="closeIcon">
              <Space class="ant-modal-operate" @click.stop>
                <FullscreenOutlined v-if="!fullscreenModel" @click="fullscreenModel = true" />
                <FullscreenExitOutlined v-else @click="restore" />
              </Space>
            </slot>
          </template>
        </Modal>
      </div>
    </ProConfigProvider>
  </teleport>
</template>

<script lang="ts" setup>
  import { ref } from 'vue';
  import { modalProps } from 'ant-design-vue/es/modal/Modal';
  import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
  import { Modal, Space } from 'ant-design-vue';

  const props = defineProps({
    ...modalProps(),
    fullscreen: {
      type: Boolean,
      default: false,
    },
    getContainer: {
      type: Function,
      default: () => document.body,
    },
  });

  // ......
  const fullscreenModel = ref(props.fullscreen);
  const modalWrapRef = ref<HTMLDivElement>();

  // 居中弹窗
  const centerModal = async () => {
    await nextTick();
    const modalEl = modalWrapRef.value?.querySelector<HTMLDivElement>('.ant-modal');

    if (modalEl && modalEl.getBoundingClientRect().left < 1) {
      modalEl.style.left = `${(document.documentElement.clientWidth - modalEl.offsetWidth) / 2}px`;
    }
  };

  const restore = async () => {
    fullscreenModel.value = false;
    centerModal();
  };
</script>

<style lang="less">
  .draggable-modal {
    &.fullscreen {
      .ant-modal {
        inset: 0 !important;
        width: 100% !important;
        max-width: 100vw !important;
        height: 100% !important;
      }

      .ant-modal-content {
        width: 100% !important;
        height: 100% !important;
      }
    }
  }
</style>
提供标题、内容、底部操作、关闭图标的插槽
<template>
  <teleport :to="getContainer()">
    <ProConfigProvider>
      <div>
        <Modal>
          <template #title>
            <slot name="title">{{ $attrs.title || '标题' }}</slot>
          </template>
          <template #closeIcon>
            <slot name="closeIcon">
              <Space class="ant-modal-operate" @click.stop>
                <CloseOutlined @click="closeModal" />
              </Space>
            </slot>
          </template>
          <slot>内容</slot>
          <template v-if="$slots.footer" #footer>
            <slot name="footer"></slot>
          </template>
        </Modal>
      </div>
    </ProConfigProvider>
  </teleport>
</template>

<script lang="ts" setup>
  // ......
  const fullscreenModel = ref(props.fullscreen);
  const modalWrapRef = ref<HTMLDivElement>();

  // 居中弹窗
  const centerModal = async () => {
    await nextTick();
    const modalEl = modalWrapRef.value?.querySelector<HTMLDivElement>('.ant-modal');

    if (modalEl && modalEl.getBoundingClientRect().left < 1) {
      modalEl.style.left = `${(document.documentElement.clientWidth - modalEl.offsetWidth) / 2}px`;
    }
  };

  const restore = async () => {
    fullscreenModel.value = false;
    centerModal();
  };
</script>
可拖拽
<script lang="ts" setup>
  import { useVModel } from '@vueuse/core';

  // 是否已经初始化过了
  let inited = false;

  const visibleModel = useVModel(props, 'open');

  const registerDragTitle = (dragEl: HTMLDivElement, handleEl: HTMLDivElement) => {
    handleEl.style.cursor = 'move';
    handleEl.onmousedown = throttle((e: MouseEvent) => {
      // 禁止用户选中文字
      document.body.style.userSelect = 'none';
      const disX = e.clientX - dragEl.getBoundingClientRect().left;
      const disY = e.clientY - dragEl.getBoundingClientRect().top;
      const mousemove = (event: MouseEvent) => {
        let iL = event.clientX - disX;
        let iT = event.clientY - disY;
        const maxL = document.documentElement.clientWidth - dragEl.offsetWidth;
        const maxT = document.documentElement.clientHeight - dragEl.offsetHeight;

        iL <= 0 && (iL = 0);
        iT <= 0 && (iT = 0);
        iL >= maxL && (iL = maxL);
        iT >= maxT && (iT = maxT);

        dragEl.style.left = `${iL}px`;
        dragEl.style.top = `${iT}px`;
      };
      // 鼠标松开时,清除所有监听
      const mouseup = () => {
        document.removeEventListener('mousemove', mousemove);
        document.removeEventListener('mouseup', mouseup);
        document.body.style.userSelect = 'auto';
      };

      // 监听鼠标移动,鼠标松开
      document.addEventListener('mousemove', mousemove);
      document.addEventListener('mouseup', mouseup);
    }, 20);
  };

  const initDrag = async () => {
    await nextTick();
    const modalWrapRefEl = modalWrapRef.value!;
    const modalWrapEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal-wrap');
    const modalEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal');
    if (modalWrapEl && modalEl) {
      centerModal();
      const headerEl = modalEl.querySelector<HTMLDivElement>('.ant-modal-header');
      headerEl && registerDragTitle(modalEl, headerEl);
    }
    inited = true;
  };

  watch(visibleModel, async (val) => {
    if ((val && Object.is(inited, false)) || props.destroyOnClose) {
      initDrag();
    }
  });
</script>
简易实现
  • 透传props时,过滤掉双向绑定属性(openonUpdate:open
<template>
  <teleport :to="getContainer()">
    <ProConfigProvider>
      <div ref="modalWrapRef" class="draggable-modal">
        <Modal
          v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open'])"
          v-model:open="visibleModel"
          :mask-closable="false"
          :get-container="() => modalWrapRef"
          :width="innerWidth || width"
          @ok="emit('ok')"
          @cancel="emit('cancel')"
        >
          <template #title>
            <slot name="title">{{ $attrs.title || '标题' }}</slot>
          </template>
          <slot></slot>
        </Modal>
      </div>
    </ProConfigProvider>
  </teleport>
</template>

<script lang="ts" setup>
  import { ref, watch } from 'vue';
  import { useRoute } from 'vue-router';
  import { modalProps } from 'ant-design-vue/es/modal/Modal';
  import { useVModel } from '@vueuse/core';
  import { omit } from 'lodash-es';
  import { Modal } from 'ant-design-vue';

  const props = defineProps({
    ...modalProps(),
    fullscreen: {
      type: Boolean,
      default: false,
    },
    getContainer: {
      type: Function,
      default: () => document.body,
    },
  });

  const emit = defineEmits(['update:open', 'update:fullscreen', 'ok', 'cancel']);

  const route = useRoute();
  const visibleModel = useVModel(props, 'open');
  const innerWidth = ref('');

  const modalWrapRef = ref<HTMLDivElement>();

  const closeModal = () => {
    visibleModel.value = false;
    emit('cancel');
  };

  // 页面切换后,确保弹窗关闭
  watch(() => route.fullPath, closeModal);
</script>
useModal组件方式
<template>
  <a-button type="primary" @click="handleOpenUseModal">useModal组件方式</a-button>
  
  <UseModalComp></UseModalComp>
</template>

<script setup lang="tsx">
  import { useModal } from '@/hooks/useModal';

  const [UseModalComp] = useModal();

  const handleOpenUseModal = () => {
    UseModalComp.show({
      title: '我是UseModalComp',
      content: '嘿嘿嘿',
    });
  };
</script>
hook纯函数式
<template>
  <a-button type="primary" @click="handleOpenHookModal">hook纯函数式</a-button>
</template>

<script setup lang="tsx">
  import { useModal } from '@/hooks/useModal';
  
  const [fnModal] = useModal();

  const handleOpenHookModal = () => {
    fnModal.show({
      title: '我是hook纯函数式模态框',
      content: 'hello',
    });
  };
</script>
useModal
import { createVNode, ref, render, getCurrentInstance, nextTick } from 'vue';
import { MyModal, type MyModalInstance } from './modal';
import type { App, ComponentInternalInstance, FunctionalComponent } from 'vue';
import type { HookModalProps } from './types';

let _app: App;

export const useModal = () => {
  let _modalInstance: ComponentInternalInstance;
  const modalRef = ref<MyModalInstance>();
  const appContext = _app?._context || getCurrentInstance()?.appContext;
  // 当前模态框是否处于App.vue上下文中
  const isAppChild = ref(false);

  const getModalInstance = async () => {
    await nextTick();
    if (isAppChild.value && modalRef.value) {
      return modalRef.value;
    }

    if (_modalInstance) {
      return _modalInstance;
    }
    const container = document.createElement('div');
    const vnode = createVNode(MyModal);
    vnode.appContext = appContext;
    render(vnode, container);
    _modalInstance = vnode.component!;
    _modalInstance.props.closeModal = hide;
    return _modalInstance;
  };

  const setProps = async (_props) => {
    const instance = await getModalInstance();
    if (Object.is(instance, modalRef.value)) {
      console.log('useModal组件方式');
      // useModal组件方式
      // @ts-ignore
      instance?.setProps?.(_props);
    } else {
      console.log('hook纯函数式');
      // hook纯函数式
      // @ts-ignore
      instance?.exposed?.setProps?.(_props);
    }
  };

  const hide = () => {
    setProps({ open: false });
  };

  const show = async (props: HookModalProps) => {
    setProps({
      ...props,
      closeModal: hide,
      open: true,
    });

    await nextTick();
  };

  interface ModalRenderComp<T> extends FunctionalComponent<T> {
    show: typeof show;
    hide: typeof hide;
    setProps: typeof setProps;
  }

  const ModalRender: ModalRenderComp<HookModalProps> = (props, { attrs, slots }) => {
    isAppChild.value = true;
    return <MyModal ref={modalRef} {...{ ...attrs, ...props }} v-slots={slots} />;
  };

  ModalRender.show = show;
  ModalRender.hide = hide;
  ModalRender.setProps = setProps;

  // ;[show, hide].forEach(fn => ModalRender[fn.name] = fn)

  return [ModalRender, modalRef] as const;
};

export type ModalInstance = ReturnType<typeof useModal>;

export const installUseModal = (app: App) => {
  _app = app;
};
modal
import { defineComponent, watch, ref, computed, unref } from 'vue';
import { omit } from 'lodash-es';
import type { HookModalProps } from './types';
import { isFunction } from '@/utils/is';
import { DraggableModal } from '@/components/core/draggable-modal';

export type MyModalInstance = InstanceType<typeof MyModal>;

export const MyModal = defineComponent({
  props: {
    content: {
      type: [String, Function] as PropType<string | JSX.Element | (() => JSX.Element)>,
    },
    closeModal: Function,
    open: Boolean,
  },
  setup(props, { attrs, expose }) {
    const confirmLoading = ref<boolean>(false);

    const propsRef = ref({ ...attrs, ...props });

    const getProps = computed(() => {
      return { ...attrs, ...props, ...unref(propsRef) };
    });

    const bindValues = computed(() => {
      const _props = unref(getProps);

      return {
        // 透传时,过滤不属于modal的属性
        ...omit(_props, ['onCancel', 'onOk', 'closeModal', 'content']),
        open: _props.open,
        confirmLoading: confirmLoading.value,
        onCancel: handleCancel,
        onOk: handleConfirm,
      };
    });

    const setVisible = (open: boolean) => {
      propsRef.value.open = open;
    };

    const setProps = (props: HookModalProps) => {
      propsRef.value = {
        ...unref(getProps),
        ...props,
      };
    };

    // 监听open为false时,关闭modal
    watch(
      () => propsRef.value.open,
      (val) => {
        Object.is(val, false) && props.closeModal?.();
      },
    );

    const handleConfirm = async (e: MouseEvent) => {
      confirmLoading.value = true;
      try {
        // 等待onOk中的异步执行完成后,再关闭modal
        // @ts-ignore
        await unref(getProps)?.onOk?.(e);
        setVisible(false);
      } catch (error) {
        return Promise.reject(error);
      } finally {
        confirmLoading.value = false;
      }
    };
    const handleCancel = async (e: MouseEvent) => {
      // 等待onCancel中的异步执行完成后,再关闭modal
      // @ts-ignore
      await unref(getProps)?.onCancel?.(e);
      setVisible(false);
    };

    expose({
      setProps,
    });

    return () => {
      const _props = unref(getProps);
      const { content } = _props;

      const Content = isFunction(content) ? content() : content;

      return <DraggableModal {...unref(bindValues)}>{Content}</DraggableModal>;
    };
  },
});