vue3封装弹窗组件

遇到的问题

  • 不能用vue2中的new Vue(Dialog) 要用vue3中的createNode()+render
  • 创建出来的组件是野生的,独立的,不属于任何app,拿不到全局注册的elementuiPlus组件
  • 想要用app注册的全局组件,需要给创建的弹窗组件指定上下文(appContext)
  • 也不能直接将整个 app 实例 挂载到了弹窗容器上,导致 Vue 认为你要在同一个 div 上挂载两个根应用。
  • 要获取上下文 getCurrentInstance,import{ getCurrentInstance }from'vue',
  • 而getCurrentInstance() 只能在 setup 内部运行,在普通 JS 函数里运行就是 null!
  • 所以最好在APP.vue 中全局获取,然后传给dialog, dialog.appContext = appContext // 传递 appContext 以支持全局组件和插件
    传值,使用异步函数resolve出来,
    封装的函数 里定义函数。去传给封装的组件,组件在调用函数时传值给他

总结

  • vue3中创建的应用是独立,野生的,也就是具备应用隔离,那么也很适合微前端开发
  • Vue2 是全局单例架构,所有 Vue 实例共享同一个全局上下文,因此天然不需要手动传递上下文;Vue3 是应用隔离架构,每个 createApp 都是独立实例,上下文不共享,手动渲染的组件必须显式指定 appContext 才能继承全局插件

1. 整体架构介绍

  • “实现了一个函数式的弹窗系统,核心是 popup 方法,它基于 Vue 3 的 createVNode 和 render 动态渲染弹窗组件,并返回一个 Promise 来处理异步结果。”
    2. 返回值获取机制
  • “popup 方法返回一个 Promise 对象,调用方可以通过 await 或 .then() 来获取弹窗的用户操作结果。”
  • 示例代码:
    3. 内部实现细节
  • “在 popup 内部,我创建了一个 Promise,并在 createVNode 时传递 onResult 回调给 Dialog 组件。”
  • “当用户在弹窗中点击按钮(如确认或取消)时,Dialog 组件会调用 prop.onResult(data),传递操作数据(如 { action: 'confirm' })。”
  • “onResult 回调接收数据后,执行清理 DOM 的逻辑,然后 resolve(data) 解析 Promise,让调用方拿到返回值。”
    4. 优势和设计考虑
  • “这种设计让弹窗调用像同步函数一样简单,支持 await,同时内部处理了 DOM 清理和错误边界,避免内存泄漏。”
  • “如果弹窗被关闭而没有操作,会通过 onClose 回调 resolve(null),确保 Promise 总是完成。”
    5. 潜在问题和优化
  • “在实现中,添加了参数验证和错误处理,确保调用安全。如果组件加载失败,会 reject Promise。”
  • “为了支持 Element Plus 等全局组件,传递了 appContext。”

Dialog.vue

<template>
   <div class="openHmtl">
  <el-dialog
    v-model="dialogVisible"
    :title="title"
    :width="width"
    :before-close="handleClose"
    class="tanchuan"
  >
    <component :is="page" :param="param" @confirm="onPageOk" @cancel="onPageCancel"
          @disabled="disabledButton" @setButtons="getButtons"/>
    <template #footer v-if="buttons && buttons.length > 0">
      <div class="dialog-footer">
       <el-button v-for="button of buttons" @click="buttonClicked(button)" :key="button.name" :type="button.name === '确认' ? 'primary' : 'default'"
          :disabled="button.disabled ? button.disabled : false" style="margin-right: 10px;">{{ button.name
          }}</el-button>
      </div>
    </template>
  </el-dialog>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
const prop = defineProps({
  title: {
    type: String,
    default: '标题'
  },
  param: {
    type: Object,
    default: () => ({})
  },
  page: {
    type: [Object, Function],
    default: ''
  },
  // 兼容 onClose 事件回调
  onClose: Function,
  onResult: Function
})

const width = ref('40%')
const buttons = ref([])
const dialogVisible = ref(true)

const getButtons = (btns) => {
  if (!Array.isArray(btns)) {
    console.warn('Dialog: setButtons payload must be an array', btns)
    return
  }
  buttons.value = btns.map((button) => ({
    ...button,
    disabled: button.disabled || false
  }))
}

const handleClose = () => {
  if (typeof prop.onClose === 'function') {
    prop.onClose()
  }
}

const onPageOk = (data) => {
  if (typeof prop.onResult === 'function') {
    prop.onResult(data || { action: 'confirm' })
  }
  dialogVisible.value = false
}

const onPageCancel = () => {
  if (typeof prop.onResult === 'function') {
    prop.onResult({ action: 'cancel' })
  }
  if (typeof prop.onClose === 'function') {
    prop.onClose()
  }
  dialogVisible.value = false
}

const disabledButton = (buttonName, disabled) => {
  const button = buttons.value.find((btn) => btn.name === buttonName)
  if (button) {
    button.disabled = disabled
  }
}

const buttonClicked = (button) => {
  if (typeof button.click === 'function') {
    button.click()
  }
  dialogVisible.value = false
}

</script>

<style scoped lang="scss">
.el-dialog {
  border-radius: 5px;
  position: absolute;
  top: 50%;
  left: 50%;
  margin: 0 !important;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
}
.tanchuan :deep(.el-dialog__body){
  border-bottom: 1px solid gainsboro;
  border-top: 1px solid gainsboro;
  overflow-y: scroll;
  height: 80%;
  max-height: 500px;
}

.openHmtl :deep(.el-dialog__header) {
  font-weight: 600;
  font-size: 18px;
  color: #D3602B;
}
</style>

utlis.js

// 全局存储 appContext(只赋值一次)
let appContext = null
export function setDialogContext(ctx) {
  appContext = ctx
}
/**
   * 弹窗函数调用(Vue3标准写法,返回Promise)
   * @param {string} title 弹窗标题
   * @param {string} path 组件路径(views下的文件名,不带.vue)
   * @param {object} param 传递给组件的参数
   * @param {number|string} [width] 弹窗宽度(百分比或像素,默认40%)
   * @returns {Promise} 返回弹窗结果的Promise
   */
  popup(title, path, param = {}, width = '40%') {
    return new Promise((resolve, reject) => {
      if (!title || !path) {
        reject(new Error('popup: title and path are required'))
        return
      }

      const container = document.createElement('div')

      // 不设置 pointerEvents,保证 el-dialog 能正常交互

      const cleanup = () => {
        render(null, container)
        if (document.body.contains(container)) {
          document.body.removeChild(container)
        }
      }

      let page
      try {
        page = getAsyncComponent(path)
        if (!page) {
          throw new Error(`popup: component ${path} not found`)
        }
      } catch (error) {
        reject(error)
        return
      }

      try {
        document.body.appendChild(container)
        const dialog = createVNode(Dialog, {
          title,
          page,
          param,
          width,
          onClose: () => {
            cleanup()
          },
          onResult: (data) => {
            cleanup()
            resolve(data)
          },
        })
        dialog.appContext = appContext // 传递 appContext 以支持全局组件和插件
        render(dialog, container)
      } catch (error) {
        console.error('popup: failed to render dialog', error)
        cleanup()
        reject(error)
      }
    })
  },

App.vue

onMounted(() => {
  // 全局存一次,永久生效
  const { appContext } = getCurrentInstance()
  setDialogContext(appContext)
})

a.vue


 <el-button
    type="primary"
    @click="freestyle"
  >享受自由人生</el-button>
  
  
const freestyle = () => {
  utils.popup('享受自由人生', 'freestyle');
};
posted @ 2026-04-21 16:52  张尊娟  阅读(8)  评论(0)    收藏  举报