1. 快速开始
通过 useCommandComponent,你可以像调用函数一样打开一个弹窗,而无需在模板中写 <Dialog /> 标签。
import { useCommandComponent } from './composables/useCommandComponent'
import MyDialog from './components/MyDialog.vue'
// 1. 创建弹窗构造函数
const showDialog = useCommandComponent(MyDialog)
// 2. 调用函数打开弹窗
showDialog({
title: '提示',
content: '这是一个命令式弹窗',
onClosed: (result) => {
console.log('弹窗关闭,返回结果:', result)
}
})
2. 两种核心用法
模式 A:Props 驱动(推荐简单场景)
适用于表单提交、确认框等一次性交互。你只需要传入参数并监听关闭回调。
组件定义 (ConfirmDialog.vue):
<template>
<el-dialog :model-value="visible" :title="title" @closed="handleClosed">
<p>{{ content }}</p>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
defineProps(['visible', 'title', 'content'])
const emit = defineEmits(['closed'])
const handleConfirm = () => emit('closed', { action: 'confirm' })
const handleCancel = () => emit('closed', { action: 'cancel' })
const handleClosed = () => emit('closed', { action: 'close' })
</script>
调用方式:
const ConfirmDialog = useCommandComponent(ConfirmDialog)
ConfirmDialog({
title: '删除确认',
content: '确定要删除这条数据吗?',
onClosed: (res) => {
if (res.action === 'confirm') deleteItem()
}
})
模式 B:Expose 驱动(推荐复杂交互)
适用于多步骤向导、需要外部触发更新或获取内部状态的复杂弹窗。
组件定义 (WizardDialog.vue):
<template>
<el-dialog v-model="internalVisible" title="向导">
<div>当前步骤: {{ step }}</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const internalVisible = ref(false)
const step = ref(1)
// 暴露方法给外部调用
const open = (options) => {
internalVisible.value = true
step.value = options.startStep || 1
}
defineExpose({ open })
</script>
调用方式:
const WizardDialog = useCommandComponent(WizardDialog)
const dialogInstance = WizardDialog() // 此时弹窗未显示
dialogInstance.open({ startStep: 2 }) // 手动控制打开并传参
3. 常用配置项
| 属性 | 类型 | 说明 |
|---|---|---|
visible | Boolean | 默认为 true,控制弹窗显隐 |
appendTo | String/HTMLElement | 挂载点,默认为 body |
onClosed | Function | 弹窗完全关闭(动画结束)后的回调 |
4. 核心源码实现
你可以直接将以下代码保存为 useCommandComponent.js。它封装了 Vue 3 的底层渲染逻辑,支持自动挂载、上下文传递以及实例方法暴露。
import { createVNode, getCurrentInstance, render } from 'vue'
export const useCommandComponent = (Component) => {
// 1. 获取当前实例,提取 appContext 和 provides
const instance = getCurrentInstance()
let appContext = null
if (instance) {
// 创建一个关联的上下文对象
// 目的:把父组件的 provides 传进去,否则动态渲染的弹窗会读不到父级数据
appContext = {...instance.appContext}
appContext.provides = instance.provides
}
// 2. 确定弹窗应该挂载到哪个 DOM 节点(默认是 body)
const getAppendToElement = (props) => {
let appendTo = document.body
if (props.appendTo) {
if (typeof props.appendTo === 'string') appendTo = document.querySelector(props.appendTo)
else if (props.appendTo instanceof HTMLElement) appendTo = props.appendTo
if (!(appendTo instanceof HTMLElement)) appendTo = document.body
}
return appendTo
}
// 3. 创建虚拟节点并渲染到临时容器中
const initInstance = (Component, props, container, appContext = null) => {
const vNode = createVNode(Component, props)
vNode.appContext = appContext // 将准备好的上下文传给组件
render(vNode, container)
getAppendToElement(props).appendChild(container)
return vNode
}
const container = document.createElement('div')
// 4. 关闭函数:卸载组件实例并从 DOM 中移除容器
const closed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
const CommandComponent = (options = {}) => {
// 默认设置弹窗为显示状态
if (!Reflect.has(options, 'visible')) options.visible = true
// 包装 onClosed 回调:确保动画结束后再执行 DOM 清理
if (typeof options.onClosed !== 'function') {
options.onClosed = closed
} else {
const originOnClosed = options.onClosed
options.onClosed = (...args) => {
originOnClosed(...args)
closed()
}
}
const vNode = initInstance(Component, options, container, appContext)
// 5. 返回一个代理对象,实现对组件实例的灵活控制
return new Proxy(vNode, {
get(target, prop) {
if (prop === 'closed') return closed // 允许外部调用 .closed()
const exposed = target.component?.exposed
if (exposed && Reflect.has(exposed, prop)) {
return Reflect.get(exposed, prop) // 允许访问 defineExpose 暴露的方法
}
return Reflect.get(target, prop)
},
has(target, prop) {
if (prop === 'closed') return true
const exposed = target.component?.exposed
if (exposed && Reflect.has(exposed, prop)) return true
return Reflect.has(target, prop)
}
})
}
CommandComponent.closed = closed
return CommandComponent
}
5. 源码分段解析
为了让你更清楚每一块代码的作用,我们将上面的源码拆分为三个核心部分:
第一部分:上下文准备
const instance = getCurrentInstance()
let appContext = null
if (instance) {
appContext = {...instance.appContext}
appContext.provides = instance.provides
}
说明: 这一步是为了拿到父组件的 provides。因为弹窗是动态创建的,如果不手动把父级的数据传给它,弹窗里的 inject 就会失效。
第二部分:DOM 挂载与清理
const initInstance = (Component, props, container, appContext = null) => {
const vNode = createVNode(Component, props)
vNode.appContext = appContext
render(vNode, container)
getAppendToElement(props).appendChild(container)
return vNode
}
const closed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
说明: 这里利用 createVNode 和 render 手动把组件渲染到一个临时的 div 里,然后把这个 div 塞进页面。关闭时则反向操作,彻底销毁组件。
第三部分:代理与交互控制
return new Proxy(vNode, {
get(target, prop) {
if (prop === 'closed') return closed
const exposed = target.component?.exposed
if (exposed && Reflect.has(exposed, prop)) return Reflect.get(exposed, prop)
return Reflect.get(target, prop)
},
// ... has 拦截器
})
说明: 使用 Proxy 是为了让返回值既能当普通对象用(访问 expose 的方法),又能直接调用 .closed() 来关闭弹窗,使用起来非常灵活。