用Element Plus打造命令式Modal弹窗 - 仿Arco Design Vue最佳实践

3,684 阅读2分钟

在开发管理后台时,我们常遇到需要快速调用确认框的场景。Element Plus默认的ElDialog采用声明式写法,需要维护v-model状态,而Arco Design Vue的命令式调用方式(如Modal.open())明显更高效

由于公司的项目是Element Plus,在开发过程中,为了提高开发效率和维护,封装了一个仿Arco Design Vue的Modal命令弹窗,实现函数调用

Arco Design Vue的Modal命令式弹窗个人觉得设计很不错,下面展示下Arco组件库Modal的一些使用示例:

code.png

code.png

虽然目前Element Plus也可以使用ElMessageBox来实现命令式弹窗,但是无法自定义底部插槽,也无法全屏等等,最好办法是基于el-dialog实现命令式弹窗

code-demo.png

下面是基于Element Plus的el-dialog封装的命令式弹窗组件

index.vue

DialogVue.png

Dialog.tsx

DialogTsx.png

使用示例

DialogDemo.png

演示效果

20250223201925_rec_.gif

源码 index.vue

<template>
  <el-dialog v-bind="dialogProps" :fullscreen="fullscreen" v-model="visible">
    <template #header>
      <el-row>
        <div class="el-dialog__title cecw-dialog__title">
          <slot name="title">{{ props.title }}</slot>
        </div>
        <el-space size="default">
          <button type="button" class="el-dialog__headerbtn" @click="fullscreen = !fullscreen">
            <el-icon class="el-dialog__close"><FullScreen /></el-icon>
          </button>
          <button type="button" class="el-dialog__headerbtn" @click="visible = false">
            <el-icon class="el-dialog__close"><Close /></el-icon>
          </button>
        </el-space>
      </el-row>
    </template>
    <slot>
      <template v-if="typeof props.content === 'string'">
        <p>{{ props.content }}</p>
      </template>
      <template v-if="typeof props.content === 'function'">
        <component :is="props?.content?.()"></component>
      </template>
    </slot>
    <template v-if="props.footer" #footer>
      <slot name="footer">
        <template v-if="typeof props.footer === 'boolean'">
          <el-button v-bind="props.cancelButtonProps" @click="handleCancel">{{ props.cancelText }}</el-button>
          <el-button type="primary" v-bind="props.okButtonProps" :loading="okLoading" @click="handleOk">{{
            props.okText
          }}</el-button>
        </template>
        <template v-else>
          <component :is="props.footer()"></component>
        </template>
      </slot>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { defineProps, defineSlots, computed, ref, type VNode } from 'vue';
import type { DialogProps, ButtonProps } from 'element-plus';
import { Close, FullScreen } from '@element-plus/icons-vue';

defineOptions({ name: 'CecwDialog' });

interface Props extends Partial<DialogProps> {
  content?: string | (() => VNode);
  footer?: boolean | (() => VNode);
  okText?: string;
  cancelText?: string;
  okButtonProps?: Partial<ButtonProps>;
  cancelButtonProps?: Partial<ButtonProps>;
  onOk?: () => void;
  onBeforeOk?: () => Promise<boolean>;
  onCancel?: () => void;
}

const props = withDefaults(defineProps<Props>(), {
  closeOnClickModal: true,
  footer: true,
  okText: '确认',
  cancelText: '取消'
});

defineSlots<{
  title: () => VNode;
  footer: () => VNode;
  default: () => VNode;
}>();

const visible = defineModel('modelValue', {
  type: Boolean,
  default: false
});

const dialogProps = computed(() => {
  return {
    ...props,
    content: undefined,
    footer: undefined,
    okText: undefined,
    cancelText: undefined,
    okButtonProps: undefined,
    cancelButtonProps: undefined,
    onOk: undefined,
    onBeforeOk: undefined,
    onCancel: undefined
  };
});

const okLoading = ref(false);
const fullscreen = ref(false);

const handleCancel = () => {
  props.onCancel?.();
  visible.value = false;
};

const handleOk = async () => {
  if (props.onBeforeOk) {
    try {
      okLoading.value = true;
      const flag = await props.onBeforeOk();
      if (flag) {
        okLoading.value = false;
        visible.value = false;
      }
    } catch (error) {
      okLoading.value = false;
    }
  } else {
    props.onOk?.();
    visible.value = false;
  }
};
</script>

<style lang="scss" scoped>
:deep(.el-dialog__headerbtn) {
  position: static;
  width: auto;
  height: auto;
}

.cecw-dialog__title {
  flex: 1;
}
</style>

Dialog.tsx

import { createApp, h, ref, AppContext } from 'vue';
import type { DialogInstance as CecwDialogInstance } from './index';
import ElementPlus from 'element-plus';
import CecwDialog from './index.vue';

type DialogOptions = Partial<CecwDialogInstance['$props']>;

export interface DialogInstance {
  close: () => void;
  update: (newProps?: Record<string, any>) => void;
}

const defaultOptions: DialogOptions = {
  width: '600px',
  center: false,
  footer: true,
  closeOnClickModal: true
};

export function createDialog(appContext?: AppContext) {
  const dialog = {
    // 核心创建方法
    create(options: DialogOptions): DialogInstance {
      const mergedOptions = { ...defaultOptions, ...options };
      let context = null;
      // 创建容器
      const container = document.createElement('div');
      document.body.appendChild(container);

      // 状态管理
      const visible = ref(true);
      const dialogOptions = ref(mergedOptions || {});

      // 创建弹窗应用
      const dialogApp = createApp({
        setup() {
          // context = appContext || getCurrentInstance()?.appContext;
          // console.log('getCurrentInstance', getCurrentInstance());
          // 关闭处理
          const closed = () => {
            dialogApp.unmount();
            container.remove();
          };

          return () =>
            h(CecwDialog, {
              ...dialogOptions.value,
              modelValue: visible.value,
              'onUpdate:modelValue': (val: boolean) => (visible.value = val),
              onClosed: () => closed()
            });
        }
      });

      dialogApp.use(ElementPlus);

      // 继承上下文
      if (context) {
        dialogApp._context = Object.assign({}, context);
        // dialogApp.config.globalProperties = context.config.globalProperties;
      }

      // 挂载
      dialogApp.mount(container);

      return {
        /** 关闭对话框 */
        close: () => {
          visible.value = false;
          setTimeout(() => {
            dialogApp.unmount();
            container.remove();
          }, 300);
        },
        /** 更新对话框 */
        update: (newProps?: Record<string, any>) => {
          dialogOptions.value = { ...dialogOptions.value, ...newProps };
        }
      };
    },

    /** 对话框-打开 */
    open(options: DialogOptions) {
      return this.create(options);
    }
  };

  return dialog;
}

// 默认导出实例
export const Dialog = createDialog();