高性能 v-html 弹窗实现:Vue3 + Element Plus 最佳实践

在现代前端开发中,尤其是低代码平台的场景下,我们常会遇到一个特殊问题:业务要求只能通过 v-html 渲染的内容触发弹窗。这意味着传统的组件引用、模板事件绑定等方式无法直接使用。

一开始,我尝试过两种方式:

  1. 在 DOM 上直接挂载 <el-dialog>
  2. 通过 iframe 引入独立的 Vue 页面

结果发现问题明显:

  • iframe 的渲染性能低,页面打开慢,动画和样式容易错乱
  • 直接挂 DOM 的方式,关闭和销毁逻辑复杂,如果处理不当会导致内存泄漏
  • 同时打开多个弹窗时,需要额外管理容器和组件实例

为了应对这些问题,我整理出了一套 高性能、极简、支持多开、自动销毁的 Vue3 + Element Plus 弹窗方案 ,专门针对 v-html 弹窗触发场景


方案设计思路

  1. 全局弹窗服务dialogModalService.js 负责创建 Vue 实例、挂载组件、传递参数和销毁实例
  2. 子组件只暴露 open 方法:组件自身只负责显示内容,关闭时调用父组件传入的销毁方法
  3. 全局方法挂载 :在 main.jsopenGlobalDialogModal 挂在 window 上,v-html 可直接调用

核心原则:点击 v-html 渲染的按钮 → 调用全局方法 → 服务创建独立容器挂载组件 → 关闭时自动销毁。


1️⃣ 弹窗服务 dialogModalService.js

javascript 复制代码
// src/utils/dialogModalService.js
import { createVNode, render } from 'vue'
import FundPlanSummaryDialog from '@/pages/office/fund/fundPlanSummary/fundPlanSummaryDialog.vue'

// 弹窗注册表
const modalRegistry = {
    fundPlanSummaryDialog: FundPlanSummaryDialog
}

// 保存弹窗容器列表
const dialogList = []

let globalApp = null

/**
 * 初始化弹窗服务(仅需调用一次)
 * @param {App} app - Vue 主应用实例
 */
export function initDialogModalService(app) {
    globalApp = app
    window.openGlobalDialogModal = openGlobalDialogModal
}

export function openGlobalDialogModal(name, params = {}) {
    if (!globalApp) {
        console.warn('[dialogModalService] 请先 initDialogModalService(app)')
        return
    }

    const DialogComponent = modalRegistry[name]
    if (!DialogComponent) {
        console.warn('[dialogModalService] 弹窗未注册:', name)
        return
    }

    // 创建独立容器
    const container = document.createElement('div')
    document.body.appendChild(container)
    dialogList.push(container)

    const vnode = createVNode(DialogComponent, {
        params,
        onDestroy: () => {
            render(null, container)
            container.remove()
            const idx = dialogList.indexOf(container)
            if (idx > -1) dialogList.splice(idx, 1)
        }
    })

    // 复用主 app 的上下文(el-dialog 全局组件、i18n 等插件都能正常工作)
    vnode.appContext = globalApp._context

    render(vnode, container)

    // 调用弹窗组件暴露的 open 方法
    vnode.component?.exposed?.open?.(params)
}

2️⃣ main.js 初始化弹窗服务

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { initDialogModalService } from '@/utils/dialogModalService.js'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import i18n from '@/assets/lang'

const app = createApp(App)

app.use(ElementPlus, { locale: zhCn })
app.use(i18n)

// 初始化弹窗服务(传入主 app 实例)
initDialogModalService(app)

app.mount('#app')

3️⃣ 弹窗组件 FundPlanSummaryDialog.vue

必须执行 props.onDestroy?.(),保证销毁

vue 复制代码
<template>
  <el-dialog
      v-model="dialogVisible"
      title="资金计划明细"
      width="90vw"
      :destroy-on-close="true"
      draggable
      top="10px">
    <div class="dialog-content">
      <fund-plan-summary-index
          v-if="dialogVisible"
          :plan-month="planMonth"
          :show-toolbar-buttons="false"/>
    </div>
    <template #footer>
      <div class="dialog-footer">
        <el-button type="primary" @click="closeDialog">关闭</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  onDestroy: { type: Function, required: true }
})

const dialogVisible = ref(false)
const planMonth = ref('')

const open = (params = {}) => {
  planMonth.value = params.planMonth || ''
  dialogVisible.value = true
}

const closeDialog = () => {
  dialogVisible.value = false
  // 必须执行,保证销毁
  props.onDestroy?.()
}

defineExpose({ open })
</script>

4️⃣ 页面 v-html 调用示例

vue 复制代码
<template>
  <div v-html="htmlStr"></div>
</template>

<script setup>
import { ref } from 'vue'

const htmlStr = ref(`
  <button onclick="openGlobalDialogModal('fundPlanSummaryDialog', {planMonth:'2024-01'})">
    打开资金计划弹窗
  </button>
`)
</script>

v-html 调用示例:

vue 复制代码
<template>
  <div v-html="htmlStr"></div>
</template>

<script setup>
import { ref } from 'vue'
import { getFundPlanSummaryButton } from '@/api/office/fund/fundPlanSummary'

const htmlStr = ref('')

// 模拟从后端获取 HTML 字符串
const loadHtml = async () => {
  const res = await getFundPlanSummaryButton({ planMonth: '2024-01' })
  htmlStr.value = res.data // 后端返回带 onclick 的 HTML
}

loadHtml()
</script>

优势与总结

  • 复用主 app 上下文 :使用 vnode.appContext = globalApp._context,无需创建独立 app,el-dialog 全局组件、i18n 等插件都能正常工作
  • 多弹窗支持:每个弹窗独立容器,互不干扰
  • 高性能:不使用 iframe,不直接挂 DOM
  • v-html 可直接触发
  • 安全稳定:点击关闭立即卸载实例,防止内存泄漏
  • 极简实现:无需管理独立 app 的插件安装和全局属性赋值

该方案适用于 v-html 弹窗触发场景,性能高、易维护,支持多人协作和扩展。

相关推荐
RuoyiOffice1 天前
SpringBoot3+Vue3 流程抄送已读:打开详情消红点,待办与知会怎么分开
spring boot·vue3·flowable·spring boot 3·ruoyi office·流程抄送·已读回执
志尊宝1 天前
Vue3 零基础每日笔记(038):亲手封装 useDebounceFn 与 useThrottleFn——高频事件的流量阀
前端·javascript·vue·html·vue3
志尊宝2 天前
Vue3 零基础每日笔记(043):defineModel 与 defineExpose 的 TS 用法——3.4+ 新 API 进阶
前端·javascript·vue·html·vue3
RuoyiOffice3 天前
SpringBoot3+Vue3+Flowable 企业审批平台全链路:从表单设计、节点权限到移动审批怎么落地
spring boot·vue3·uniapp·springboot3·flowable·审批系统·ruoyi office
RuoyiOffice3 天前
SpringBoot3+Vue3 企业级管理平台架构:多租户、RBAC、数据权限、工作流与微服务一次讲透
spring boot·vue3·springboot3·rbac·多租户·数据权限·ruoyi office
Bs_MoneyMagnet3 天前
基于springboot+vue的个人健康管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·vue3·springboot3·计算机毕业设计
志尊宝4 天前
Vue3 零基础每日笔记(026):emit 子传父——defineEmits 与事件校验
前端·javascript·html·vue3
RuoyiOffice4 天前
SpringBoot3+Vue3 库存盘点:账面和实盘对不上,差异单怎么走审批再调账
spring boot·vue3·erp·spring boot 3·库存盘点·盘盈盘亏
RuoyiOffice4 天前
SpringBoot3+PaddleOCR 发票识别:不接云端大模型,本地飞桨怎么把专票读进进项单
spring boot·vue3·飞桨·paddleocr·spring boot 3·发票ocr·进项发票
RuoyiOffice5 天前
SpringBoot3+Vue3 项目立项到甘特:审批冻结台账、任务树怎么汇进度
spring boot·项目管理·vue3·甘特图·wbs·spring boot 3·立项审批