稀土掘金技术社区 05月14日 10:07
Vue动态弹窗(Dialog)新境界:告别繁琐,拥抱优雅!
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文分享了一种在 Vue 中优雅调用弹窗的架构方案,主要针对低代码平台等场景下,组件配置弹窗的管理问题。该方案的核心是将通用的弹窗逻辑抽离,让每个组件的配置面板专注于自身 UI 视图和数据处理,从而实现高内聚、低耦合、易扩展。通过动态创建和静态方法关联,简化了弹窗的维护,提高了代码的可维护性。文章详细介绍了架构设计、目录结构、关键代码实现,以及核心工具函数的使用,为开发者提供了实用的参考。

💡 采用动态创建和静态方法关联的架构,将通用的弹窗逻辑抽离,使每个组件的配置面板专注于自身 UI 视图和数据处理。

✨ 核心工具函数 `dialogWithComponent` 负责动态创建、挂载、销毁弹窗组件,并处理确认、取消等交互逻辑,简化了组件内部的代码。

📦 每个组件的 Dialog 入口文件(如 `BarChart/Dialog/index.js`)负责调用 `dialogWithComponent`,并可以在此处预设弹窗的配置,例如标题和宽度。

✅ 每个组件的 Panel.vue 组件仅放置该组件特有的配置信息,并通过 `getValue` 方法对外提供配置数据,实现高内聚、低耦合。

原创 橙某人 2025-05-08 08:30 重庆

点击关注公众号,“技术干货”及时达!

(💰金石瓜分计划回归,速戳下图了解详情🔍)

写在开头

嘿,各位好呀!😀

本次要分享的是关于如何「在 Vue 中比较优雅的调用弹窗」的过程,请诸君按需食用哈。

需求背景

最近,小编在捣鼓一个和低代码拖动交互类似的业务,说到低代码,大家肯定都不陌生吧❓像低代码表单、低代码图表平台等,用户可以通过简单的拖拽操作,像搭积木一样,快速 "拼" 出一个功能完善的表单页面,或者酷炫的数据可视化大屏。

而在这些低代码平台中,配置组件属性的「交互方式」通常有两种主流玩法:

其一,三栏式布局,左边是组件列表,中间是画布 / 预览区,右边是属性配置面板。选中中间画布的某个组件,右侧面板就自动显示它的配置项,如下:

其二,弹窗式配置,同样从左侧拖拽组件到画布,但选中组件后,通常会看到一个 "设置" 或 "编辑" 按钮。点击这个按钮,Duang~ ✨ 弹出一个专门的配置窗口 (Dialog),让你在里面集中完成所有设置。

这两种交互各有千秋,不评判好坏哈,反正合适自己业务场景的才是最好的。

然,今天咱们重点聚焦第二种:点击按钮弹出 Dialog 进行配置的场景。

这种方式在很多场景下也很常见,比如配置项特别多、需要更「沉浸式的设置体验」时。

但问题也随之而来:如果平台支持的组件越来越多,这里咱们假设是低代码图表场景,如柱状图、折线图、饼图、地图、文本、图片... 等等,每个组件都需要一个独立的配置弹窗...🤔,那么,我们应该如何设计一套优雅、可扩展、易维护的代码架构来管理这些层出不穷的 Dialog 呢?🤔

结构设计

万事开头难,尤其是在做一些稍微带点设计或架构意味的事情时,切忌盲目上手。心里得先有个谱,想清楚大致方向,否则等到后面业务需求像潮水般涌来,迭代压力陡增时,你就会深刻体会到早期设计不佳带来的痛苦了(别问小编是怎么知道的...😭)。

当然,如果你已是经验丰富的老司机,那就当我没说哈。😂

面对 "组件点击按钮弹出配置框" 这个需求,最开始,最直观的想法可能就是:一个组件配一个专属的 Dialog.vue 文件,相互独立,互不影响,挺好不是❓

比如,咱当前有柱状图、折线图、饼图三个组件,那么它们的目录结构可能是这样子的:

src/
├── components/
│   ├── BarChart/
│   │   ├── Drag.vue  # 组件的拖动视图
│   │   ├── Dialog.vue # 组件的配置弹窗
│   │   └── index.js  # 组件的Model
│   ├── LineChart/
│   │   ├── Drag.vue  # 组件的拖动视图
│   │   ├── Dialog.vue # 组件的配置弹窗
│   │   └── index.js  # 组件的Model
│   ├── PieChart/
│   │   ├── Drag.vue  # 组件的拖动视图
│   │   ├── Dialog.vue # 组件的配置弹窗
│   │   └── index.js  # 组件的Model
└── App.vue # 入口


咱们不详说其他文件中的代码情况,仅关注每个组件中 Dialog.vue 文件的代码要如何写❓

可能大概是这样:

<template>
  <el-dialog :modelValue="modelValue">
    <div>内容....</div>
  </el-dialog>
</template>
<script>
defineProps({
  modelValue: Boolean,
});
</script>


小编这里使用 Element-Plus[1] 的 el-dialog 组件作为案例演示。

然后,为了在页面上渲染这些不同组件的 Dialog.vue,最笨的方法可能是在父组件里面用 v-if/v-else-if 来判断, 或者高级一点使用 <component :is="currentDialog"> 再配合一堆 import 来动态加载渲染。父组件需要维护哪个弹窗应该显示的状态,以及负责传递数据和接收结果,逻辑很快变得复杂且难以维护。

在项目初期,组件类型少的时候,这种方式确实能跑通,没有问题❗

你就说它能不能跑吧,就算它不能跑,你能跑不就行😋,项目和你总有一个能跑的。

但随着业务不断迭代,支持的组件类型越来越多,这种 "各自为战" 的模式很快就暴露出了诸多问题,其中有两个问题比较尖锐:

    缺乏统一控制📝:如果想给所有弹窗统一调整弹窗配置、或者添加一个水印、或者调整一下默认样式、或者增加一个通用的 "重置" 按钮,怎么办?只能去每个 Dialog.vue 文件里手动修改,效率低下不说,还极易遗漏或出错。
    代码冗余严重📜:每个 Dialog.vue 文件里,关于弹窗的显示 / 隐藏逻辑、确认 / 取消按钮的处理、与 Element Plus (或其他 UI 库) ElDialog 组件的交互代码,几乎都是大同小异的模板代码,写到后面简直是精神污染。(这里手动 Q 一下我同事🔨)

总之,随着项目的迭代,这种最初看似简单的结构,维护成本越来越高,每次增加或修改一个组件的配置弹窗都成了一种 "折磨"。

那么,要如何重新来设计这个架构呢❓

小编采用的是基于动态创建和静态方法关联的架构,其架构的核心理念就是:将通用的弹窗逻辑(创建、销毁、交互)抽离出来,让每个组件的配置面板(Panel)只专注于自身的配置项 UI 视图和数据处理逻辑 ,从而实现高内聚、低耦合、易扩展的目标。

先来瞅瞅目录结构的最终情况👇:

src/
├── components/
│   ├── BarChart/
│   │   ├── Dialog/
│   │   |   ├── index.js   # Dialog 组件的入口
│   │   |   ├── Panel.vue  # Dialog 组件UI视图
│   │   ├── Drag.vue
│   │   └── index.js  
│   ├── LineChart/
│   │   ├── Dialog/
│   │   |   ├── index.js   # Dialog 组件的入口
│   │   |   ├── Panel.vue  # Dialog 组件UI视图
│   │   ├── Drag.vue
│   │   └── index.js
│   ├── PieChart/
│   │   ├── Dialog/
│   │   |   ├── index.js   # Dialog 组件的入口
│   │   |   ├── Panel.vue  # Dialog 组件UI视图
│   │   ├── Drag.vue
│   │   └── index.js
│   ├── BaseDialog.vue
│   └── index.js
├── utils/
│   ├── BaseControl.js
│   └── dialog.js
└── App.vue # 入口


关键变动是 Dialog.vue 变成了 Dialog/index.js 与 Dialog/Panel.vue,它们俩的作用:

    Panel.vue:负责 "长什么样" 和 "填什么数据" 。
    index.js:负责 "怎么被调用" 和 "调用时带什么默认配置",并将 Panel.vue 包装后提供给外部使用。

具体实现

接下来,咱们就详细拆解一下这套新架构的设计具体代码实现过程。👇

但为了更好的讲述关键代码的实现,咱们不管拖动那块逻辑,仅通过点击按钮简单的来模拟,效果如下:

本次小编是新建了一个 Vue3 的项目并且安装了 ElementPlus 进行了全局引入,基础项目环境就这样。

然后,从入口出发(App.vue):

<template>
<el-button type="primary" v-for="type in componentList" :key="type" @click="openDialog(type)">
  {{ type }}
</el-button>
</template>

<script setup>
import { ElButton } from "element-plus";
import { componentMap } from "./components"; // 引入组件映射

/** @name 实例化所有组件 **/
const componentInstanceMap = Object.keys(componentMap).reduce((pre, key) => {
    const instance = new componentMap[key]();
    pre[key] = instance;
    return pre;
}, {});

/** @name 打开组件弹窗 **/
async function openDialog(type) {
  const component = await componentMap[type].DialogComponent.create(
      { type }, 
      componentInstanceMap[type]
  );
  console.log("component", component);
}
</script>


统一管理所有组件导出文件(components/index.js):

import PieChart from "./PieChart";
import BarChart from "./BarChart";
import LineChart from "./LineChart";

export const componentMap = {
  [PieChart.type]: PieChart,
  [BarChart.type]: BarChart,
  [LineChart.type]: LineChart,
};

/** @typedef { keyof componentMap } ComponentType */


组件入口文件(components/PieChart/index.js):

import BaseControl from "../../utils/BaseControl";
import Drag from "./Drag.vue";
import Dialog from "./Dialog";

class Component extends BaseControl {
  static type = "barChart";
  label = "柱状图"; 
  icon = "bar-chart";

  getDialogDataDefault() {
    return {
      title: { text: "柱状图" },
      tooltip: { trigger: "axis" },
    };
  }

  static DragComponent = Drag;
  static DialogComponent = Dialog;
}

export default Component;


该文件用于集中管理组件的核心数据结构与统一的业务逻辑。

咱们以柱状图为例哈。📊

所有组件的基类文件(utils/BaseControl.js):

/** @typedef { import('vue').Component|import('vue').ConcreteComponent } VueConstructor */

export default class BaseControl {
  /** @name 组件唯一标识 **/
  type = "baseControl";
  /** @name 组件label **/
  label = "未知组件";
  /** @name 组件高度 **/
  height = "110px";
  constructor() {
    if (this.constructor.type) {
      this.type = this.constructor.type;
    }
  }
  /**
   * @name 拖动组件
   * @type { VueConstructor | null }
   */
  static DragComponent = null;
  /**
   * @name 弹窗组件
   * @type { VueConstructor | null }
   */
  static DialogComponent = null;

  dialog = {};
  /**
   * @name 用于获取Dialog组件的默认数据
   * @returns {Dialog} 默认数据
   */
  getDialogDataDefault() {
    return {};
  }
}


该文件是所有组件的 "基石"🏛️,每个具体的图表组件都继承自 BaseControl 类,并在该基础上定义自己特有的信息和逻辑。

组件的拖动视图组件(Drag.vue),这个可以先随便整一个,暂时用不上:

<template>
  <div>某某组件的拖动视图组件</div>
</template>


Dialog 组件的入口文件(components/BarChart/Dialog/index.js):

import Panel from "./Panel.vue"; // Dialog 的 UI 视图组件
import { dialogWithComponent } from "../../../utils/dialog.js";

/**
 * @name 静态方法,渲染Dialog组件,并且可在此处自定义dialog组件的props
 * @param {{ component: object, instance: object, componentDataAll: Array<object> }} contentProps 组件数据
 * @returns {Promise<any>}
 */
Panel.create = async (panelProps = {}) => {
  return dialogWithComponent((render) => render(Panel, panelProps), {
    title: panelProps.label,
    width: "400px",
  });
};

export default Panel;


该文件导入真正的 UI 视图面板(Panel.vue),然后给组件挂载了一个静态 create 方法。这个 create 方法用于动态创建 Dialog 组件,它内部调用 dialogWithComponent 方法,并可以在此处预设一些该 Dialog 组件特有的配置(如默认标题、宽度)。

Dialog 组件的 Panel.vue 文件:

<template>
  <h1>柱状图的配置</h1>
</template>

<script setup>
defineExpose({
  async getValue() {
    await new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, 1000);
    });
    return { type: "barChart" };
  }
})
</script>


该组件仅放置柱状图特有的配置信息,并且不需要管弹窗自身的逻辑行为,很干净很专注😎。还有,它内部「必须」对外提供一个 getValue 方法❗用于在用户点击确认时调用,以获取最终的配置数据。

核心工具函数(utils/dialog.js)文件 :

import { createApp, h, ref } from "vue";
import { ElDialog, ElMessage } from "element-plus";
import BaseDialog from "../components/BaseDialog.vue";

/**
 * @name 协助统一创建dialog组件,并且进行挂载、销毁、上报
 * @param {import('vue').Component} ContentComponent 渲染的组件
 * @param {import('element-plus').dialogProps} dialogProps dialog组件的props
 * @returns {Promise<any>}
 */
export function dialogWithComponent(ContentComponent, dialogProps = {}) {
    return new Promise((resolve) => {
      /** @name 挂载容器 */
      const container = document.createElement("div");
      document.body.appendChild(container);
      /** @name dialog组件实例 */
      let vm = null;
      /** @name dialog组件loading */
      let loading = ref(false);
      const dialogRef = ref(null);
      const contentRef = ref(null);

      const unmount = () => {
        if (vm) {
          vm.unmount();
          vm = null;
        }
        document.body.removeChild(container);
      };
      const confirm = async () => {
        let result = {};
        const instance = contentRef.value;
        if (instance && instance.getValue) {
          loading.value = true;
          try {
                result = await instance.getValue();
          } catch (error) {
                typeof error === "string" && ElMessage.error(error);
                loading.value = false;
                return;
          }
          loading.value = false;
        }
        unmount();
        resolve(result);
      };

      // 创建dialog组件实例
      vm = createApp({
        render() {
          return h(
            BaseDialog,
            {
              ref: dialogRef,
              modelValue: true,
              loading: loading.value,
              onDialogConfirm() {
                confirm();
              },
              onDialogCancel() {
                unmount();
              },
              ...dialogProps,
            },
            {
              default: () => createVNode(h, ContentComponent, contentRef),
            },
          );
        },
      });

      // 挂载dialog组件
      vm.mount(container);
    });
}

/**
* @name 创建一个 VNode 实例
* @param {import('vue').CreateElement} h Vue 的 createElement 函数
* @param {Function|Element} Component 渲染的组件或渲染函数
* @param {string} key VNode 的 key
* @param {import('vue').Ref} ref 组件引用
* @returns {import('vue').VNode|null} 返回 VNode 实例或 null
*/
export function createVNode(h, Component, ref = null) {
    if (!Component) return null;
    /** @type { import('vue').VNode } */
    let instance = null;
    /** @name 升级h函数,统一混入ref **/
    const render = (type, props = {}, children) => {
      return h(
        type,
        {
          ...props,
          ref: (el) => {
            if (ref) ref.value = el;
          },
        },
        children,
      );
    };
    if (typeof Component === "function") {
      instance = Component(render);
    } else {
      instance = render(Component);
    }
    return instance;
}


dialogWithComponent 这个函数是整个架构的核心!它的职责就像一个专业的 Dialog "召唤师":

脑袋突然蹦出一句话:"去吧,就决定是你了,皮卡丘(柱状图)"🎯

    动态创建:不再需要在模板里预先写好 <el-dialog> 。 dialogWithComponent 会在你需要的时候,通过 createApp 和 h 函数,动态地创建一个包含 <el-dialog> 和你的内容组件的 Vue 应用实例。
    挂载与销毁:它负责将创建的 Dialog 实例挂载到 document.body 上,并在 Dialog 关闭(确认、取消或点击遮罩层)后,优雅地将其从 DOM 中移除并销毁 Vue 实例,避免内存泄漏。
    Promise 驱动:调用 dialogWithComponent 会返回一个 Promise。当用户点击 "确认" 并成功获取数据后,Promise 会调用 resolve 并返回数据;如果用户点击 "取消" 或 "关闭",Promise 会调用 reject 。这使得异步处理 Dialog 结果变得异常简洁,并且支持异步。
    配置注入:你可以轻松地向 dialogWithComponent 传递 <el-dialog> 的各种 props,实现 Dialog 的定制化。

createVNode 这个函数是 Vue 中 h 函数的升级版本,它主要是帮忙做内容的渲染🎩,它有两个小小的特点:

    组件 / 函数通吃:你可以直接传递一个 Vue 组件 ( .vue 文件或 JS/TS 对象) 给它,它会用 h 函数渲染这个组件。你还可以传递一个渲染函数!能让你在运行时动态决定渲染什么内容,简直不要太方便!是吧是吧。🤩
    Ref 传递:它巧妙地集中处理了 ref ,使得 dialogWithComponent 函数可以获取到内容组件的实例 (contentRef.value),从而能够调用内容组件暴露的方法(getValue),非常关键的一点。⏰

基础的 Dialog 组件文件(components/BaseDialog.vue):

<template>
<el-dialog v-bind="dialogAttrs">
    <slot></slot>
    <template v-if="showFooter" #footer>
        <span>
            <template v-if="!$slots.footer">
                <el-button @click="handleCancel">取消</el-button>
                <el-button type="primary" :loading="loading" @click="handleConfirm">确定</el-button>
            </template>
            <slot v-else ></slot>
        </span>
    </template>
</el-dialog>
</template>

<script setup>
import { useAttrs, computed } from "vue";
import { ElDialog, ElButton } from "element-plus";
defineProps({
    showFooter: {
        type: Boolean,
        default: true,
    },
    loading: {
        type: Boolean,
        default: false,
    }
});
const emit = defineEmits(["dialogCancel", "dialogConfirm"]);
const attrs = useAttrs();
const dialogAttrs = computed(() => ({
    ...attrs,
}));
function handleCancel() {
    emit("dialogCancel");
}
function handleConfirm() {
    emit("dialogConfirm");
}
</script>


那么,整个核心代码的实现过程大概就是如此了。不知道你看完这部分的拆解,是否有了新的收获呢?😋

当然啦,在实际的业务场景中,代码的组织和细节处理会更加复杂,比如会涉及到更精细的状态管理、错误处理、权限控制、以及各种边界情况的兼容等等。这里为了突出咱们动态创建 Dialog 架构的核心思想,小编仅仅是把最关键的脉络拎了出来,并进行了一定程度的精简。

总结

总而言之,言而总之,这次架构的演进,给小编最大的感受就是🏗️从 "各自为战" 到 "统一调度"。

告别了维护繁琐、数量庞大的单个 Dialog.vue 文件,转而拥抱了基于 createApp 和 h 函数的动态创建方式。

这种新模式下,基础 Dialog、配置面板 (Panel.vue)、以及调用逻辑各司其职,实现了真正的高内聚、低耦合。最终使得整个项目结构更加清晰、代码更加健壮,也极大地提升了后续的可维护性。希望这套方案能给你带来一些启发!

最后,如果你有任何疑问或者更好的想法,随时欢迎交流哦!👇

至此,本篇文章就写完啦,撒花撒花。


关注更多AI编程资讯请去AI Coding专区:https://juejin.cn/aicoding

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Vue 弹窗 组件化 架构设计
相关文章