像ElMessage一样编程式调用Vue组件

837 阅读8分钟

Element的MessageBox组件Message Box 信息弹窗框可以让我们以编程式调用一个即用即走的弹窗,免去了在<template>中定义一个el-doalog再通过一堆变量去控制的麻烦。

有时候,UI同学需要一个有自己系统风格的MessageBox,要是在每处用到的地方都在<template>中写一套弹窗,那也未免太麻烦了,接下来让我们来实现一个自己的可以编程式调用的Vue组件。

1. 准备一个Vue弹窗组件MyMessageBox.vue

MessageBoxProp只是一个类型定义,第二节中有,这里先不关注

<script setup lang="ts">
import {ref} from "vue";
import {MessageBoxProp} from "./index";

const emit = defineEmits<{
  confirm: [], // 点击确认按钮
  closed: [],// 当组件被关闭后
}>()
withDefaults(defineProps<MessageBoxProp>(), {
  title: '提示',
})

// 使默认值为true,这样只要该组件被挂载到页面中就会显示出来
const visible = ref(true)
</script>

<template>
  <el-dialog v-model="visible" :title="title" @close="emit('closed')">
    <h3>{{ message }}</h3>

    <template #footer>
      <el-button type="primary" @click="emit('confirm')">确认</el-button>
      <el-button @click="visible = false">取消</el-button>
    </template>
  </el-dialog>
</template>

现在这个组件只要引入到<template>中就会被展示出来。但我们想要的是JS调用方式。

2. 写一个函数来实现当函数调用时渲染该组件到页面中

index.ts

import {createApp} from "vue";
import MyMessageBox from './MyMessageBox.vue'

export interface MessageBoxProp {
  title?: string;
  message: string;
}

export const confirmMessageBox = (props: MessageBoxProp) => {
  // 创建一个容器,用来装vue渲染的内容
  const div = document.createElement('div')
  // 创建App实例
  // 第二个参数是将传递给实例的props参数
  const app = createApp(MyMessageBox, {
    title: props.title,
    message: props.message,
  })
  // 将实例渲染到容器中
  app.mount(div)
  // 将渲染出的结果追加到页面
  document.body.appendChild(div)
}

需要注意的是: 由于我们创建了一个新的app,此时这个app中并没有那些在项目入口处全局挂载的内容,所以直接调用此函数将报错:无法找到el-dialog组件等等.
所以需要把那些在编程式调用的组件中用到的全局的组件/指令等也挂载到这个app中,比如:

 app.use(ElementPlus)

但是, 这样未免太重了些,更好的方式是直接在我们的组件中单独引用它们:

import {ElDialog, ElButton} from "element-plus";

知识点

  • createApp()的第二个参数可以为根组件提供props
  • 每一个app的context都是独立的,需要单独挂载全局内容。

3. 在JS代码中调用该函数

然后编写代码在组件App.vue中调用这个函数

<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";

const remove = () => {
  confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'})
}
</script>

<template>
  <el-button @click="remove">删除</el-button>
</template>

点击按钮: chrome-capture-2023-7-27 (2).gif 可以看到组件成功被渲染并插入到body中,但是当关闭弹窗后dom并没有被卸载,并且无法在点击了确认按钮后执行调用API等事件。

4.关闭弹窗时移除dom、点击确认时执行外部传入的回调

只需要改造下createApp的第二个参数,并且给函数参数添加confirm声明:

export const confirmMessageBox = (props: MessageBoxProp & { confirm: () => void }) => {
  // 创建一个容器,用来装vue渲染的内容
  const div = document.createElement('div')
  // 创建App实例
  // 第二个参数是将传递给实例的props参数
  const app = createApp(MyMessageBox, {
    title: props.title,
    message: props.message,
    onConfirm: () => {
      // 外部传入的确认回调
      props.confirm()
    },
    onClosed: () => {
      document.body.removeChild(div)
    }
  })
  // 将实例渲染到容器中
  app.mount(div)
  // 将渲染出的结果追加到页面
  document.body.appendChild(div)
}

知识点:

  • createApp()中的根组件的props可以在第二个参数中直接传递,根组件的emit回调事件则需要改变为onXxx的形式。

然后在调用的地方添加confirm处理函数:
index.ts

<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";

const remove = () => {
  confirmMessageBox({
    title: '确认删除?',
    message: '您确定要删除该数据?',
    confirm: () => {
      console.log('删掉它!')
    }
  })
}
</script>

<template>
  <el-button @click="remove">删除</el-button>
</template>

现在组件就会在关闭时移除dom,点击确认也会执行传入的处理函数。

chrome-capture-2023-7-27 (3).gif

你可能已经注意到,弹窗在出现时是有动画的,但是移除时立即就被删除了,这太不优雅。

  • 方式一: 简单的解决办法是直接添加一个延时:
const app = createApp(MyMessageBox, {
  // ...
  onClosed: () => {
    setTimeout(() => {
      document.body.removeChild(div)
    }, 300)
  }
})
  • 方式二:更好的办法是直接使用el-dialogclosed事件而不是close事件:
    closed会在弹窗完全关闭、动画也执行完成后调用。close会在弹窗开始关闭时调用,此时动画还没执行完毕。

修改MyMessageBox.vue:

<template>
<!-- 使用@closed来保证弹窗关闭动画结束后再移除dom -->
  <el-dialog v-model="visible" :title="title" @closed="emit('closed')">
    <h3>{{ message }}</h3>

    <template #footer>
      <el-button type="primary" @click="emit('confirm')">确认</el-button>
      <el-button @click="visible = false">取消</el-button>
    </template>
  </el-dialog>
</template>

现在组件的加载、关闭、确认回调都已经完成了。

5. 弹窗的关闭时机

现在,当点击取消或X时弹窗就会关闭并从dom中移除。
点击确认按钮就会打印"删掉它!",现在还没有后续处理,所以点击确认后弹窗还不会关闭。
可以在onConfirm中直接关闭弹窗,不过多数场景下我们不应该在onConfirm中关闭弹窗,考虑这种场景: 点击确认后调用删除API,API成功了则关闭弹窗,失败了则保持弹窗的打开状态以方便用户重试。

所以需要将弹窗的关闭回调交给调用它的函数,由外部的函数来决定何时关闭弹窗:

  1. 首先在MyMessageBox.vue中对外暴露关闭弹窗的方法
defineExpose({
  hide: () => {
    visible.value = false
  }
})
  1. 然后将一个调用组件hide方法的回调提供给外部调用者:
export const confirmMessageBox = (props: MessageBoxProp & { confirm: (done: () => void) => void }) => {
  // 创建一个容器,用来装vue渲染的内容
  const div = document.createElement('div')
  // 根组件的实例
  let instance
  // 创建App实例
  // 第二个参数是将传递给实例的props参数
  const app = createApp(MyMessageBox, {
    title: props.title,
    message: props.message,
    onConfirm: () => {
    
    
      // !!!!变化在这里 !!!!
      // 外部传入的确认回调
      props.confirm(() => {
        instance?.hide()
      })
      
      
    },
    onClosed: () => {
      document.body.removeChild(div)
    }
  })
  // 将实例渲染到容器中
  instance = app.mount(div)
  // 将渲染出的结果追加到页面
  document.body.appendChild(div)
}

知识点:

  • app.mount()会返回根组件的实例,等同于对该组件的模板引用ref="xxx"。通过这个实例可以和组件进行通信,传递值或使用组件暴露的方法。
  1. 调用者自由决定在合适的时机关闭弹窗
const remove = () => {
  confirmMessageBox({
    title: '确认删除?',
    message: '您确定要删除该数据?',
    confirm: (done) => {
      console.log('删掉它!')
      
      
      // 调用API或其他什么事,使用confirm的回调参数在合适的时机关闭弹窗
      done()
      
      
    }
  })
}

完成!

上述代码可以满足大多数场景。 不过还可以让代码更清晰些。

完整代码在最后。

追加: 将函数改写为一个可以链式调用的方法

一般的使用回调方式就够了,但也可以将编程式调用改为链式调用,代码结构更好清晰些。

tips

也可以是一个Promise,不过这时then代码块中就不能执行异步代码了

 confirmMessageBox()
   .then(done=>{
     // 执行异步逻辑
     done()
   })
   .catch(()=>{
     // ...
   })

因为点击确认后进入then代码块,如果then块中的异步失败了需要重试, 此时由于promise已经完成,代码不可能再次执行then中的异步了。

所以这个场景还是使用链式调用方式好些。

组件代码不用动,函数改为:

type ConfirmCallback = (done: () => void) => void // 确认回调
type CancelCallback = () => void // 取消回调
type MessageBox = {
  confirm: (cb: ConfirmCallback) => MessageBox,
  cancel: (cb: CancelCallback) => MessageBox
}


export const confirmMessageBox = (props: MessageBoxProp): MessageBox => {
  // 回调事件池
  const confirmEvents: ConfirmCallback[] = []
  let cancelEvents: CancelCallback[] = []

  // 创建一个容器,用来装vue渲染的内容
  const div = document.createElement('div')
  // 根组件的实例
  let instance
  // 创建App实例
  // 第二个参数是将传递给实例的props参数
  const app = createApp(MyMessageBox, {
    title: props.title,
    message: props.message,
    onConfirm: () => {
      // 执行确认事件池
      confirmEvents.forEach(cb => {
        cb(() => {
          instance?.hide()
          // 清空取消事件池,已经确认了,不需要取消事件了
          cancelEvents = []
        })
      })
    },
    onClosed: () => {
      document.body.removeChild(div)
      // 执行取消事件池
      cancelEvents.forEach(cb => {
        cb()
      })
    }
  })
  // 将实例渲染到容器中
  instance = app.mount(div)
  // 将渲染出的结果追加到页面
  document.body.appendChild(div)


  return {
    confirm(cb: ConfirmCallback) {
      confirmEvents.push(cb)
      return this
    },
    cancel(cb: CancelCallback) {
      cancelEvents.push(cb)
      return this
    }
  }
}

函数调用改为:

confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'}).confirm((done) => {
  console.log('删掉它!')
  if (time !== 0) {
    // 调用API或其他什么事,在合适的时机关闭弹窗
    done()
    ElMessage.success('API重试成功,弹窗关闭')
  } else {
    ElMessage.error('模拟API失败')
  }
  time++
}).cancel(() => {
  console.log('取消了')
})

chrome-capture-2023-9-10.gif

完整代码

MyMessageBox.vue

<script setup lang="ts">
import {ref} from "vue";
import {MessageBoxProp} from "./index";
import {ElDialog, ElButton} from "element-plus";

const emit = defineEmits<{
  confirm: [], // 点击确认按钮
  closed: [],// 当组件被关闭后
}>()
withDefaults(defineProps<MessageBoxProp>(), {
  title: '提示',
})

// 使默认值为true,这样只要该组件被挂载到页面中就会显示出来
const visible = ref(true)

defineExpose({
  hide: () => {
    visible.value = false
  }
})
</script>

<template>
  <el-dialog v-model="visible" :title="title" @closed="emit('closed')">
    <h3>{{ message }}</h3>

    <template #footer>
      <el-button type="primary" @click="emit('confirm')">确认</el-button>
      <el-button @click="visible = false">取消</el-button>
    </template>
  </el-dialog>
</template>

index.ts

import {createApp} from "vue";
import MyMessageBox from './MyMessageBox.vue'

export interface MessageBoxProp {
  title?: string;
  message: string;
}

type ConfirmCallback = (done: () => void) => void // 确认回调
type CancelCallback = () => void // 取消回调
type MessageBox = {
  confirm: (cb: ConfirmCallback) => MessageBox,
  cancel: (cb: CancelCallback) => MessageBox
}

/**
 * @example
 *  confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'})
 *    .confirm(done => {
 *      // 在合适的时机关闭弹窗
 *      done()
 *    })
 *    .confirm(done => {
 *      // confirm可以注册多个
 *    })
 *    .cancel(() => {
 *      // confirm也可以注册多个
 *    })
 *
 */
export const confirmMessageBox = (props: MessageBoxProp): MessageBox => {
  // 回调事件池
  const confirmEvents: ConfirmCallback[] = []
  let cancelEvents: CancelCallback[] = []

  // 创建一个容器,用来装vue渲染的内容
  const div = document.createElement('div')
  // 根组件的实例
  let instance
  // 创建App实例
  // 第二个参数是将传递给实例的props参数
  const app = createApp(MyMessageBox, {
    title: props.title,
    message: props.message,
    onConfirm: () => {
      // 执行确认事件池
      confirmEvents.forEach(cb => {
        cb(() => {
          instance?.hide()
          // 清空取消事件池,已经确认了,不需要取消事件了
          cancelEvents = []
        })
      })
    },
    onClosed: () => {
      document.body.removeChild(div)
      // 执行取消事件池
      cancelEvents.forEach(cb => {
        cb()
      })
    }
  })
  // 将实例渲染到容器中
  instance = app.mount(div)
  // 将渲染出的结果追加到页面
  document.body.appendChild(div)


  return {
    confirm(cb: ConfirmCallback) {
      confirmEvents.push(cb)
      return this
    },
    cancel(cb: CancelCallback) {
      cancelEvents.push(cb)
      return this
    }
  }
}

App.vue

<script setup lang="ts">
import {confirmMessageBox} from "./components/message-box/index";
import {ElMessage} from "element-plus";

const remove = () => {
  let time = 0
  confirmMessageBox({title: '确认删除?', message: '您确定要删除该数据?'}).confirm((done) => {
    console.log('删掉它!')
    if (time !== 0) {
      // 调用API或其他什么事,在合适的时机关闭弹窗
      done()
      ElMessage.success('API重试成功,弹窗关闭')
    } else {
      ElMessage.error('模拟API失败')
    }
    time++
  }).cancel(() => {
    console.log('取消了')
  })
}
</script>

<template>
  <el-button @click="remove">删除</el-button>
</template>

最后

这个示例简单的介绍了如何与App实例进行通信。通过通信手段,可以在js中给组件传数据、接受vue组件的事件、调用vue组件的方法。

除了二次确认弹窗,这也可以应用于在Echarts的Tooltip功能中使用Vue组件。请移步: 在ECharts的tooltip中使用Vue组件,这里面还介绍了用customElement的方式来实现编程式调用。

与组件通信的方式还有不少,比如用vuex/pinia、用esm的特性等等