跳转到内容

二次封装 el-dialog 学习 Vue 重复小组件处理经验

刀刀
0字
0分钟
2026/8/16

重复小组件处理经验

场景模拟

现有一个项目需求需要使用提示弹窗组件,多个页面组件需要使用。

该弹窗组件在封装时需注意:

  1. 弹窗显示时机不一致,可能在 A 页面一进入就显示,在 B 页面点击按钮显示
  2. 弹窗同意或拒绝按钮执行的后续不一致,拒绝隐藏弹窗,同意调用接口,后续操作看各自组件需求
  3. 弹窗组件内部内容不一致

如果直接使用多个组件,页面上就需要写很多个组件,声明很多个控制显隐的变量,代码冗余且不易维护。

render 方案

把弹窗封装为一个组件,点击行为传给组件,通过 v-if 控制显隐。

缺点:每次都得写组件显隐控制逻辑,还得引入、注册弹窗组件。

希望能像 Element Plus 的 ElMessageBox 方法一样,调用方法就能弹出弹窗。

实现思路:

  1. createVnode 或 JSX 编写组件结构
  2. render 方法渲染在一个 div
  3. appendChild 方法加入
js
import { createVNode, render } from 'vue'

export const signProp = (content) => {
  // 创建虚拟dom,参数一:dom标签;参数二:dom属性,包括class类名、id、style样式等;参数三:内容,可为数字文本,也可为虚拟dom
  let pop = createVNode('div', {
    class: 'divcover'
  }, {
    createVNode('div', {
      class: 'popcontent'
    }, content)
  })

  // 参数一:要渲染的虚拟dom;参数二,要渲染到那个真实dom上
  render(pop, document.body)
}

现在页面上就有无样式效果的弹窗了。但这种方法不推荐,更推荐使用 JSX。

jsx 组件标签方案

jsx
import { render } from 'vue'

export const signProp = (content) => {
  let pop = (
    <div class="cover">
      <div class="covercontent">
        <div>{content}</div>
        <div>
          <button
            onClick={() => {
              document.body.removeChild(pop.el) // 这里需要真实dom,虚拟dom会报错
            }}
          >
            不同意
          </button>
          <button>确认签署</button>
        </div>
      </div>
    </div>
  )

  // 参数一:要渲染的虚拟dom;参数二,要渲染到那个真实dom上
  render(pop, document.body)
}

这么写点击按钮后页面能生成对应 DOM,点击不同意按钮也能卸载。但再次点击按钮后不再生成 DOM,因为 render 函数只会执行一次,执行完后虽然页面真实 DOM 被删除了,但 render 认为已挂载,就不再执行。

参考一下 Element UI 的方法修改。

jsx
import { render } from 'vue'

export const signProp = (content, handler) => {
  let div = document.createElement('div') 
  let pop = (
    <div class="dialog-cover">
      <div class="dialog-cover-content">
        <div class="content">{content}</div>
        <div class="btns">
          <button
            onClick={() => {
              document.body.removeChild(div) // 这里需要真实dom,虚拟dom会报错
              handler.cancel && handler.cancel() 
            }}
          >
            不同意
          </button>
          <button
            onClick={() => {
              document.body.removeChild(div) // 这里需要真实dom,虚拟dom会报错
              handler.confirm && handler.confirm() 
            }} 
          >
            确认签署
          </button>
        </div>
      </div>
    </div>
  )

  // 参数一:要渲染的虚拟dom;参数二,要渲染到那个真实dom上
  render(pop, div) 
  document.body.appendChild(div)
}

它是通过原生 DOM 来加入到 body 内,这样就不会管虚拟 DOM 是否挂载。

现在可以在各个需要的场景使用该方法了。

vue
<script setup>
import { signProp } from './signProp.jsx'
</script>

<template>
  <button
    @click="
      signProp('我是内容文本', {
        confirm: () => {},
        cancel: () => {},
      })
    "
  >
    click me
  </button>
</template>

需注意的是,有些场景可能用不到 cancel 之类的按钮点击事件,没有传对应函数方法,需要有良好的代码健壮性意识,添加非空判断,避免代码报错。

h 与 createApp 方案

这个写法与前面的 JSX 方案类似,只不过不使用标签方式,而是使用 Vue 提供的 hcreateApp 方式。

首先声明一个函数,接收三个参数:第一个参数是弹窗内部要渲染的组件(这里以 Element Plus 组件的弹窗为例),第二个参数是内部组件的 props,第三个参数是弹窗组件的 props。通过 h 创建完虚拟 DOM 后,再通过 createApp 创建一个 Vue 实例,最后通过 mount 方法挂载到 body 上。

ts
import { createApp, h } from 'vue'
import { ElDialog } from 'element-plus'

export function signProp({ component, props, modalProps }) {
  const dialog = h(
    ElDialog,
    { ...modalProps, modelValue: true },
    { default: () => h(component, props) },
  )

  const app = createApp(dialog)

  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
vue
<script setup>
import signProp from './signProp.ts'
import LoginForm from './LoginForm.vue'

const clickFn = () => {
  signProp({
    component: LoginForm,
    props: {
      msg: '欢迎来到xx系统',
    },
    modalProps: {
      title: '登录',
    },
  })
}
</script>

<template>
  <a-button @click="clickFn"></a-button>
</template>
vue
<script setup lang="ts">
import { reactive, ref } from 'vue'

withDefaults(
  defineProps<{
    msg: string
  }>(),
  {
    msg: '请登录',
  },
)

const formData = reactive({
  username: '',
  password: '',
})

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 5, message: '长度在 3 到 5 个字符', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 3, max: 5, message: '长度在 3 到 5 个字符', trigger: 'blur' },
  ],
}

const formRef = ref(null)

const submit = () => {
  return new Promise((resolve, reject) => {
    formRef.value.validate((valid: boolean) => {
      if (valid) {
        resolve(valid)
      } else {
        reject(valid)
      }
    })
  })
}

defineExpose({
  submit,
})
</script>

<template>
  <el-form ref="formRef" :model="formData" :rules="rules">
    <p class="mb-20">{{ msg }}</p>
    <el-form-item label="账号" prop="username">
      <el-input v-model="formData.username"></el-input>
    </el-form-item>
    <el-form-item label="密码" prop="password">
      <el-input v-model="formData.password"></el-input>
    </el-form-item>
  </el-form>
</template>

执行后发现页面只打开了 dialog 弹窗,没有 form 表单组件。查看控制台发现有警告说组件找不到。这是因为 createApp 创建的实例需要 use 方法来注册组件和方法(如组件库组件、piniarouter),之前 template 的写法用的是 main.ts 入口文件的 createApp 实例,组件和方法都已注册;但这里是新建了一个 createApp 实例,没有注册其他组件库的组件,所以报警告。

解决方法是,在 signProp 函数中通过 app.use 方法来注册组件库的组件。可一个个复制粘贴过来,如果代码量少这样很方便,但代码量多会很繁琐,且不利于后续维护,更好的方法是写成一个函数,在函数内注册挂载。

ts
import Antd from 'ant-design-vue'
export function loadPlugin(app) {
  app.use(Antd) 
} 
ts
import { createApp, h } from 'vue'
import { ElDialog } from 'element-plus'
import { loadPlugin } from './plugin.ts'

export function signProp({ component, props, modalProps }) {
  const dialog = h(
    ElDialog,
    { ...modalProps, modelVAlue: true },
    { default: () => h(component, props) },
  )

  app.createApp(dialog)
  loadPlugin(app) 

  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

现在能正常渲染组件了,但是点击关闭按钮后发现无法生效,这是因为弹框开启的变量 visible 设为 true ,需要修改为一个响应式变量。修改为响应式变量后,点击关闭按钮发现还是不生效,这是因为响应式变量想要工作,需要作为订阅者 effect 函数的依赖项,因此这里需要调整为一个函数的形式。

signProp.ts
ts
import { createApp, h } from 'vue'
import { ElDialog } from 'element-plus'
import { loadPlugin } from './plugin.ts'

export function signProp(component, props, modalProps) {
  const visible = ref(true) 
  const dialog = () =>
    h(
      ElDialog,
      {
        ...modalProps,
        modelValue: visible.value, 
      },
      {
        default: () => h(component, props),
        footer: () =>
          h('div', {}, [
            h(
              ElButton, 
              {
                onClick() {
                  unmount() 
                }, 
              }, 
              { default: () => '取消' }, 
            ), 
            h(
              ElButton, 
              {
                type: 'primary', 
                onClick() {
                }, 
              }, 
              { default: () => '确认' }, 
            ), 
          ]), 
      },
    )

  app.createApp(dialog)
  loadPlugin(app)

  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)

  function unmount() {
    visible.value = false
    setTimeout(() => {
      // 组件关闭后再卸载组件,保留弹框的关闭动画
      app.unmount() 
      document.body.removeChild(div) 
    }, 300) 
  } 
}

现在关闭功能实现了,该来实现点击确定按钮的功能了,对于表单而言,点击确定按钮后,需要实现表单校验、调用接口等功能,这些功能是组件 compoennt 内部就封装好了的,需要考虑的是如何调用该组件的方法。

声明一个变量,在 h 函数第三个参数中的 h 函数的第二个参数添加一个 ref,后续可以通过这个变量拿到组件内部的方法。

signProp.ts
ts
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
import { loadPlugin } from "./plugin.ts";

export function signProp{( component, props, modalProps }) {
  const visible = ref(true);
  const instanceRef = ref(); 
  const dialog = () =>
    h(
      ElDialog,
      modelValue: visible.value,
      {
        ...modalProps,
      },
      {
        default: () => h(component, props), 
        default: () => h(component, { ref: instanceRef, ...props }), 
        footer: () => h('div', {}, [
          h(
            ElButton,
            {
              onClick() {
                unmount()
              }
            },
            { default: () => '取消' }
          ),
          h(
            ElButton,
            {
              type: 'primary',
              async onClick() {
                try { 
                  await instanceRef.value.submit() 
                  unmount() 
                } 
                catch (error) { 
                  console.log(error) 
                } 
              }
            },
            { default: () => '确认' }
          ),
        ])
      }
    );

  app.createApp(dialog);
  loadPlugin(app);

  const div = document.createElement("div");
  document.body.appendChild(div);
  app.mount(div);


  function unmount() {
    visible.value = false;
    setTimeout(() => {
      // 组件关闭后再卸载组件,保留弹框的关闭动画
      app.unmount();
      document.body.removeChild(div);
    }, 300);
  }
}

最后,可以添加按钮 loading 效果等优化。如果想要暴露出去让父组件也能使用内部的变量和方法,可以 return 出去。

signProp.ts
ts
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
import { loadPlugin } from "./plugin.ts";

export function signProp(( component, props, modalProps, onComfirm = () => {} )) { 
  const visible = ref(true);
  const instanceRef = ref();
  const loading = ref(false); 
  const dialog = () =>
    h(
      ElDialog,
      {
        ...modalProps,
        modelValue: visible.value,
      },
      {
        default: () => h(component, { ref: instanceRef, ...props }),
        footer: () => h('div', {}, [
          h(
            ElButton,
            {
              onClick() {
                unmount()
              }
            },
            { default: () => '取消' }
          ),
          h(
            ElButton,
            {
              type: 'primary',
              loading: loading.value, 
              async onClick() {
                loading.value = true
                try {
                  await instanceRef.value.submit()
                  await onComfirm() // 通过表单校验后做其他操作
                  unmount()
                }
                catch (error) {
                  console.log(error)
                }
                finally { 
                  loading.value = false
                } 
              }
            },
            { default: () => '确认' }
          ),
        ])
      }
    );

  app.createApp(dialog);
  loadPlugin(app);

  const div = document.createElement("div");
  document.body.appendChild(div);
  app.mount(div);

  function unmount() {
    visible.value = false;
    setTimeout(() => {
      // 组件关闭后再卸载组件,保留弹框的关闭动画
      app.unmount();
      document.body.removeChild(div);
    }, 300);
  }

  return { instanceRef, unmount }; 
}

动手实操

跳转预览:点击跳转

贡献者

The avatar of contributor named as duyidao duyidao
The avatar of contributor named as v_duyilin v_duyilin
The avatar of contributor named as 刀刀 刀刀
The avatar of contributor named as 平安喜乐 平安喜乐

页面历史

刀刀博客累计访客 人;文档累计访问量共