Vue3 实现 Modal 组件

使用 Vue3 设计和实现一个可复用的 Modal 弹窗组件

问题

如何使用 Vue3 实现一个功能完整的 Modal 组件,支持组件引入和 API 调用两种方式?

解答

组件设计思路

Modal 组件需要支持不同场景的复用,比如新增和编辑弹窗,只需通过传入不同参数来显示不同内容,避免重复开发。

核心功能包括:遮罩层、标题、主体内容、确定和取消按钮。主体内容需要灵活支持字符串、HTML 或自定义组件。

目录结构

├── plugins
│   └── modal
│       ├── Content.tsx        // 维护 Modal 内容,支持 h 函数和 JSX
│       ├── Modal.vue          // 基础组件
│       ├── config.ts          // 全局默认配置
│       ├── index.ts           // 入口文件
│       ├── locale             // 国际化
│       │   ├── index.ts
│       │   └── lang
│       │       ├── en-US.ts
│       │       ├── zh-CN.ts
│       │       └── zh-TW.ts
│       └── modal.type.ts      // TypeScript 类型声明

组件实现

Modal.vue 基础结构

<template>
  <Teleport to="body" :disabled="!isTeleport">
    <div v-if="modelValue" class="modal">
      <div class="modal__mask"></div>
      <div class="modal__wrapper">
        <div class="modal__container">
          <div class="modal__header">
            <span>{{ title }}</span>
          </div>
          <div class="modal__content">
            <Content v-if="typeof content === 'function'" :render="content" />
            <slot v-else>{{ content }}</slot>
          </div>
          <div class="modal__footer">
            <button @click="handleCancel">取消</button>
            <button @click="handleConfirm">确定</button>
          </div>
        </div>
      </div>
    </div>
  </Teleport>
</template>

使用 Teleport 将 Modal 传送到 body,避免 z-index 和定位问题。

主体内容处理

支持三种方式传入内容:

<!-- 1. 默认插槽 -->
<Modal v-model="show" title="演示 slot">
  <div>hello world~</div>
</Modal>

<!-- 2. 字符串 -->
<Modal v-model="show" title="演示 content" content="hello world~" />
// 3. h 函数
$modal.show({
  title: '演示 h 函数',
  content(h) {
    return h('div', {
      style: 'color:red;',
      onClick: ($event) => console.log('clicked', $event.target)
    }, 'hello world~');
  }
});

// 4. JSX 语法
$modal.show({
  title: '演示 JSX',
  content() {
    return (
      <div onClick={($event) => console.log('clicked', $event.target)}>
        hello world~
      </div>
    );
  }
});

API 调用方式

Vue3 中使用 createVNoderender 实现动态创建组件:

import { createVNode, render } from 'vue';
import Modal from './Modal.vue';

const container = document.createElement('div');
const vnode = createVNode(Modal);
render(vnode, container);
const instance = vnode.component;
document.body.appendChild(container);

挂载到全局属性:

// index.ts
export default {
  install(app) {
    app.config.globalProperties.$modal = {
      show(options) {
        // 创建组件实例并显示
      }
    };
  }
};

事件处理

使用 Composition API 处理确定和取消事件:

// Modal.vue
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);

const handleConfirm = () => {
  emit('confirm');
  emit('update:modelValue', false);
};

const handleCancel = () => {
  emit('cancel');
  emit('update:modelValue', false);
};

API 调用时通过 _hub 属性监听事件:

app.config.globalProperties.$modal = {
  show({ onConfirm, onCancel, ...options }) {
    const { props, _hub } = instance;
    
    const _closeModal = () => {
      props.modelValue = false;
    };
    
    if (onConfirm) {
      _hub.on('confirm', () => {
        onConfirm();
        _closeModal();
      });
    }
    
    if (onCancel) {
      _hub.on('cancel', () => {
        onCancel();
        _closeModal();
      });
    }
  }
};

关键点

  • 使用 Teleport 将 Modal 挂载到 body,解决层级和定位问题
  • 通过 createVNoderender 实现 API 调用方式,替代 Vue2 的 Vue.extend
  • 主体内容支持插槽、字符串、h 函数和 JSX 四种方式,提供灵活性
  • 使用 app.config.globalProperties 挂载全局方法,适配 Vue3 的 setup 语法
  • 通过 emit 和事件中心 _hub 两种方式处理事件,同时支持组件和 API 调用